[4/4] buzz teams: group published personas into rosters

Completes the definition surface: `buzz teams create|list|get|delete`
writes kind:30176 over the personas from [3/4], from flags or a Desktop
`.team.json` export.

Membership resolution is the load-bearing part. A team event stores
persona d-tags, and Desktop publishes personas under their record id —
usually a UUID — while its team export names members only by display
name. Matching on the slugified name would therefore resolve nothing for
every Desktop-exported roster. `--persona` accepts a d-tag, a slugified
d-tag, or a unique display name, in that order; an ambiguous display name
is an error rather than a guess, and a member with no published persona
is refused rather than published as a seat that silently stays empty.

A team id is used verbatim, never normalized. The relay enforces the slug
grammar on persona d-tags but only a length bound on team ids, and
Desktop writes raw UUIDs and ids like `builtin-team:welcome` —
normalizing would address a different coordinate than the one Desktop
published, making its teams unreachable. Since kind:30176 has no envelope
validator on the relay, the CLI is the only guard against a blank or
oversized id.

`instructions` and `persona_ids` are always published. On the wire an
absent field means "publisher predates always-publish, membership
unknown, preserve local" — distinct from an explicit empty. A new client
must never claim the former, or a write meant to clear a roster reads as
"leave it alone" and one meant to leave it alone wipes it.

The e2e suite gains the relay rule the delete path depends on: a
tombstone older than its target head is accepted, deletes nothing, and
still reports OK.

Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Max Lampert
2026-08-17 14:27:14 -07:00
committed by GitHub
parent 582e993daf
commit 4ce7cf606f
6 changed files with 1086 additions and 3 deletions
+30 -3
View File
@@ -482,9 +482,9 @@ buzz notes get --name dco-check # exits non-zero: not found
buzz notes rm --name does-not-exist # exits non-zero
```
### 6.13 Personas (NIP-AP, kind:30175)
### 6.13 Personas & Teams (NIP-AP, kind:30175 / kind:30176)
Owner-authored agent definitions. These are the same coordinates
Owner-authored agent definitions and rosters. These are the same coordinates
Buzz Desktop reads, so run them with the **same key as the Desktop you expect
the definitions to appear in** — a different key writes to a different
coordinate space and Desktop shows nothing.
@@ -521,8 +521,15 @@ buzz personas list
buzz personas get herring
buzz personas get herring --json | jq . # sig-stripped array of one
# create a team; members resolve by d-tag OR persona display name
buzz teams create --name "Red team" --persona Herring --persona quinby | jq .
# → {event_id, accepted, message, team_id}
buzz teams create --from ~/Downloads/Red\ team.team.json --replace | jq .
buzz teams get red-team
# delete (NIP-09 a-tag tombstone; the CLI re-reads to confirm the coordinate
# is gone, because the relay accepts a tombstone that deleted nothing)
buzz teams delete red-team
buzz personas delete herring
buzz personas get herring # exits non-zero: not found
```
@@ -530,6 +537,9 @@ buzz personas get herring # exits non-zero: not found
Checks worth making by hand:
```bash
# A member with no published persona is refused rather than seated empty
buzz teams create --name "Ghost team" --persona nobody-published; echo "exit: $?" # 1
# Desktop rejects invisible characters in definition text; so must the CLI
buzz personas create --display-name $'Reviewer' --prompt x; echo "exit: $?" # 1
@@ -541,6 +551,15 @@ buzz personas create --display-name "Herring" --prompt x --shared --replace
buzz personas create --display-name "Herring" --prompt y --replace
buzz personas get herring | grep shared # → shared: true
# A team created from --name is addressed by its slug; the miss says so
buzz teams create --name "Red team" --persona herring
buzz teams get "Red team"; echo "exit: $?" # 1, error names 'red-team'
buzz teams get red-team # found
# A team past the relay's content cap fails locally, before members resolve
buzz teams create --name "Red team" --instructions "$(head -c 300000 /dev/zero | tr '\0' a)"
echo "exit: $?" # 1, "too large to publish"
# A non-image --avatar is refused locally rather than after a round trip
buzz personas create --display-name "Herring" --prompt x --avatar ./notes.txt; echo "exit: $?" # 1
@@ -578,10 +597,15 @@ buzz personas create --from /tmp/bad.agent.json; echo "exit: $?"
# Past the 5 MiB Desktop itself refuses to import
head -c 6000000 /dev/zero | tr '\0' a > /tmp/big.agent.json
buzz personas create --from /tmp/big.agent.json; echo "exit: $?" # 1, "snapshot limit"
# Same for a team snapshot, at Desktop's 25 MiB team limit
jq '.team.instructions = ["be terse"]' ~/Downloads/Red\ team.team.json > /tmp/bad.team.json
buzz teams create --from /tmp/bad.team.json; echo "exit: $?"
# → "not a valid v1 team snapshot", exit 1
```
Cross-check in Desktop: after `personas create`, the definition appears in the
agent picker.
agent picker; after `teams create`, the team appears with every member seated.
---
@@ -726,3 +750,6 @@ buzz channels delete --channel "$FORUM_ID" | jq .
| 63a | `personas create --avatar` | ☐ | Small image inlines with its metadata stripped; large flat art downscales back to inline; EXIF-bearing photo succeeds and lands upright; non-image → exit 1; `--avatar-url` conflict or non-http(s) URL → exit 1 |
| 64 | `personas list` / `get` | ☐ | `--json` is a sig-stripped array |
| 65 | `personas delete` | ☐ | Delete→get 404; warns when a published team still lists it |
| 66 | `teams create` | ☐ | Flags, `--from` snapshot, members by d-tag and by display name, unpublished member → exit 1 |
| 67 | `teams list` / `get` | ☐ | Members absent vs. empty render differently |
| 68 | `teams delete` | ☐ | Delete→get 404; verify in Desktop the team is gone |
@@ -211,6 +211,11 @@ pub const SNAPSHOT_VERSION: u64 = 1;
/// in Buzz Desktop's persona import.
pub const MAX_AGENT_SNAPSHOT_BYTES: usize = 5 * 1024 * 1024;
/// Largest team snapshot this CLI reads, mirroring `MAX_TEAM_SNAPSHOT_JSON_BYTES`
/// in Buzz Desktop's team import. Larger than the agent cap because a team
/// carries a snapshot per member.
pub const MAX_TEAM_SNAPSHOT_BYTES: usize = 25 * 1024 * 1024;
/// Load and parse a Buzz Desktop snapshot export (`.agent.json` /
/// `.team.json`), checking the `format` and `version` envelope before the caller
/// reads its payload. Refuses a file past `max_bytes`.
+1
View File
@@ -18,6 +18,7 @@ pub mod projects;
pub mod reactions;
pub mod repos;
pub mod social;
pub mod teams;
pub mod upload;
pub mod users;
pub mod workflows;
+868
View File
@@ -0,0 +1,868 @@
//! `buzz teams` — create, list, get, and delete kind:30176 team definitions.
//!
//! A team is a named set of persona definitions plus optional layered
//! instructions. Membership is by persona d-tag, and `create` resolves every
//! `--persona` against the personas this identity has actually published: a team
//! naming a persona that does not exist yet is rejected rather than published as
//! a dangling reference.
use std::collections::{BTreeMap, BTreeSet};
use buzz_core::kind::{KIND_PERSONA, KIND_TEAM};
use buzz_sdk::agent_definitions::{
build_team_delete, build_team_event, event_d_tag, normalize_d_tag, persona_content_from_event,
team_content_from_event, TeamEventContent, MAX_D_TAG_LEN,
};
use super::definitions::{
fetch_head, list_owned, print_event_json, print_write_response, publish_definition,
read_body_file, read_snapshot, MAX_EVENT_CONTENT_LEN, MAX_TEAM_SNAPSHOT_BYTES,
};
use crate::client::BuzzClient;
use crate::error::CliError;
use crate::validate::sdk_err;
use crate::{TeamCreateArgs, TeamsCmd};
/// Snapshot envelope written by Buzz Desktop's team export.
const TEAM_SNAPSHOT_FORMAT: &str = "buzz-team-snapshot";
pub async fn dispatch(cmd: TeamsCmd, client: &BuzzClient) -> Result<(), CliError> {
match cmd {
TeamsCmd::Create(args) => cmd_create(client, args).await,
TeamsCmd::List { json } => cmd_list(client, json).await,
TeamsCmd::Get { id, json } => cmd_get(client, &id, json).await,
TeamsCmd::Delete { id } => cmd_delete(client, &id).await,
}
}
/// The v1 `team` header fields a team event publishes.
#[derive(serde::Deserialize)]
struct SnapshotTeam {
name: Option<String>,
description: Option<String>,
instructions: Option<String>,
}
/// Resolve `--from` and the individual flags into a team id plus content body.
///
/// `instructions` and `persona_ids` are always `Some`: the outer `None` on the
/// wire means "publisher predates always-publish, preserve local", which a new
/// client must never claim. An empty instruction set is published as `null` and
/// an empty roster as `[]`, so a reader can tell "cleared" from "unknown".
fn resolve_create(args: &TeamCreateArgs) -> Result<(String, TeamEventContent), CliError> {
let mut name = None;
let mut description = None;
let mut instructions = None;
let mut personas: Vec<String> = Vec::new();
let mut id_from_file = None;
if let Some(path) = &args.from {
let snapshot = read_snapshot(path, TEAM_SNAPSHOT_FORMAT, MAX_TEAM_SNAPSHOT_BYTES)?;
let raw_team = snapshot
.get("team")
.ok_or_else(|| CliError::Usage(format!("'{path}' has no `team` object to publish")))?;
// Typed, so a wrong-typed field is an error: probing with `as_str` would
// drop a non-string `instructions` and publish a team missing the text
// that drives it, reporting success.
let team: SnapshotTeam = serde_json::from_value(raw_team.clone()).map_err(|e| {
CliError::Usage(format!(
"'{path}' team is not a valid v1 team snapshot: {e}"
))
})?;
name = team.name;
description = team.description;
instructions = team.instructions;
id_from_file = name.clone();
// Members are embedded agent snapshots, which identify a persona only by
// display name — there is no id in the export. Keep the name verbatim;
// cmd_create resolves it against what is actually published, because
// Desktop publishes personas under their record id (usually a UUID) and
// slugifying the name here would never match one.
// Dropping an unreadable member would publish a smaller roster and still
// report success — silent membership loss is the exact failure the
// persona_ids tri-state exists to prevent, so refuse instead.
personas = match snapshot.get("members") {
None => Vec::new(),
Some(serde_json::Value::Array(members)) => {
let mut out = Vec::with_capacity(members.len());
for (i, m) in members.iter().enumerate() {
let name = m
.get("definition")
.and_then(|d| d.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
CliError::Usage(format!(
"'{path}' member {i} has no string `definition.name`"
))
})?;
out.push(name.to_owned());
}
out
}
Some(_) => {
return Err(CliError::Usage(format!(
"'{path}' has a non-array `members` field"
)))
}
};
}
if let Some(v) = &args.name {
name = Some(v.clone());
}
if let Some(v) = &args.description {
description = Some(v.clone());
}
match (&args.instructions, &args.instructions_file) {
(Some(_), Some(_)) => {
return Err(CliError::Usage(
"--instructions and --instructions-file are mutually exclusive".into(),
))
}
(Some(v), None) => instructions = Some(v.clone()),
(None, Some(path)) => instructions = Some(read_body_file(path, "instructions")?),
(None, None) => {}
}
if !args.persona.is_empty() {
// Verbatim, like the snapshot path: cmd_create resolves a handle to a
// published d-tag, accepting either a slug or a persona display name.
personas = args.persona.clone();
}
let name = name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
CliError::Usage("--name is required (or supply --from with a team name)".into())
})?;
// Duplicate members would publish the same persona twice in one roster.
let mut seen = BTreeSet::new();
personas.retain(|p| seen.insert(p.clone()));
// An explicit --id is used verbatim; only a derived one is slugified. The
// relay enforces the slug grammar on persona d-tags but only a length bound
// on team ids, and Buzz Desktop team ids are raw UUIDs or identifiers like
// `builtin-team:welcome` — normalizing here would address a different
// coordinate than the one Desktop wrote, making its teams unreachable.
let id = match &args.id {
// The relay has no envelope validator for kind:30176 — only a generic
// length cap — so a blank id would publish at a junk coordinate that
// later writers collide with, which is last-write-wins data loss. Only
// an explicit id needs the guard: normalize_d_tag always yields a
// non-empty slug within MAX_D_TAG_LEN.
Some(id) => {
if id.trim().is_empty() {
return Err(CliError::Usage("--id must not be empty or blank".into()));
}
if id.len() > MAX_D_TAG_LEN {
return Err(CliError::Usage(format!(
"--id must not exceed {MAX_D_TAG_LEN} bytes (got {})",
id.len()
)));
}
id.clone()
}
None => normalize_d_tag(&id_from_file.unwrap_or_else(|| name.clone())),
};
let content = TeamEventContent {
name,
description,
instructions: Some(instructions),
persona_ids: Some(personas),
};
// Report the relay's own cap here rather than letting the write fail with an
// opaque rejection after every member has already been resolved. Only the
// cap — Desktop applies no text rules to a team, so a CLI-only one would
// refuse a roster Desktop exported legitimately.
let len = serde_json::to_string(&content)
.map(|s| s.len())
.unwrap_or(0);
if len > MAX_EVENT_CONTENT_LEN {
return Err(CliError::Usage(format!(
"team is too large to publish ({len} bytes, max {MAX_EVENT_CONTENT_LEN}); \
shorten --instructions"
)));
}
Ok((id, content))
}
/// Resolve each requested member to a published persona d-tag, rejecting any
/// that has no published kind:30175 definition.
///
/// A team event stores d-tags, not definitions, so a missing persona is a
/// dangling reference that surfaces later as an empty seat rather than an error.
///
/// A handle may be a d-tag or a persona's display name: a Desktop team snapshot
/// names its members only by display name, and Desktop publishes personas under
/// their record id — usually a UUID — so matching on the slugified name alone
/// would reject every Desktop-published roster.
async fn resolve_members(
client: &BuzzClient,
requested: &[String],
) -> Result<Vec<String>, CliError> {
if requested.is_empty() {
return Ok(Vec::new());
}
let events = list_owned(client, KIND_PERSONA).await?;
let mut d_tags: BTreeSet<&str> = BTreeSet::new();
let mut by_display: BTreeMap<String, Vec<&str>> = BTreeMap::new();
for event in &events {
let Some(d_tag) = event_d_tag(event) else {
continue;
};
d_tags.insert(d_tag);
if let Ok(content) = persona_content_from_event(event) {
by_display
.entry(content.display_name.to_lowercase())
.or_default()
.push(d_tag);
}
}
match_members(requested, &d_tags, &by_display)
}
/// Match member handles against an index of published personas.
///
/// Tried in order: exact d-tag, slugified d-tag, then unique display name
/// (case-insensitive). An ambiguous display name is an error rather than a
/// guess, since picking the wrong persona seats the wrong agent.
fn match_members(
requested: &[String],
d_tags: &BTreeSet<&str>,
by_display: &BTreeMap<String, Vec<&str>>,
) -> Result<Vec<String>, CliError> {
let mut resolved = Vec::with_capacity(requested.len());
let mut missing = Vec::new();
for handle in requested {
if let Some(hit) = d_tags.get(handle.as_str()) {
resolved.push((*hit).to_owned());
continue;
}
let slug = normalize_d_tag(handle);
if let Some(hit) = d_tags.get(slug.as_str()) {
resolved.push((*hit).to_owned());
continue;
}
match by_display.get(&handle.to_lowercase()).map(Vec::as_slice) {
Some([only]) => resolved.push((*only).to_owned()),
Some(many) => {
return Err(CliError::Usage(format!(
"'{handle}' matches {} published personas ({}); \
pass the slug you want with --persona",
many.len(),
many.join(", ")
)));
}
_ => missing.push(handle.as_str()),
}
}
if !missing.is_empty() {
return Err(CliError::NotFound(format!(
"no published persona for: {}. Create each one first with \
`buzz personas create` (see `buzz personas list`).",
missing.join(", ")
)));
}
// Two handles can resolve to the same persona (a slug and its display name),
// which would seat it twice.
let mut seen = BTreeSet::new();
resolved.retain(|p| seen.insert(p.clone()));
Ok(resolved)
}
/// Find the published team whose name matches `name`, if exactly one does.
///
/// Ambiguity returns `None` rather than guessing: writing to the wrong
/// coordinate would overwrite an unrelated team.
async fn find_team_by_name(client: &BuzzClient, name: &str) -> Result<Option<String>, CliError> {
let events = list_owned(client, KIND_TEAM).await?;
let mut hits: Vec<&str> = Vec::new();
for event in &events {
let Some(d_tag) = event_d_tag(event) else {
continue;
};
if let Ok(content) = team_content_from_event(event) {
if content.name.eq_ignore_ascii_case(name) {
hits.push(d_tag);
}
}
}
match hits.as_slice() {
[only] => Ok(Some((*only).to_owned())),
_ => Ok(None),
}
}
async fn cmd_create(client: &BuzzClient, args: TeamCreateArgs) -> Result<(), CliError> {
let (mut id, mut content) = resolve_create(&args)?;
// Membership is resolved before the existence check so a typo'd roster fails
// the same way whether or not the team already exists. resolve_create leaves
// handles verbatim; the event must carry d-tags.
let members = resolve_members(client, &content.persona_ids.clone().unwrap_or_default()).await?;
content.persona_ids = Some(members.clone());
// A team snapshot carries no id, so a derived one is a slugified name — but
// Desktop publishes teams under a UUID or an id like `builtin-team:welcome`.
// Writing the derived id would mint a second coordinate for a team that
// already exists, leaving Desktop with two records of the same name. Adopt
// the published team's own id when the name matches, the same way member
// handles resolve.
if args.id.is_none() {
if let Some(existing) = find_team_by_name(client, &content.name).await? {
if existing != id {
eprintln!(
"note: adopting published team id '{existing}' for '{}'",
content.name
);
id = existing;
}
}
}
let head = fetch_head(client, KIND_TEAM, &id).await?;
if head.is_some() && !args.replace {
return Err(CliError::Conflict(format!(
"team '{id}' already exists — pass --replace to overwrite it"
)));
}
let builder = build_team_event(&id, &content).map_err(sdk_err)?;
let (event, response) = publish_definition(client, builder, head.as_ref()).await?;
let verb = if head.is_some() {
"replaced"
} else {
"created"
};
eprintln!(
"{verb} team {id} ({}) with {} member(s) — event {}",
content.name,
members.len(),
event.id.to_hex()
);
print_write_response(response, "team_id", &id);
Ok(())
}
async fn cmd_list(client: &BuzzClient, json: bool) -> Result<(), CliError> {
let events = list_owned(client, KIND_TEAM).await?;
if json {
let items: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": event_d_tag(e),
"event_id": e.id.to_hex(),
"created_at": e.created_at.as_secs(),
"content": team_content_from_event(e).ok(),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&items)
.map_err(|e| CliError::Other(format!("failed to render JSON: {e}")))?
);
return Ok(());
}
if events.is_empty() {
eprintln!("no teams published by this identity");
return Ok(());
}
for event in &events {
let id = event_d_tag(event).unwrap_or("<no d-tag>");
match team_content_from_event(event) {
Ok(c) => {
let members = c.persona_ids.unwrap_or_default();
println!("{id:<24} {:<32} {}", c.name, members.join(", "));
}
Err(e) => println!("{id:<24} <unparseable content: {e}>"),
}
}
Ok(())
}
/// Report a missing team, naming the slug when the id would have been one.
///
/// Lookups are verbatim because Desktop writes raw UUIDs and ids like
/// `builtin-team:welcome`, which normalizing would point at a different
/// coordinate. But `create` slugifies an id it derives from `--name`, so
/// `teams get "Red team"` misses the `red-team` it just published.
fn team_not_found(id: &str) -> CliError {
let slug = normalize_d_tag(id);
if slug == id {
return CliError::NotFound(format!("no team '{id}' for this identity"));
}
CliError::NotFound(format!(
"no team '{id}' for this identity — a team created from --name is addressed by its \
slug, so try '{slug}'"
))
}
async fn cmd_get(client: &BuzzClient, id: &str, json: bool) -> Result<(), CliError> {
// Verbatim: an explicit --id publishes verbatim, so reads must match it.
let event = fetch_head(client, KIND_TEAM, id)
.await?
.ok_or_else(|| team_not_found(id))?;
if json {
return print_event_json(&event);
}
let c = team_content_from_event(&event).map_err(sdk_err)?;
println!("id: {id}");
println!("name: {}", c.name);
println!(
"description: {}",
c.description.unwrap_or_else(|| "-".into())
);
match c.persona_ids {
Some(ids) if ids.is_empty() => println!("members: (none)"),
Some(ids) => println!("members: {}", ids.join(", ")),
// Absent, not empty: this publisher predates always-publish, so its
// real membership is unknown rather than empty.
None => println!("members: (not published — unknown)"),
}
println!("event: {}", event.id.to_hex());
if let Some(Some(instructions)) = c.instructions {
println!("\n{instructions}");
}
Ok(())
}
async fn cmd_delete(client: &BuzzClient, id: &str) -> Result<(), CliError> {
// Verbatim: an explicit --id publishes verbatim, so reads must match it.
let head = fetch_head(client, KIND_TEAM, id).await?;
if head.is_none() {
return Err(team_not_found(id));
}
let builder = build_team_delete(id, &client.keys().public_key().to_hex()).map_err(sdk_err)?;
// NIP-09 scopes an `a`-tag delete to versions at or before the tombstone's
// own created_at, so the tombstone must outrank the head it targets. A team
// replaced within this same second carries a bumped created_at, and a
// tombstone stamped with a bare `now` would be silently ignored.
let (event, response) = publish_definition(client, builder, head.as_ref()).await?;
// The relay accepts a tombstone that deletes nothing and only debug-logs the
// miss, so acceptance is not evidence. Re-read the coordinate to confirm.
//
// A stale read is not a race: buzz-db documents that "stale deletions can
// briefly inflate the result set", so a lagging replica can still return the
// head we just deleted. That head is always STRICTLY older than its own
// tombstone, which is stamped past it — so a survivor at or past the
// tombstone's stamp is a different event, one the delete did not cover.
if let Some(survivor) = fetch_head(client, KIND_TEAM, id).await? {
if survivor.created_at >= event.created_at {
return Err(CliError::Conflict(format!(
"team '{id}' still exists (head at {}); a concurrent write raced the delete",
survivor.created_at.as_secs()
)));
}
}
eprintln!("deleted team {id} — tombstone {}", event.id.to_hex());
print_write_response(response, "team_id", id);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The relay would reject this write anyway, but only after every member has
/// been resolved and with nothing naming the cause.
#[test]
fn a_team_past_the_relay_content_cap_is_refused_locally() {
let err = resolve_create(&TeamCreateArgs {
name: Some("Red team".into()),
instructions: Some("a".repeat(MAX_EVENT_CONTENT_LEN + 1)),
..args()
})
.unwrap_err();
assert!(err.to_string().contains("too large to publish"), "{err}");
}
/// Desktop applies no text rules to a team, so a CLI-only one would refuse a
/// roster Desktop exported legitimately. Long-but-publishable text passes.
#[test]
fn team_text_is_not_validated_beyond_the_cap() {
assert!(resolve_create(&TeamCreateArgs {
name: Some("Red team".into()),
instructions: Some(format!("Ship\u{200B}it{}", "a".repeat(64 * 1024))),
..args()
})
.is_ok());
}
/// `create` slugifies an id derived from `--name`, so a read with the name
/// misses. Lookups stay verbatim — Desktop writes ids like
/// `builtin-team:welcome` — so the error has to name the slug instead.
#[test]
fn a_missing_team_names_the_slug_it_would_have() {
let err = team_not_found("Red team").to_string();
assert!(err.contains("red-team"), "{err}");
// An id that is already a slug has nothing to suggest.
let plain = team_not_found("red-team").to_string();
assert!(!plain.contains("try"), "{plain}");
}
fn args() -> TeamCreateArgs {
TeamCreateArgs {
id: None,
name: None,
description: None,
instructions: None,
instructions_file: None,
persona: Vec::new(),
from: None,
replace: false,
}
}
fn snapshot_file(body: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"buzz-teams-{}-{}",
super::super::definitions::now_secs(),
body.len()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("team.json");
std::fs::write(&path, body).unwrap();
(dir, path)
}
#[test]
fn name_is_required() {
let err = resolve_create(&args()).unwrap_err();
assert!(err.to_string().contains("--name is required"), "{err}");
}
/// A new client must always publish both fields: the outer `None` means
/// "unknown, preserve local", which would let this write wipe a roster.
#[test]
fn always_publishes_instructions_and_members() {
let (_, content) = resolve_create(&TeamCreateArgs {
name: Some("Red team".into()),
..args()
})
.unwrap();
assert_eq!(content.instructions, Some(None), "cleared, not unknown");
assert_eq!(content.persona_ids, Some(vec![]), "emptied, not unknown");
let json = serde_json::to_string(&content).unwrap();
assert!(json.contains(r#""instructions":null"#), "{json}");
assert!(json.contains(r#""persona_ids":[]"#), "{json}");
}
#[test]
fn id_defaults_to_the_normalized_name() {
let (id, _) = resolve_create(&TeamCreateArgs {
name: Some("Red Team".into()),
..args()
})
.unwrap();
assert_eq!(id, "red-team");
}
/// Buzz Desktop team ids are raw UUIDs or identifiers like
/// `builtin-team:welcome`. Slugifying an explicit --id would address a
/// different coordinate than Desktop wrote, so its teams would be
/// unreachable from the CLI.
#[test]
fn explicit_id_is_used_verbatim() {
for raw in [
"builtin-team:welcome",
"9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50",
] {
let (id, _) = resolve_create(&TeamCreateArgs {
id: Some(raw.into()),
name: Some("Whatever".into()),
..args()
})
.unwrap();
assert_eq!(id, raw);
}
}
/// Handles stay verbatim here — `resolve_members` maps them to published
/// d-tags, because a display name is not necessarily its own slug. Only
/// exact duplicates collapse at this stage.
#[test]
fn members_are_kept_verbatim_and_deduped() {
let (_, content) = resolve_create(&TeamCreateArgs {
name: Some("T".into()),
persona: vec!["Herring".into(), "Herring".into(), "quinby".into()],
..args()
})
.unwrap();
assert_eq!(
content.persona_ids,
Some(vec!["Herring".into(), "quinby".into()])
);
}
#[test]
fn instructions_flags_conflict() {
let err = resolve_create(&TeamCreateArgs {
name: Some("T".into()),
instructions: Some("a".into()),
instructions_file: Some("b".into()),
..args()
})
.unwrap_err();
assert!(err.to_string().contains("mutually exclusive"), "{err}");
}
/// A team snapshot embeds full agent definitions; the team event references
/// them by d-tag, so members are reduced to normalized slugs.
#[test]
fn from_reads_a_desktop_team_snapshot() {
let (dir, path) = snapshot_file(
r#"{
"format": "buzz-team-snapshot",
"version": 1,
"team": {"name": "Red team", "description": "Three skeptics."},
"members": [
{"definition": {"name": "Monocle"}},
{"definition": {"name": "Quinby"}},
{"definition": {"name": "Herring"}}
]
}"#,
);
let (id, content) = resolve_create(&TeamCreateArgs {
from: Some(path.to_str().unwrap().into()),
..args()
})
.unwrap();
assert_eq!(id, "red-team");
assert_eq!(content.name, "Red team");
assert_eq!(content.description.as_deref(), Some("Three skeptics."));
// Display names verbatim: Desktop publishes personas under a UUID d-tag,
// so these are resolved against the relay, not slugified here.
assert_eq!(
content.persona_ids,
Some(vec!["Monocle".into(), "Quinby".into(), "Herring".into()])
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn persona_flags_replace_the_snapshot_roster() {
let (dir, path) = snapshot_file(
r#"{"format":"buzz-team-snapshot","version":1,
"team":{"name":"Red team"},
"members":[{"definition":{"name":"Monocle"}}]}"#,
);
let (_, content) = resolve_create(&TeamCreateArgs {
from: Some(path.to_str().unwrap().into()),
persona: vec!["herring".into()],
..args()
})
.unwrap();
assert_eq!(content.persona_ids, Some(vec!["herring".into()]));
std::fs::remove_dir_all(&dir).ok();
}
/// Silently dropping a member would publish a smaller roster and still
/// report success — the exact loss the `persona_ids` tri-state guards.
#[test]
fn from_rejects_a_member_without_a_definition_name() {
let (dir, path) = snapshot_file(
r#"{"format":"buzz-team-snapshot","version":1,
"team":{"name":"Red team"},
"members":[{"definition":{"name":"Monocle"}},{"definition":{}}]}"#,
);
let err = resolve_create(&TeamCreateArgs {
from: Some(path.to_str().unwrap().into()),
..args()
})
.unwrap_err();
assert!(err.to_string().contains("member 1 has no string"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn from_rejects_a_non_array_members_field() {
let (dir, path) = snapshot_file(
r#"{"format":"buzz-team-snapshot","version":1,
"team":{"name":"Red team"},"members":{"a":1}}"#,
);
let err = resolve_create(&TeamCreateArgs {
from: Some(path.to_str().unwrap().into()),
..args()
})
.unwrap_err();
assert!(err.to_string().contains("non-array `members`"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
/// Probing with `as_str` would drop a wrong-typed field and publish a team
/// missing the instructions that drive it, reporting success.
#[test]
fn from_rejects_a_wrong_typed_team_field() {
let (dir, path) = snapshot_file(
r#"{"format":"buzz-team-snapshot","version":1,
"team":{"name":"Red team","instructions":["be terse"]}}"#,
);
let err = resolve_create(&TeamCreateArgs {
from: Some(path.to_str().unwrap().into()),
..args()
})
.unwrap_err();
assert!(
err.to_string().contains("not a valid v1 team snapshot"),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// The relay only length-bounds a team d-tag, so the CLI is the sole guard
/// against publishing at a junk coordinate.
#[test]
fn blank_explicit_id_is_refused() {
let err = resolve_create(&TeamCreateArgs {
id: Some(" ".into()),
name: Some("Red team".into()),
..args()
})
.unwrap_err();
assert!(err.to_string().contains("must not be empty"), "{err}");
}
#[test]
fn oversized_explicit_id_is_refused() {
let err = resolve_create(&TeamCreateArgs {
id: Some("t".repeat(MAX_D_TAG_LEN + 1)),
name: Some("Red team".into()),
..args()
})
.unwrap_err();
assert!(err.to_string().contains("must not exceed"), "{err}");
}
/// The derived path carries no id guard because normalization can't
/// produce a blank or oversized d-tag — pin that so the omission stays true.
#[test]
fn derived_id_is_always_within_the_relay_bound() {
// A blank name never gets this far — --name rejects it first.
for name in ["!!!", "-", &"z".repeat(MAX_D_TAG_LEN * 2)] {
let (id, _) = resolve_create(&TeamCreateArgs {
name: Some(name.into()),
..args()
})
.unwrap();
assert!(!id.trim().is_empty(), "blank id from {name:?}");
assert!(id.len() <= MAX_D_TAG_LEN, "oversized id from {name:?}");
}
}
#[test]
fn from_rejects_an_agent_snapshot() {
let (dir, path) = snapshot_file(r#"{"format":"buzz-agent-snapshot","version":1}"#);
let err = resolve_create(&TeamCreateArgs {
from: Some(path.to_str().unwrap().into()),
..args()
})
.unwrap_err();
assert!(
err.to_string().contains("expected 'buzz-team-snapshot'"),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
// ── member resolution ────────────────────────────────────────────────────
fn index<'a>(
published: &[(&'a str, &'a str)],
) -> (BTreeSet<&'a str>, BTreeMap<String, Vec<&'a str>>) {
let mut d_tags = BTreeSet::new();
let mut by_display: BTreeMap<String, Vec<&str>> = BTreeMap::new();
for (d_tag, display) in published {
d_tags.insert(*d_tag);
by_display
.entry(display.to_lowercase())
.or_default()
.push(*d_tag);
}
(d_tags, by_display)
}
/// The Desktop→CLI case: personas published under UUID d-tags, a team
/// snapshot naming them only by display name.
#[test]
fn display_names_resolve_to_uuid_d_tags() {
let (d, n) = index(&[
("6f1e2c9a-0b3d-4e5f-8a7b-1c2d3e4f5a6b", "Monocle"),
("7a2f3d0b-1c4e-5f6a-9b8c-2d3e4f5a6b7c", "Herring"),
]);
let got = match_members(&["Monocle".into(), "Herring".into()], &d, &n).unwrap();
assert_eq!(
got,
vec![
"6f1e2c9a-0b3d-4e5f-8a7b-1c2d3e4f5a6b".to_string(),
"7a2f3d0b-1c4e-5f6a-9b8c-2d3e4f5a6b7c".to_string(),
]
);
}
/// The CLI-only case still works: the slug is the d-tag.
#[test]
fn slugs_resolve_directly() {
let (d, n) = index(&[("herring", "Herring")]);
assert_eq!(
match_members(&["herring".into()], &d, &n).unwrap(),
vec!["herring".to_string()]
);
// A display name that slugifies onto its own d-tag resolves too.
assert_eq!(
match_members(&["Herring".into()], &d, &n).unwrap(),
vec!["herring".to_string()]
);
}
/// A d-tag hit must win over a display-name hit pointing elsewhere.
#[test]
fn exact_d_tag_wins_over_display_name() {
let (d, n) = index(&[("herring", "Quinby"), ("quinby-2", "Herring")]);
assert_eq!(
match_members(&["herring".into()], &d, &n).unwrap(),
vec!["herring".to_string()]
);
}
#[test]
fn ambiguous_display_name_is_refused() {
let (d, n) = index(&[("uuid-a", "Herring"), ("uuid-b", "Herring")]);
let err = match_members(&["Herring".into()], &d, &n).unwrap_err();
assert!(
err.to_string().contains("matches 2 published personas"),
"{err}"
);
}
#[test]
fn unpublished_member_is_refused() {
let (d, n) = index(&[("herring", "Herring")]);
let err = match_members(&["ghost".into()], &d, &n).unwrap_err();
assert!(
err.to_string().contains("no published persona for: ghost"),
"{err}"
);
}
/// A slug and its display name are one persona, not two seats.
#[test]
fn handles_converging_on_one_persona_dedupe() {
let (d, n) = index(&[("herring", "Herring")]);
assert_eq!(
match_members(&["herring".into(), "Herring".into()], &d, &n).unwrap(),
vec!["herring".to_string()]
);
}
}
+83
View File
@@ -240,6 +240,9 @@ enum Cmd {
/// Publish and manage agent definitions (personas) on the relay
#[command(subcommand)]
Personas(PersonasCmd),
/// Group published personas into teams
#[command(subcommand)]
Teams(TeamsCmd),
/// Community moderation — reports queue, bans, timeouts, audit trail
#[command(subcommand)]
Moderation(ModerationCmd),
@@ -1954,6 +1957,80 @@ pub enum PersonasCmd {
},
}
/// Flags for `buzz teams create`.
///
/// A `clap::Args` struct rather than inline variant fields so the CLI surface
/// and the resolver share one definition of the create inputs.
#[derive(clap::Args)]
pub struct TeamCreateArgs {
/// Team id (the event d-tag), used verbatim. Defaults to the name,
/// slugified. Buzz Desktop ids are raw UUIDs or identifiers like
/// `builtin-team:welcome` — pass one exactly as Desktop shows it
#[arg(long)]
pub id: Option<String>,
/// Team name
#[arg(long)]
pub name: Option<String>,
/// Short description
#[arg(long)]
pub description: Option<String>,
/// Instructions layered over each member's own prompt
#[arg(long, conflicts_with = "instructions_file")]
pub instructions: Option<String>,
/// Read the instructions from a file
#[arg(long)]
pub instructions_file: Option<String>,
/// Member persona slug; repeat for each member. Replaces the roster from
/// --from when both are given
#[arg(long)]
pub persona: Vec<String>,
/// Read fields from a Buzz Desktop `.team.json` export; members are taken
/// as persona slugs and must already be published
#[arg(long)]
pub from: Option<String>,
/// Overwrite an existing team at this id
#[arg(long)]
pub replace: bool,
}
/// Subcommands for `buzz teams` — kind:30176 team definitions.
///
/// A team references personas by slug, so every member must already be
/// published; `create` rejects a roster naming a persona that does not exist.
// clap cannot derive `Args` through a `Box`, and a subcommand enum is built
// once per process — boxing to even out variant sizes would trade an allocation
// for nothing.
#[allow(clippy::large_enum_variant)]
#[derive(Subcommand)]
pub enum TeamsCmd {
/// Publish a team over already-published personas
#[command(after_help = "Examples:\n \
buzz teams create --name 'Red team' --persona monocle --persona quinby --persona herring\n \
buzz teams create --from ./red-team.team.json\n \
buzz teams create --id red-team --name 'Red team' --instructions-file ./charter.md \\\n \
--persona herring --replace")]
Create(TeamCreateArgs),
/// List teams published by this identity
List {
/// Emit JSON instead of a table
#[arg(long)]
json: bool,
},
/// Show one team
Get {
/// Team id
id: String,
/// Emit the relay event as a sig-stripped JSON array
#[arg(long)]
json: bool,
},
/// Delete a team (NIP-09 coordinate tombstone)
Delete {
/// Team id
id: String,
},
}
/// Community moderation commands.
///
/// The community (tenant) is selected by the relay host in `--relay` /
@@ -2155,6 +2232,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
Cmd::Personas(sub) => commands::personas::dispatch(sub, &client).await,
Cmd::Teams(sub) => commands::teams::dispatch(sub, &client).await,
Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await,
Cmd::Pack(_) => unreachable!("handled above"),
}
@@ -2281,6 +2359,7 @@ mod tests {
"reactions",
"repos",
"social",
"teams",
"upload",
"users",
"workflows",
@@ -2373,6 +2452,10 @@ mod tests {
names(&cmd, "personas"),
vec!["create", "delete", "get", "list"]
);
assert_eq!(
names(&cmd, "teams"),
vec!["create", "delete", "get", "list"]
);
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
assert_eq!(
+99
View File
@@ -61,6 +61,38 @@ fn team_delete_event(keys: &Keys, d_tag: &str) -> nostr::Event {
.unwrap()
}
fn team_delete_event_at(keys: &Keys, d_tag: &str, created_at: u64) -> nostr::Event {
let coord = format!("{TEAM_KIND}:{}:{d_tag}", keys.public_key().to_hex());
EventBuilder::new(Kind::Custom(5), "")
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()])
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap()
}
/// Count the live events at a team's NIP-33 coordinate.
async fn live_team_count(
client: &mut BuzzTestClient,
keys: &Keys,
d_tag: &str,
name: &str,
) -> usize {
let sid = sub_id(name);
let filter = Filter::new()
.kind(Kind::Custom(TEAM_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect")
.len()
}
#[tokio::test]
#[ignore]
async fn test_team_publish_and_query() {
@@ -210,3 +242,70 @@ async fn test_team_tombstone_deletes_coordinate() {
client.disconnect().await.expect("disconnect");
}
/// A tombstone older than the head it targets is accepted but does nothing —
/// `soft_delete_by_coordinate` matches on `created_at <= tombstone.created_at`,
/// and its return value only feeds a debug log, so the relay reports OK either
/// way. Any writer that bumps a head to `max(now, prior + 1)` (NIP-33 tie-break)
/// can therefore land a head in the future and then fail to delete it with a
/// tombstone stamped at a bare `now`. Publishers must stamp the tombstone at or
/// after the head; this pins the rule they depend on.
#[tokio::test]
#[ignore]
async fn test_tombstone_older_than_head_does_not_delete() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("team-stale-tomb-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Head lands ahead of the wall clock, as a same-second replacement would.
let now = Timestamp::now().as_secs();
let head_at = now + 5;
let ok = client
.send_event(team_event_at(
&keys,
&d_tag,
r#"{"name":"Bumped Team","persona_ids":["p1"]}"#,
head_at,
))
.await
.expect("send head");
assert!(ok.accepted, "relay rejected head: {}", ok.message);
assert_eq!(
live_team_count(&mut client, &keys, &d_tag, "stale-pre").await,
1,
"head should be live"
);
// Stale tombstone: accepted by the relay, but must not erase a newer head.
let ok = client
.send_event(team_delete_event_at(&keys, &d_tag, now))
.await
.expect("send stale tombstone");
assert!(
ok.accepted,
"relay should still accept the stale tombstone: {}",
ok.message
);
assert_eq!(
live_team_count(&mut client, &keys, &d_tag, "stale-post").await,
1,
"a tombstone older than the head must not delete it"
);
// Same instant as the head is enough — the predicate is `<=`.
let ok = client
.send_event(team_delete_event_at(&keys, &d_tag, head_at))
.await
.expect("send matching tombstone");
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
assert_eq!(
live_team_count(&mut client, &keys, &d_tag, "stale-final").await,
0,
"a tombstone at the head's instant must delete it"
);
client.disconnect().await.expect("disconnect");
}