Files
buzz/crates/buzz-cli/src/commands/channels.rs
Cameron Hotchkies f8f2ef0440 feat(cli): add --visibility flag to channels update (#5119)
## Why
`buzz channels update` could already change name, description, and TTL,
but the SDK/relay/DB path for channel visibility was unreachable from
the CLI.

## What
- Add `--visibility open|private` to `buzz channels update`
- Pass the visibility value through to `build_update_channel`
- Add guard tests proving empty updates still fail and visibility-only
updates are accepted

## Risk Assessment
Low — this is limited to the buzz-cli update command and uses existing
SDK validation plus existing relay/DB handling.

## References
- Spike notes: `RESEARCH/SPIKE_CHANNEL_VISIBILITY_TOGGLE.md`
- Local validation: `cargo test -p buzz-cli`

Generated with Codex

Signed-off-by: Cameron Hotchkies <chotchkies@block.xyz>
Co-authored-by: Lazy Joe <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@buzz.block.builderlab.xyz>
2026-08-10 14:48:30 -07:00

1745 lines
65 KiB
Rust

use std::collections::HashSet;
use buzz_core::kind::{KIND_MANAGED_AGENT, KIND_TEAM};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::client::{
extract_d_tag, extract_p_tags, extract_tag_value, normalize_write_response,
print_create_response, BuzzClient,
};
use crate::commands::agents::fetch_archived_snapshot;
use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"channel_id": extract_d_tag(e),
"name": extract_tag_value(e, "name"),
"description": extract_tag_value(e, "about"),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
}
pub async fn cmd_list_channels(
client: &BuzzClient,
visibility: Option<&str>,
member: Option<bool>,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let effective_limit = limit.unwrap_or(500);
let events = if member == Some(true) {
// Step 1: find channel IDs where we're a member (kind:39002)
let my_pk = client.keys().public_key().to_hex();
let member_filter = serde_json::json!({
"kinds": [39002],
"#p": [my_pk],
});
let member_events = client
.query_paginated(member_filter, effective_limit)
.await?;
let channel_ids: Vec<String> = member_events
.iter()
.map(extract_d_tag)
.filter(|id| !id.is_empty())
.collect();
if channel_ids.is_empty() {
println!("[]");
return Ok(());
}
// Step 2: fetch kind:39000 metadata for those channels.
let metadata_filter = serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
});
client
.query_paginated(metadata_filter, effective_limit)
.await?
} else {
let filter = serde_json::json!({
"kinds": [39000],
});
client.query_paginated(filter, effective_limit).await?
};
let channels: Vec<serde_json::Value> = events
.iter()
.filter(|e| {
if let Some(vis) = visibility {
// NIP-29: relay emits ["public"] or ["private"] single-element tags
let nip29_tag = match vis {
"open" => "public",
_ => vis,
};
e.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter().any(|tag| {
tag.as_array()
.map(|a| {
a.len() == 1
&& a.first().and_then(|v| v.as_str()) == Some(nip29_tag)
})
.unwrap_or(false)
})
})
.unwrap_or(false)
} else {
true
}
})
.map(extract_channel_metadata)
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = channels
.iter()
.map(|c| {
serde_json::json!({
"channel_id": c.get("channel_id").cloned().unwrap_or_default(),
"name": c.get("name").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
/// Search channels by human-readable name (kind:39000 group metadata).
///
/// The relay's access control already filters out channels the caller can't see
/// (private channels they're not a member of), so we just post-filter the
/// returned events by name and project them into a stable JSON shape.
pub async fn cmd_search_channels(
client: &BuzzClient,
query: &str,
exact: bool,
include_archived: bool,
limit: u32,
) -> Result<(), CliError> {
if query.trim().is_empty() {
return Err(CliError::Usage("--query cannot be empty".into()));
}
let filter = serde_json::json!({
"kinds": [39000],
});
let arr = client.query_paginated(filter, limit).await?;
let needle = query.to_ascii_lowercase();
let mut matches: Vec<ChannelSummary> = arr
.iter()
.filter_map(ChannelSummary::from_event)
.filter(|c| if include_archived { true } else { !c.archived })
.filter(|c| name_matches(&c.name, &needle, exact))
.collect();
matches.sort_by(|a, b| {
a.name
.cmp(&b.name)
.then_with(|| a.channel_id.cmp(&b.channel_id))
});
let output = serde_json::to_string(&matches).expect("serializing ChannelSummary");
println!("{output}");
Ok(())
}
/// Stable, scriptable projection of a kind:39000 channel-metadata event.
#[derive(serde::Serialize)]
struct ChannelSummary {
channel_id: String,
name: String,
channel_type: Option<String>,
visibility: Option<String>,
archived: bool,
about: Option<String>,
topic: Option<String>,
purpose: Option<String>,
}
impl ChannelSummary {
/// Parse a kind:39000 event JSON value into a summary. Returns `None` if the
/// event lacks the required `d` (channel UUID) or `name` tags.
fn from_event(event: &serde_json::Value) -> Option<Self> {
let tags = event.get("tags")?.as_array()?;
let mut channel_id: Option<String> = None;
let mut name: Option<String> = None;
let mut channel_type: Option<String> = None;
let mut visibility: Option<String> = None;
let mut archived = false;
let mut about: Option<String> = None;
let mut topic: Option<String> = None;
let mut purpose: Option<String> = None;
for tag in tags {
let Some(tag_arr) = tag.as_array() else {
continue;
};
let key = tag_arr.first().and_then(|v| v.as_str()).unwrap_or("");
let val = tag_arr.get(1).and_then(|v| v.as_str());
match key {
"d" => channel_id = val.map(str::to_string),
"name" => name = val.map(str::to_string),
"t" => channel_type = val.map(str::to_string),
// NIP-29 emits both `private` and `public` (Buzz adds the latter).
// The presence of either tag is the source of truth; tag value is unused.
"private" => visibility = Some("private".to_string()),
"public" => visibility = Some("public".to_string()),
"about" => about = val.map(str::to_string),
"topic" => topic = val.map(str::to_string),
"purpose" => purpose = val.map(str::to_string),
"archived" => archived = val == Some("true"),
_ => {}
}
}
Some(ChannelSummary {
channel_id: channel_id?,
name: name?,
channel_type,
visibility,
archived,
about,
topic,
purpose,
})
}
}
fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool {
let hay = name.to_ascii_lowercase();
if exact {
hay == needle_lower
} else {
hay.contains(needle_lower)
}
}
pub async fn cmd_get_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [39000],
"#d": [channel_id],
"limit": 1
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(e) = events.first() {
let mut normalized = extract_channel_metadata(e);
normalized["pubkey"] =
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""));
println!("{normalized}");
} else {
println!("null");
}
Ok(())
}
pub async fn cmd_list_channel_members(
client: &BuzzClient,
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [39002],
"#d": [channel_id],
"limit": 1
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let members = events.first().map(extract_p_tags).unwrap_or_default();
let output = serde_json::to_string(&members).unwrap_or_default();
println!("{output}");
Ok(())
}
pub async fn cmd_get_canvas(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [40100],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(content) = events
.first()
.and_then(|e| e.get("content"))
.and_then(|c| c.as_str())
{
println!("{content}");
} else {
println!("null");
}
Ok(())
}
pub async fn cmd_create_channel(
client: &BuzzClient,
name: &str,
channel_type: &str,
visibility: &str,
description: Option<&str>,
ttl: Option<i64>,
) -> Result<(), CliError> {
match channel_type {
"stream" | "forum" => {}
_ => {
return Err(CliError::Usage(format!(
"--type must be 'stream' or 'forum' (got: {channel_type})"
)))
}
}
match visibility {
"open" | "private" => {}
_ => {
return Err(CliError::Usage(format!(
"--visibility must be 'open' or 'private' (got: {visibility})"
)))
}
}
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
let channel_uuid = Uuid::new_v4();
let vis = match visibility {
"open" => buzz_sdk::Visibility::Open,
"private" => buzz_sdk::Visibility::Private,
_ => unreachable!(),
};
let ct = match channel_type {
"stream" => buzz_sdk::ChannelKind::Stream,
"forum" => buzz_sdk::ChannelKind::Forum,
_ => unreachable!(),
};
let builder =
buzz_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description, ttl)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
print_create_response(&resp, "channel_id", &channel_uuid.to_string());
Ok(())
}
/// A resolved live managed-agent instance backing a template persona slug.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ResolvedAgent {
persona_id: String,
pubkey: String,
}
/// Minimal projection of a kind:30177 event's content needed for roster
/// resolution. Other fields (system_prompt, model, ...) are irrelevant here.
#[derive(Debug, Deserialize)]
struct ManagedAgentContent {
#[serde(default)]
persona_id: Option<String>,
}
/// Outcome of the pure F4 cardinality rule alone (see
/// [`apply_cardinality_rule`]) — zero live instances is a plain "skipped"
/// slug here, with no notion of *why* (archive filtering happens one layer
/// up, in [`build_roster_resolution`]).
#[derive(Debug)]
struct ResolvedRoster {
/// Exactly one live instance per persona slug — safe to add.
agents: Vec<ResolvedAgent>,
/// Persona slugs with no live kind:30177 instance for the effective
/// owner (cold-start provisioning is desktop-only, out of scope here).
skipped: Vec<String>,
}
/// One live instance dropped from the roster because it's archived
/// (NIP-IA) — reported so an operator can see exactly what was excluded,
/// not just that a slug ended up short.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ArchivedExclusion {
persona_id: String,
pubkey: String,
}
/// A persona slug with no agent added to the roster, and why. Distinguishes
/// "no live instances ever existed" from "instances existed but all are
/// archived" — both look the same as a bare skip otherwise.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct SkippedSlug {
persona_id: String,
reason: String,
}
/// Archive-aware roster resolution: the [`apply_cardinality_rule`] outcome
/// after archived instances are filtered out first, plus what the archive
/// filter itself excluded and whether its snapshot could be trusted.
#[derive(Debug)]
struct RosterResolution {
/// Exactly one live, non-archived instance per persona slug.
agents: Vec<ResolvedAgent>,
skipped: Vec<SkippedSlug>,
/// The complete archive-exclusion list, never just the first.
archived_excluded: Vec<ArchivedExclusion>,
/// Set when the archived-identities snapshot failed its trust check
/// (NIP-IA state 3) and the filter fell back to "no known archived
/// identities" rather than fabricating a resolution. `None` means the
/// snapshot was trusted (states 1/2) or there was nothing to filter.
archive_state_warning: Option<String>,
}
/// Fetch kind:30176 (team) events authored by `owner` with `#d = [team_id]`
/// and return the team's persona slugs. Absent `persona_ids` (publisher
/// predates always-publish, or no matching event) resolves to an empty slug
/// set — the CLI reads a single relay snapshot, not a local reconciled
/// merge, so "unknown" here is indistinguishable from "empty."
async fn fetch_team_persona_slugs(
client: &BuzzClient,
owner: &str,
team_id: &str,
) -> Result<Vec<String>, CliError> {
let filter = serde_json::json!({
"kinds": [KIND_TEAM],
"authors": [owner],
"#d": [team_id],
"limit": 1,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse team query response: {e}")))?;
let Some(event) = events.first() else {
return Err(CliError::NotFound(format!(
"team '{team_id}' not found for effective owner {owner}"
)));
};
let content: serde_json::Value = event
.get("content")
.and_then(|c| c.as_str())
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(serde_json::Value::Null);
let slugs = content
.get("persona_ids")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Ok(slugs)
}
/// Scan all kind:30177 (managed-agent) events authored by `owner`, keyset-
/// paginated (`until` + `before_id`, never `page`/offset — 30177 is
/// parameterized-replaceable and offset drift can silently skip a live
/// instance across requests). Returns every event whose `content.persona_id`
/// is in `slugs`, keyed by the event's `d` tag (the agent pubkey).
async fn scan_managed_agents_by_owner(
client: &BuzzClient,
owner: &str,
slugs: &HashSet<&str>,
) -> Result<Vec<ResolvedAgent>, CliError> {
let filter = serde_json::json!({
"kinds": [KIND_MANAGED_AGENT],
"authors": [owner],
});
let events = client.query_all(filter).await?;
let mut found: Vec<ResolvedAgent> = Vec::new();
for event in &events {
let pubkey = extract_d_tag(event);
if pubkey.is_empty() {
continue;
}
let content: ManagedAgentContent = event
.get("content")
.and_then(|c| c.as_str())
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(ManagedAgentContent { persona_id: None });
let Some(persona_id) = content.persona_id else {
continue;
};
if slugs.contains(persona_id.as_str()) {
found.push(ResolvedAgent { persona_id, pubkey });
}
}
Ok(found)
}
/// Apply the F4 cardinality rule per persona slug: zero live instances is a
/// known skip (cold-start provisioning is desktop-only, out of scope), one is
/// added, more than one is a hard error listing candidate pubkeys — matching
/// all instances silently would risk adding a stale or wrong instance. Pure
/// and independent of the relay so it's directly unit-testable.
fn apply_cardinality_rule(
slugs: &[String],
found: &[ResolvedAgent],
) -> Result<ResolvedRoster, CliError> {
let mut agents = Vec::new();
let mut skipped = Vec::new();
for slug in slugs {
let matches: Vec<&ResolvedAgent> = found.iter().filter(|a| &a.persona_id == slug).collect();
match matches.as_slice() {
[] => skipped.push(slug.clone()),
[one] => agents.push((*one).clone()),
many => {
let candidates: Vec<&str> = many.iter().map(|a| a.pubkey.as_str()).collect();
return Err(CliError::Usage(format!(
"persona '{slug}' has {} live instances for this owner ({}); \
pass a template with a single instance per persona, or resolve \
the duplicate in Buzz Desktop before creating the channel",
many.len(),
candidates.join(", ")
)));
}
}
}
Ok(ResolvedRoster { agents, skipped })
}
/// The stderr warning + embedded-in-report/error-detail message for an
/// untrusted archived-identities snapshot (NIP-IA state 3) — one wording,
/// shared by [`build_roster_resolution`]'s immediate stderr print and
/// [`resolve_roster_with_archive_filter`]'s `archive_state_warning`/error-
/// detail paths, so the two never drift.
fn archive_snapshot_warning(e: &CliError) -> String {
format!("archived-identities snapshot untrusted, proceeding without archive filtering: {e}")
}
/// Pure archive-filter + cardinality core of [`build_roster_resolution`],
/// taking the already-fetched `found` set and the already-attempted archive
/// snapshot fetch (`archived_result`) so it's directly unit-testable without
/// any relay I/O — same separation as [`apply_cardinality_rule`] vs
/// [`scan_managed_agents_by_owner`].
///
/// Archive filtering deliberately **fails open** on `archived_result: Err`
/// (NIP-IA snapshot state 3): filtering with an empty archived set rather
/// than fabricating a resolution, since filtering can only *remove*
/// ambiguity, never resolve it. The trust failure is always surfaced: on
/// stderr immediately by the caller (network-adjacent, not this function's
/// job), and in whichever of `RosterResolution::archive_state_warning` or
/// the returned error the caller ends up on here.
fn resolve_roster_with_archive_filter(
slugs: &[String],
found: Vec<ResolvedAgent>,
archived_result: Result<Vec<String>, CliError>,
) -> Result<RosterResolution, CliError> {
let (archived, archive_state_warning) = match archived_result {
Ok(pubkeys) => (pubkeys.into_iter().collect::<HashSet<String>>(), None),
Err(e) => (HashSet::new(), Some(archive_snapshot_warning(&e))),
};
let mut archived_excluded = Vec::new();
let mut live_found = Vec::new();
for agent in found {
if archived.contains(&agent.pubkey) {
archived_excluded.push(ArchivedExclusion {
persona_id: agent.persona_id.clone(),
pubkey: agent.pubkey.clone(),
});
} else {
live_found.push(agent);
}
}
let resolved = apply_cardinality_rule(slugs, &live_found).map_err(|e| {
match (e, &archive_state_warning) {
(CliError::Usage(msg), Some(warning)) => {
CliError::Usage(format!("{msg} (warning: {warning})"))
}
(other, _) => other,
}
})?;
let archived_slugs: HashSet<&str> = archived_excluded
.iter()
.map(|a| a.persona_id.as_str())
.collect();
let skipped = resolved
.skipped
.into_iter()
.map(|persona_id| {
let reason = if archived_slugs.contains(persona_id.as_str()) {
"all instances archived".to_string()
} else {
"no live instances".to_string()
};
SkippedSlug { persona_id, reason }
})
.collect();
Ok(RosterResolution {
agents: resolved.agents,
skipped,
archived_excluded,
archive_state_warning,
})
}
/// Sync tail of [`build_roster_resolution`]: emits the state-3 trust-failure
/// warning to `warn_sink` (production: stderr) and then runs
/// [`resolve_roster_with_archive_filter`]. Split out from the async relay
/// I/O so the emission and the resolution it gates are exercised together
/// through an injected writer — the only way to prove the emission is still
/// wired to the same trust check the resolution result carries.
fn finalize_roster_resolution(
slugs: &[String],
found: Vec<ResolvedAgent>,
archived_result: Result<Vec<String>, CliError>,
warn_sink: &mut dyn std::io::Write,
) -> Result<RosterResolution, CliError> {
if let Err(e) = &archived_result {
let warning = archive_snapshot_warning(e);
let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": warning}));
}
resolve_roster_with_archive_filter(slugs, found, archived_result)
}
/// Resolve a template's roster against the relay: expand team entries into
/// persona slugs (via kind:30176), scan for live kind:30177 instances scoped
/// to the effective owner, filter out archived (NIP-IA) instances, and apply
/// the cardinality rule per slug (see [`resolve_roster_with_archive_filter`]
/// for the pure filter+cardinality core and the fail-open contract). Runs
/// entirely before any channel-creation side effect — a cardinality error
/// aborts with nothing created.
async fn build_roster_resolution(
client: &BuzzClient,
owner: &str,
roster: &TemplateAgentRoster,
) -> Result<RosterResolution, CliError> {
let mut slugs: Vec<String> = Vec::new();
for entry in &roster.personas {
if !slugs.contains(&entry.persona_id) {
slugs.push(entry.persona_id.clone());
}
}
for team in &roster.teams {
let team_slugs = fetch_team_persona_slugs(client, owner, &team.team_id).await?;
for slug in team_slugs {
if !slugs.contains(&slug) {
slugs.push(slug);
}
}
}
if slugs.is_empty() {
return Ok(RosterResolution {
agents: Vec::new(),
skipped: Vec::new(),
archived_excluded: Vec::new(),
archive_state_warning: None,
});
}
let slug_set: HashSet<&str> = slugs.iter().map(String::as_str).collect();
let found = scan_managed_agents_by_owner(client, owner, &slug_set).await?;
let archived_result = fetch_archived_snapshot(client).await;
finalize_roster_resolution(&slugs, found, archived_result, &mut std::io::stderr())
}
/// `buzz channels create --template <name>`: load a desktop-local channel
/// template, resolve its agent roster against the relay, create the
/// channel, apply the canvas template, and add resolved agents as members.
///
/// Roster resolution happens entirely before channel creation (see
/// `build_roster_resolution`) so an ambiguous roster aborts with zero side
/// effects. Channel creation, canvas, and member-add are best-effort from
/// that point: canvas failures and per-member add failures are reported,
/// not fatal.
#[allow(clippy::too_many_arguments)]
pub async fn cmd_create_channel_from_template(
client: &BuzzClient,
name: &str,
template_name: &str,
templates_file: Option<&str>,
channel_type_override: Option<&str>,
visibility_override: Option<&str>,
description: Option<&str>,
ttl: Option<i64>,
) -> Result<(), CliError> {
let templates_path = channel_templates::resolve_templates_path(templates_file)?;
let template: ChannelTemplateRecord =
channel_templates::find_template(&templates_path, template_name)?;
let channel_type = channel_type_override.unwrap_or(&template.channel_type);
let visibility = visibility_override.unwrap_or(&template.visibility);
match channel_type {
"stream" | "forum" => {}
_ => {
return Err(CliError::Usage(format!(
"template channel_type must be 'stream' or 'forum' (got: {channel_type})"
)))
}
}
match visibility {
"open" | "private" => {}
_ => {
return Err(CliError::Usage(format!(
"template visibility must be 'open' or 'private' (got: {visibility})"
)))
}
}
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
// Owner invariant (F1): the auth-tag owner (already verified against the
// signer at startup) if present, else the signing pubkey. No sole-author
// fallback — a same-slug 30176/30177 from another principal must never
// be selected.
let owner = client
.auth_tag_owner_hex()
.unwrap_or_else(|| client.keys().public_key().to_hex());
let resolved = build_roster_resolution(client, &owner, &template.agents).await?;
let channel_uuid = Uuid::new_v4();
let vis = match visibility {
"open" => buzz_sdk::Visibility::Open,
"private" => buzz_sdk::Visibility::Private,
_ => unreachable!(),
};
let ct = match channel_type {
"stream" => buzz_sdk::ChannelKind::Stream,
"forum" => buzz_sdk::ChannelKind::Forum,
_ => unreachable!(),
};
let effective_description = description.or(template.description.as_deref());
let builder = buzz_sdk::build_create_channel(
channel_uuid,
name,
Some(vis),
Some(ct),
effective_description,
ttl,
)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
client.submit_event(event).await?;
let mut canvas_applied = false;
if let Some(canvas_template) = template.canvas_template.as_deref() {
let content = canvas_template
.replace("{channel.name}", name)
.replace("{template.name}", &template.name);
let canvas_result: Result<(), CliError> = async {
let builder = buzz_sdk::build_set_canvas(channel_uuid, &content)
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
let event = client.sign_event(builder)?;
client.submit_event(event).await?;
Ok(())
}
.await;
// Canvas is best-effort — matches desktop's useApplyTemplate.ts behavior.
canvas_applied = canvas_result.is_ok();
}
// Members are added sequentially: concurrent kind:9000 writes are
// last-write-wins on the relay (see channelAgents.ts), so parallel adds
// here would race each other for no benefit.
let mut members_added: Vec<serde_json::Value> = Vec::new();
let mut member_failures: Vec<serde_json::Value> = Vec::new();
for agent in &resolved.agents {
let outcome: Result<(), CliError> = async {
let builder = buzz_sdk::build_add_member(
channel_uuid,
&agent.pubkey,
Some(buzz_sdk::MemberRole::Bot),
)
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
let event = client.sign_event(builder)?;
client.submit_event(event).await?;
Ok(())
}
.await;
match outcome {
Ok(()) => members_added.push(serde_json::json!({
"persona_id": agent.persona_id,
"pubkey": agent.pubkey,
})),
Err(e) => member_failures.push(serde_json::json!({
"persona_id": agent.persona_id,
"pubkey": agent.pubkey,
"error": e.to_string(),
})),
}
}
let status = if member_failures.is_empty() {
"ok"
} else {
"partial"
};
let report = build_template_report(
&channel_uuid.to_string(),
&template.name,
status,
canvas_applied,
members_added,
member_failures,
&resolved,
);
println!("{report}");
Ok(())
}
/// Pure construction of `channels create --template`'s final stdout report.
/// Isolated from `cmd_create_channel_from_template` so the `archive_state_warning`
/// insertion is directly testable against a `RosterResolution` without any
/// relay I/O or channel-creation side effects — production prints exactly
/// what this returns.
#[allow(clippy::too_many_arguments)]
fn build_template_report(
channel_id: &str,
template_name: &str,
status: &str,
canvas_applied: bool,
members_added: Vec<serde_json::Value>,
member_failures: Vec<serde_json::Value>,
resolved: &RosterResolution,
) -> serde_json::Value {
let mut report = serde_json::json!({
"status": status,
"channel_id": channel_id,
"template": template_name,
"canvas_applied": canvas_applied,
"members_added": members_added,
"skipped": resolved.skipped,
"archived_excluded": resolved.archived_excluded,
"member_failures": member_failures,
});
if let Some(warning) = &resolved.archive_state_warning {
report["archive_state_warning"] = serde_json::Value::String(warning.clone());
}
report
}
/// Validate a user-supplied TTL (in seconds): must be a positive value that
/// fits in the relay's `i32` column.
fn validate_ttl_seconds(secs: i64) -> Result<i32, CliError> {
if secs <= 0 {
return Err(CliError::Usage(format!(
"--ttl must be a positive number of seconds (got: {secs})"
)));
}
i32::try_from(secs)
.map_err(|_| CliError::Usage(format!("--ttl is too large (max {} seconds)", i32::MAX)))
}
fn validate_update_channel_fields(
name: Option<&str>,
description: Option<&str>,
visibility: Option<&str>,
ttl_change: Option<Option<i32>>,
) -> Result<(), CliError> {
if name.is_none() && description.is_none() && visibility.is_none() && ttl_change.is_none() {
return Err(CliError::Usage(
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl)"
.into(),
));
}
Ok(())
}
pub async fn cmd_update_channel(
client: &BuzzClient,
channel_id: &str,
name: Option<&str>,
description: Option<&str>,
visibility: Option<&str>,
ttl: Option<i64>,
no_ttl: bool,
) -> Result<(), CliError> {
// Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it,
// None (from --no-ttl) clears it, making the channel permanent.
let ttl_change: Option<Option<i32>> = match (ttl, no_ttl) {
(Some(secs), _) => Some(Some(validate_ttl_seconds(secs)?)),
(None, true) => Some(None),
(None, false) => None,
};
validate_update_channel_fields(name, description, visibility, ttl_change)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder =
buzz_sdk::build_update_channel(channel_uuid, name, description, visibility, ttl_change)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_channel_topic(
client: &BuzzClient,
channel_id: &str,
topic: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_set_topic(channel_uuid, topic)
.map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_channel_purpose(
client: &BuzzClient,
channel_id: &str,
purpose: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_set_purpose(channel_uuid, purpose)
.map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_join_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_join(channel_uuid)
.map_err(|e| CliError::Other(format!("build_join failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_leave_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_leave(channel_uuid)
.map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_archive_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_archive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_unarchive_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_unarchive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_delete_channel(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_delete_channel(channel_uuid)
.map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_add_channel_member(
client: &BuzzClient,
channel_id: &str,
pubkey: &str,
role: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let channel_uuid = parse_uuid(channel_id)?;
let typed_role = match role {
None => None,
Some("owner") => Some(buzz_sdk::MemberRole::Owner),
Some("admin") => Some(buzz_sdk::MemberRole::Admin),
Some("member") => Some(buzz_sdk::MemberRole::Member),
Some("guest") => Some(buzz_sdk::MemberRole::Guest),
Some("bot") => Some(buzz_sdk::MemberRole::Bot),
Some(other) => {
return Err(CliError::Usage(format!(
"--role must be owner/admin/member/guest/bot (got: {other})"
)))
}
};
let builder = buzz_sdk::build_add_member(channel_uuid, pubkey, typed_role)
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_remove_channel_member(
client: &BuzzClient,
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_remove_member(channel_uuid, pubkey)
.map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event.
pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> {
match policy {
"anyone" | "owner_only" | "nobody" => {}
_ => {
return Err(CliError::Usage(format!(
"--policy must be 'anyone', 'owner_only', or 'nobody' (got: {policy})"
)))
}
}
// Check if this policy is allowed by the deployment.
// NOTE: This gate covers only the `buzz channels set-add-policy` CLI path.
// A client that submits a kind:10100 event directly to the relay bypasses
// this check. Full enforcement requires relay-side validation, which is
// intentionally out of scope for this change (see team decision: no
// relay-side enforcement of client behavior).
if let Ok(allowed_raw) = std::env::var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES") {
let allowed: Vec<&str> = allowed_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !allowed.is_empty() && !allowed.contains(&policy) {
return Err(CliError::Usage(format!(
"channel_add_policy '{policy}' is not permitted on this deployment \
(BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES={allowed_raw})"
)));
}
}
let content = serde_json::json!({ "channel_add_policy": policy }).to_string();
use nostr::{EventBuilder, Kind};
let builder = EventBuilder::new(
Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16),
&content,
)
.tags([]);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_canvas(
client: &BuzzClient,
channel_id: &str,
content: &str,
) -> Result<(), CliError> {
let content = read_or_stdin(content)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = buzz_sdk::build_set_canvas(channel_uuid, &content)
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn dispatch(
cmd: crate::ChannelsCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::ChannelsCmd;
match cmd {
ChannelsCmd::List {
visibility,
member,
limit,
} => {
let vis_str = visibility.as_ref().map(|v| v.to_string());
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await
}
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
ChannelsCmd::Search {
query,
exact,
include_archived,
limit,
} => cmd_search_channels(client, &query, exact, include_archived, limit).await,
ChannelsCmd::Create {
name,
channel_type,
visibility,
description,
ttl,
template,
templates_file,
} => {
if let Some(template_name) = template {
cmd_create_channel_from_template(
client,
&name,
&template_name,
templates_file.as_deref(),
channel_type.as_ref().map(|t| t.to_string()).as_deref(),
visibility.as_ref().map(|v| v.to_string()).as_deref(),
description.as_deref(),
ttl,
)
.await
} else {
// required_unless_present = "template" guarantees these are
// Some when template is None.
let channel_type =
channel_type.ok_or_else(|| CliError::Usage("--type is required".into()))?;
let visibility =
visibility.ok_or_else(|| CliError::Usage("--visibility is required".into()))?;
cmd_create_channel(
client,
&name,
&channel_type.to_string(),
&visibility.to_string(),
description.as_deref(),
ttl,
)
.await
}
}
ChannelsCmd::Update {
channel,
name,
description,
visibility,
ttl,
no_ttl,
} => {
let visibility = visibility.as_ref().map(|v| v.to_string());
cmd_update_channel(
client,
&channel,
name.as_deref(),
description.as_deref(),
visibility.as_deref(),
ttl,
no_ttl,
)
.await
}
ChannelsCmd::Topic { channel, topic } => {
cmd_set_channel_topic(client, &channel, &topic).await
}
ChannelsCmd::Purpose { channel, purpose } => {
cmd_set_channel_purpose(client, &channel, &purpose).await
}
ChannelsCmd::Join { channel } => cmd_join_channel(client, &channel).await,
ChannelsCmd::Leave { channel } => cmd_leave_channel(client, &channel).await,
ChannelsCmd::Archive { channel } => cmd_archive_channel(client, &channel).await,
ChannelsCmd::Unarchive { channel } => cmd_unarchive_channel(client, &channel).await,
ChannelsCmd::Delete { channel } => cmd_delete_channel(client, &channel).await,
ChannelsCmd::Members { channel } => cmd_list_channel_members(client, &channel).await,
ChannelsCmd::AddMember {
channel,
pubkey,
role,
} => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await,
ChannelsCmd::RemoveMember { channel, pubkey } => {
cmd_remove_channel_member(client, &channel, &pubkey).await
}
ChannelsCmd::SetAddPolicy { policy } => cmd_set_add_policy(client, &policy).await,
}
}
pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::CanvasCmd;
match cmd {
CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await,
CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await,
}
}
#[cfg(test)]
mod tests {
use super::{
apply_cardinality_rule, build_template_report, cmd_set_add_policy,
finalize_roster_resolution, name_matches, resolve_roster_with_archive_filter,
validate_ttl_seconds, validate_update_channel_fields, ArchivedExclusion, ChannelSummary,
ResolvedAgent, RosterResolution, SkippedSlug,
};
use crate::client::BuzzClient;
use crate::CliError;
use serde_json::json;
fn event(tags: serde_json::Value) -> serde_json::Value {
json!({ "tags": tags })
}
#[test]
fn from_event_extracts_known_tags() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "buzz-chat-composer"],
["t", "stream"],
["public"],
["about", "About text"],
["topic", "Composer work"],
["purpose", "Track UI for the composer"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.channel_id, "11111111-1111-1111-1111-111111111111");
assert_eq!(s.name, "buzz-chat-composer");
assert_eq!(s.channel_type.as_deref(), Some("stream"));
assert_eq!(s.visibility.as_deref(), Some("public"));
assert!(!s.archived);
assert_eq!(s.about.as_deref(), Some("About text"));
assert_eq!(s.topic.as_deref(), Some("Composer work"));
assert_eq!(s.purpose.as_deref(), Some("Track UI for the composer"));
}
#[test]
fn from_event_marks_archived() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "old-channel"],
["archived", "true"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert!(s.archived);
}
#[test]
fn from_event_marks_private() {
let ev = event(json!([
["d", "11111111-1111-1111-1111-111111111111"],
["name", "secret"],
["private"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.visibility.as_deref(), Some("private"));
}
#[test]
fn from_event_returns_none_without_required_tags() {
// missing `name`
let ev = event(json!([["d", "11111111-1111-1111-1111-111111111111"]]));
assert!(ChannelSummary::from_event(&ev).is_none());
// missing `d`
let ev = event(json!([["name", "no-id"]]));
assert!(ChannelSummary::from_event(&ev).is_none());
}
#[test]
fn from_event_tolerates_malformed_tags() {
// Non-array tag entry, empty tag, single-element tag — all must be skipped, not panic.
let ev = event(json!([
"not-an-array",
[],
["name"],
["d", "11111111-1111-1111-1111-111111111111"],
["name", "fine"],
]));
let s = ChannelSummary::from_event(&ev).expect("parse");
assert_eq!(s.name, "fine");
}
// `name_matches` takes a pre-lowercased needle (caller responsibility, set in
// cmd_search_channels). Tests follow the same contract.
#[test]
fn name_matches_substring_case_insensitive() {
assert!(name_matches("Buzz-Chat-Composer", "composer", false));
assert!(name_matches("Buzz-Chat-Composer", "buzz", false));
assert!(!name_matches("design", "composer", false));
}
#[test]
fn name_matches_exact_case_insensitive() {
assert!(name_matches("Buzz", "buzz", true));
assert!(!name_matches("Buzz-Chat", "buzz", true));
}
#[test]
fn validate_ttl_accepts_positive() {
assert_eq!(validate_ttl_seconds(3600).unwrap(), 3600);
assert_eq!(validate_ttl_seconds(1).unwrap(), 1);
assert_eq!(validate_ttl_seconds(i32::MAX as i64).unwrap(), i32::MAX);
}
#[test]
fn validate_ttl_rejects_zero_and_negative() {
assert!(validate_ttl_seconds(0).is_err());
assert!(validate_ttl_seconds(-1).is_err());
}
#[test]
fn validate_ttl_rejects_overflow() {
assert!(validate_ttl_seconds(i32::MAX as i64 + 1).is_err());
}
#[test]
fn update_channel_fields_rejects_empty_update() {
let result = validate_update_channel_fields(None, None, None, None);
assert!(matches!(result, Err(CliError::Usage(_))));
let msg = result.unwrap_err().to_string();
assert!(msg.contains("at least one field required"));
assert!(msg.contains("--visibility"));
}
#[test]
fn update_channel_fields_accepts_visibility_only_update() {
let result = validate_update_channel_fields(None, None, Some("open"), None);
assert!(result.is_ok(), "visibility-only update should be accepted");
}
// --- BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES gate ---
fn check_allowed_channel_add_policy(allowed_raw: &str, policy: &str) -> Result<(), CliError> {
let allowed: Vec<&str> = allowed_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if !allowed.is_empty() && !allowed.contains(&policy) {
return Err(CliError::Usage(format!(
"channel_add_policy '{policy}' is not permitted on this deployment \
(BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES={allowed_raw})"
)));
}
Ok(())
}
#[test]
fn set_add_policy_rejects_disallowed_policy() {
let result = check_allowed_channel_add_policy("owner_only,nobody", "anyone");
assert!(
result.is_err(),
"anyone should be rejected when not in allowed set"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("not permitted"),
"error should mention 'not permitted': {msg}"
);
assert!(
msg.contains("anyone"),
"error should name the disallowed policy: {msg}"
);
}
#[test]
fn set_add_policy_accepts_allowed_policy() {
let result = check_allowed_channel_add_policy("owner_only,nobody", "owner_only");
assert!(result.is_ok(), "owner_only should be accepted: {result:?}");
}
#[test]
fn set_add_policy_no_restriction_allows_all() {
// Empty allowed list means no restriction.
let result = check_allowed_channel_add_policy("", "anyone");
assert!(
result.is_ok(),
"empty allowed list should permit any policy: {result:?}"
);
}
// --- Integration test: full env-var → cmd_set_add_policy() path ---
//
// This test calls cmd_set_add_policy directly with the env var set. The function
// returns early with an error before any network call, so no relay is needed.
// If the BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES check were removed from cmd_set_add_policy,
// this test would fail (it would proceed to sign_event and return a different error).
fn make_test_client() -> BuzzClient {
// Scalar = 1 is the smallest valid secp256k1 private key.
let keys =
nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid test key");
BuzzClient::new("ws://localhost:3000".to_string(), keys, None, None)
.expect("client construction should not fail")
}
#[tokio::test]
async fn set_add_policy_env_gate_rejects_disallowed_via_full_path() {
std::env::set_var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES", "owner_only,nobody");
let client = make_test_client();
let result = cmd_set_add_policy(&client, "anyone").await;
std::env::remove_var("BUZZ_ACP_ALLOWED_CHANNEL_ADD_POLICIES");
assert!(
result.is_err(),
"cmd_set_add_policy should reject 'anyone' when not in allowed set"
);
match result.unwrap_err() {
crate::CliError::Usage(msg) => {
assert!(
msg.contains("not permitted"),
"error should mention 'not permitted': {msg}"
);
}
other => panic!("expected CliError::Usage, got {other:?}"),
}
}
// --- Template roster cardinality (F4) ---
fn agent(persona_id: &str, pubkey: &str) -> ResolvedAgent {
ResolvedAgent {
persona_id: persona_id.to_string(),
pubkey: pubkey.to_string(),
}
}
#[test]
fn cardinality_zero_instances_is_skipped_not_error() {
let slugs = vec!["builtin:fizz".to_string()];
let resolved = apply_cardinality_rule(&slugs, &[]).expect("zero instances is not fatal");
assert!(resolved.agents.is_empty());
assert_eq!(resolved.skipped, vec!["builtin:fizz".to_string()]);
}
#[test]
fn cardinality_one_instance_is_added() {
let slugs = vec!["builtin:fizz".to_string()];
let found = vec![agent("builtin:fizz", "a".repeat(64).as_str())];
let resolved = apply_cardinality_rule(&slugs, &found).expect("single instance resolves");
assert_eq!(resolved.agents.len(), 1);
assert_eq!(resolved.agents[0].persona_id, "builtin:fizz");
assert!(resolved.skipped.is_empty());
}
#[test]
fn cardinality_multiple_instances_is_hard_error_listing_candidates() {
let slugs = vec!["builtin:fizz".to_string()];
let found = vec![
agent("builtin:fizz", &"a".repeat(64)),
agent("builtin:fizz", &"b".repeat(64)),
];
let err = apply_cardinality_rule(&slugs, &found).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
let msg = err.to_string();
assert!(msg.contains("builtin:fizz"));
assert!(msg.contains(&"a".repeat(64)));
assert!(msg.contains(&"b".repeat(64)));
}
#[test]
fn cardinality_mixed_slugs_zero_one_many_reports_first_ambiguity() {
// Zero and one resolve fine on their own, but a hard error on any
// slug must abort the whole roster (no partial channel-creation
// side effects from this stage) — the error must name the
// ambiguous slug, not a co-resolved one.
let slugs = vec![
"builtin:no-instance".to_string(),
"builtin:fizz".to_string(),
"builtin:duplicated".to_string(),
];
let found = vec![
agent("builtin:fizz", &"a".repeat(64)),
agent("builtin:duplicated", &"b".repeat(64)),
agent("builtin:duplicated", &"c".repeat(64)),
];
let err = apply_cardinality_rule(&slugs, &found).unwrap_err();
assert!(err.to_string().contains("builtin:duplicated"));
}
#[test]
fn cardinality_empty_roster_resolves_to_empty_lists() {
let resolved = apply_cardinality_rule(&[], &[]).expect("empty roster is not fatal");
assert!(resolved.agents.is_empty());
assert!(resolved.skipped.is_empty());
}
#[test]
fn cardinality_ignores_instances_for_unrelated_slugs() {
// A found agent for a slug that isn't in this roster must not leak
// into the resolved set or affect another slug's cardinality.
let slugs = vec!["builtin:fizz".to_string()];
let found = vec![
agent("builtin:fizz", &"a".repeat(64)),
agent("builtin:unrelated", &"z".repeat(64)),
];
let resolved = apply_cardinality_rule(&slugs, &found).expect("resolves");
assert_eq!(resolved.agents.len(), 1);
assert_eq!(resolved.agents[0].persona_id, "builtin:fizz");
}
// --- PR B: archive-aware roster resolution (resolve_roster_with_archive_filter) ---
#[test]
fn archive_filter_drops_archived_instance_and_resolves_to_live_one() {
// Two instances of the same persona, one archived: the archived one
// is dropped before cardinality runs, so the slug resolves cleanly
// to the live instance instead of hitting the multi-instance error.
let slugs = vec!["builtin:fizz".to_string()];
let live_pk = "a".repeat(64);
let archived_pk = "b".repeat(64);
let found = vec![
agent("builtin:fizz", &live_pk),
agent("builtin:fizz", &archived_pk),
];
let resolution =
resolve_roster_with_archive_filter(&slugs, found, Ok(vec![archived_pk.clone()]))
.expect("resolves to the single live instance");
assert_eq!(resolution.agents.len(), 1);
assert_eq!(resolution.agents[0].pubkey, live_pk);
assert!(resolution.skipped.is_empty());
assert_eq!(
resolution.archived_excluded,
vec![ArchivedExclusion {
persona_id: "builtin:fizz".to_string(),
pubkey: archived_pk,
}]
);
assert!(resolution.archive_state_warning.is_none());
}
#[test]
fn archive_filter_all_instances_archived_is_skipped_with_explicit_reason() {
// Every live instance for the slug is archived: distinguishable
// from "no instances ever existed" via the skip reason, and both
// exclusions appear in the complete archived_excluded list.
let slugs = vec!["builtin:fizz".to_string()];
let pk1 = "a".repeat(64);
let pk2 = "b".repeat(64);
let found = vec![agent("builtin:fizz", &pk1), agent("builtin:fizz", &pk2)];
let resolution =
resolve_roster_with_archive_filter(&slugs, found, Ok(vec![pk1.clone(), pk2.clone()]))
.expect("all-archived is a skip, not an error");
assert!(resolution.agents.is_empty());
assert_eq!(
resolution.skipped,
vec![SkippedSlug {
persona_id: "builtin:fizz".to_string(),
reason: "all instances archived".to_string(),
}]
);
assert_eq!(resolution.archived_excluded.len(), 2);
}
#[test]
fn archive_filter_no_instances_ever_existed_is_skipped_with_different_reason() {
// Zero live instances (nothing to archive) must not be confused
// with "all instances archived" — no exclusions were made.
let slugs = vec!["builtin:fizz".to_string()];
let resolution = resolve_roster_with_archive_filter(&slugs, vec![], Ok(vec![]))
.expect("zero instances is not fatal");
assert!(resolution.agents.is_empty());
assert_eq!(
resolution.skipped,
vec![SkippedSlug {
persona_id: "builtin:fizz".to_string(),
reason: "no live instances".to_string(),
}]
);
assert!(resolution.archived_excluded.is_empty());
}
#[test]
fn archive_filter_state3_on_success_path_proceeds_with_warning() {
// Snapshot trust failure (state 3) fails open: resolution proceeds
// exactly as if no archive filtering had happened, but the warning
// is threaded into the report via archive_state_warning.
let slugs = vec!["builtin:fizz".to_string()];
let pk = "a".repeat(64);
let found = vec![agent("builtin:fizz", &pk)];
let archived_err = CliError::Other("relay info document missing 'self' field".into());
let resolution = resolve_roster_with_archive_filter(&slugs, found, Err(archived_err))
.expect("fails open — resolution still succeeds");
assert_eq!(resolution.agents.len(), 1);
assert_eq!(resolution.agents[0].pubkey, pk);
assert!(resolution.archived_excluded.is_empty());
let warning = resolution
.archive_state_warning
.expect("state-3 warning must be present on the success path");
assert!(warning.contains("untrusted"));
}
#[test]
fn archive_filter_state3_on_ambiguity_path_keeps_hard_error_with_warning() {
// Snapshot trust failure alongside an unrelated multi-instance
// ambiguity: the cardinality hard-error is unchanged (fail-open
// never removes an error the non-filtered path would have hit),
// but the trust warning rides along in the error detail — never a
// fake success report on stdout.
let slugs = vec!["builtin:fizz".to_string()];
let found = vec![
agent("builtin:fizz", &"a".repeat(64)),
agent("builtin:fizz", &"b".repeat(64)),
];
let archived_err = CliError::Other("query failure".into());
let err = resolve_roster_with_archive_filter(&slugs, found, Err(archived_err))
.expect_err("ambiguity error must still propagate");
assert!(matches!(err, CliError::Usage(_)));
let msg = err.to_string();
assert!(
msg.contains("builtin:fizz"),
"error should name the slug: {msg}"
);
assert!(
msg.contains("untrusted"),
"error detail should carry the trust warning: {msg}"
);
}
#[test]
fn archive_filter_report_shape_includes_archived_excluded() {
// A slug with no archiving at all still gets an (empty) complete
// archived_excluded list in the resolution — the field is always
// present, not conditionally omitted.
let slugs = vec!["builtin:fizz".to_string()];
let pk = "a".repeat(64);
let found = vec![agent("builtin:fizz", &pk)];
let resolution = resolve_roster_with_archive_filter(&slugs, found, Ok(vec![]))
.expect("resolves with nothing archived");
assert!(resolution.archived_excluded.is_empty());
let serialized = serde_json::to_value(&resolution.archived_excluded).unwrap();
assert_eq!(serialized, serde_json::json!([]));
}
// --- Observable-boundary regressions for the state-3 warning wiring ---
//
// The tests above exercise `resolve_roster_with_archive_filter` (the pure
// core), which never touches stderr or the stdout report — deleting the
// stderr emission at the `finalize_roster_resolution` call site, or the
// `archive_state_warning` insertion in `build_template_report`, left all
// of them green. These three go through the two production seams
// directly so a regression at either wiring point fails a test.
fn empty_report_inputs() -> (Vec<serde_json::Value>, Vec<serde_json::Value>) {
(Vec::new(), Vec::new())
}
#[test]
fn archive_filter_state3_success_warns_on_sink_and_in_report() {
// State-3 fails open on the success path: the trust warning must
// reach BOTH observable boundaries — the stderr sink at the point
// of detection, and the top-level report field the caller prints.
let slugs = vec!["builtin:fizz".to_string()];
let pk = "a".repeat(64);
let found = vec![agent("builtin:fizz", &pk)];
let archived_err = CliError::Other("relay info document missing 'self' field".into());
let mut sink: Vec<u8> = Vec::new();
let resolution = finalize_roster_resolution(&slugs, found, Err(archived_err), &mut sink)
.expect("fails open — resolution still succeeds");
let sink_text = String::from_utf8(sink).expect("sink is UTF-8");
let lines: Vec<&str> = sink_text.lines().collect();
assert_eq!(lines.len(), 1, "exactly one warning line: {sink_text:?}");
let parsed: serde_json::Value =
serde_json::from_str(lines[0]).expect("warning line is parseable JSON");
let warning = parsed["warning"]
.as_str()
.expect("warning line has a string 'warning' field");
assert!(warning.contains("untrusted"), "got: {warning}");
let (members_added, member_failures) = empty_report_inputs();
let report = build_template_report(
"channel-1",
"template-1",
"ok",
false,
members_added,
member_failures,
&resolution,
);
assert_eq!(
report["archive_state_warning"].as_str(),
Some(warning),
"report must carry the same warning text as the stderr sink: {report}"
);
}
#[test]
fn archive_filter_state3_ambiguity_warns_on_sink_and_in_error_detail() {
// State-3 alongside an unrelated cardinality ambiguity: the warning
// still reaches the stderr sink at detection time, and the hard
// error's own detail carries it too. No report is constructible on
// this path by construction — the function returns `Err` before
// `cmd_create_channel_from_template` ever reaches `build_template_report`.
let slugs = vec!["builtin:fizz".to_string()];
let found = vec![
agent("builtin:fizz", &"a".repeat(64)),
agent("builtin:fizz", &"b".repeat(64)),
];
let archived_err = CliError::Other("query failure".into());
let mut sink: Vec<u8> = Vec::new();
let err = finalize_roster_resolution(&slugs, found, Err(archived_err), &mut sink)
.expect_err("ambiguity error must still propagate");
let sink_text = String::from_utf8(sink).expect("sink is UTF-8");
assert_eq!(
sink_text.lines().count(),
1,
"exactly one warning line: {sink_text:?}"
);
assert!(
sink_text.contains("untrusted"),
"stderr sink should carry the trust warning: {sink_text}"
);
assert!(matches!(err, CliError::Usage(_)));
assert!(
err.to_string().contains("untrusted"),
"error detail should carry the trust warning: {err}"
);
}
#[test]
fn build_template_report_omits_warning_key_when_none() {
// The insertion must not be unconditional — a trusted (states 1/2)
// resolution's report has NO `archive_state_warning` key at all,
// not merely a null one, so this can't be satisfied by always
// inserting the field.
let resolution = RosterResolution {
agents: Vec::new(),
skipped: Vec::new(),
archived_excluded: Vec::new(),
archive_state_warning: None,
};
let (members_added, member_failures) = empty_report_inputs();
let report = build_template_report(
"channel-1",
"template-1",
"ok",
false,
members_added,
member_failures,
&resolution,
);
assert!(
report.get("archive_state_warning").is_none(),
"no warning key expected: {report}"
);
}
}