[1/4] buzz-sdk: own the NIP-AP persona and team event shapes

Buzz Desktop was the only implementation of the kind:30175 / kind:30176
wire format. Anything else that wanted to publish an agent definition had
to re-derive it, and a re-derivation that gets serde field order wrong
produces different content bytes, a different event id, and a persona
Desktop treats as unrelated to the one it already has.

Move the builders, content structs, and coordinate helpers into
`buzz_sdk::agent_definitions`; Desktop now calls them instead of holding
its own copy. `double_option` moves with them, because the persona/team
tri-state (absent = "unknown, preserve local" vs. null/[] = "explicitly
cleared") is part of the wire contract rather than a Desktop detail.

Field order is pinned by a byte-exact serialization test — a reorder
would silently invalidate every stored `persona_content_hash`.

Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Max Lampert
2026-08-17 14:27:12 -07:00
committed by GitHub
parent a282e0643f
commit 4ad77b2367
6 changed files with 509 additions and 165 deletions
+464
View File
@@ -0,0 +1,464 @@
//! Persona (kind:30175) and team (kind:30176) definition wire shapes.
//!
//! NIP-AP parameterized-replaceable events keyed by `(pubkey, kind, d_tag)`.
//! This module owns the published content projections and the pure
//! build/parse/delete helpers, so every publisher — the desktop app and the
//! CLI — shares one definition of the wire contract. Mapping a client's own
//! record type onto a projection stays with that client.
//!
//! Two things here are load-bearing and easy to "clean up" into silent data
//! loss: [`PersonaEventContent`]'s field order, which pins content bytes and
//! therefore event ids, and [`TeamEventContent`]'s `Option` nesting, which
//! distinguishes "unknown, preserve local" from "explicitly emptied". Read
//! their doc comments before changing either.
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
use crate::SdkError;
use buzz_core::kind::{KIND_PERSONA, KIND_TEAM};
/// NIP-09 deletion kind.
const KIND_DELETE: u16 = 5;
/// Maximum d-tag length in the NIP-AP slug grammar.
pub const MAX_D_TAG_LEN: usize = 64;
/// Deserialize a field so that "absent" and "present but null" stay distinct.
///
/// `Option<Option<T>>`: outer `None` = key absent, `Some(None)` = explicit
/// `null`, `Some(Some(v))` = set. Serde collapses absent and null together
/// without this.
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
/// The JSON body stored in a persona event's content field.
///
/// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md`
/// content body: `display_name, system_prompt, avatar_url, runtime, model,
/// provider, name_pool`). serde emits fields in declaration order, so this
/// order pins the exact content bytes and therefore the NIP-01 event id — a
/// reorder breaks cross-implementation interop.
///
/// Explicit opt-IN projection: `skip_serializing_if` on every optional field
/// keeps serialized bytes stable against pre-revision publishers, which is what
/// makes content-hash drift comparison meaningful.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersonaEventContent {
/// Human-readable name shown in clients.
pub display_name: String,
/// Optional since the unified agent model (NIP-AP revision): a definition
/// can be pure configuration. Writers emit `Some` whenever the record has a
/// prompt (including the empty string) so pre-revision content bytes are
/// unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// Avatar URL, or a data URL small enough for a reader to render inline.
/// Desktop's inline bounds are 8 KiB for `image/svg+xml` and 256 KiB for
/// raster; past those, upload to media storage and publish the URL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
/// Preferred agent harness id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
/// Model identifier, interpreted relative to `runtime`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Inference provider, when the runtime supports more than one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Candidate display names for instances created from this definition.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_pool: Vec<String>,
/// Definition-level defaults copied onto instances at creation (NIP-AP
/// behavioral fields). Absent = defer to client defaults;
/// `skip_serializing_if` keeps pre-revision hashes stable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
/// Pubkeys an instance answers when `respond_to` is allowlist-scoped.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub respond_to_allowlist: Vec<String>,
/// Concurrent turn limit copied onto instances at creation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallelism: Option<u32>,
}
/// The JSON body stored in a team event's content field.
///
/// Explicit opt-IN projection of the public team fields. A team carries no
/// secrets, but the projection is still explicit so a future record field is
/// published only when deliberately added here. Fields describing one client's
/// install (source directory, symlink state, builtin flag, timestamps) are
/// intentionally absent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamEventContent {
/// Team name.
pub name: String,
/// Optional description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Runtime-layered instructions. `Option` is PERMANENT wire semantics (not
/// a transitional shim): absent = publisher predates always-publish (true
/// value unknown — reconcile must preserve local), `null` = explicitly
/// cleared, a string = set. New clients always publish this field (outer
/// always `Some`), using `null` for "no instructions" so that state
/// round-trips instead of being read back as "unknown".
#[serde(
default,
deserialize_with = "double_option",
skip_serializing_if = "Option::is_none"
)]
pub instructions: Option<Option<String>>,
/// Persona members, by d-tag. `Option` is PERMANENT wire semantics, not a
/// transitional shim: `None` = publisher predates always-publish (its true
/// membership is unknown — reconcile must preserve local), while
/// `Some(vec![])` = explicitly emptied. New clients always publish
/// `Some(...)`, even when empty. "Cleaning up" the `Option` reintroduces
/// the bug where an old client's event silently wipes team membership (see
/// the Sietch Tabr incident).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona_ids: Option<Vec<String>>,
}
/// Normalize a raw slug to the NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`
/// (`docs/nips/NIP-AP.md:27`).
///
/// The relay enforces this grammar, so an un-normalized slug like
/// `CodeReviewer` or `_ops` is signed locally but REJECTED — pending forever.
/// The builders here deliberately do NOT call this: a d-tag is a coordinate,
/// and rewriting it under a caller would silently move an existing definition
/// to a new address. Callers route both the outbound publish and the inbound
/// match key through this fn so the two cannot drift.
///
/// - ASCII-lowercase every char.
/// - Map any char outside `[a-z0-9_-]` to `-`.
/// - If the first char is not `[a-z0-9]` (a leading `_`/`-`), prepend `a`
/// rather than trimming — trimming `_ops`→`ops` would collide with a real
/// `ops` slug, whereas the prefix keeps distinct inputs distinct.
/// - Truncate to [`MAX_D_TAG_LEN`] bytes.
///
/// Deterministic but NOT globally injective: two slugs differing only in case
/// collapse to one d-tag. That is the correct NIP-33 behavior — same logical
/// definition, one coordinate.
pub fn normalize_d_tag(raw: &str) -> String {
let mut out: String = raw
.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect();
if !out
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
out.insert(0, 'a');
}
out.truncate(MAX_D_TAG_LEN);
out
}
/// Build a kind:30175 persona event from an already-projected content body.
///
/// `d_tag` is written verbatim, so callers must pass it through
/// [`normalize_d_tag`] first — the relay enforces the slug grammar on persona
/// d-tags and rejects anything else. (Team ids are the opposite; see
/// [`build_team_event`].) `shared` adds the `["shared", "true"]` tag that marks
/// a persona for catalog discovery. Returns an unsigned builder; the caller
/// signs and submits.
pub fn build_persona_event(
d_tag: &str,
content: &PersonaEventContent,
shared: bool,
) -> Result<EventBuilder, SdkError> {
let body = serde_json::to_string(content)
.map_err(|e| SdkError::InvalidInput(format!("failed to serialize persona content: {e}")))?;
let mut tags = vec![d_tag_of(d_tag)?];
if shared {
tags.push(
Tag::parse(["shared", "true"])
.map_err(|e| SdkError::InvalidTag(format!("invalid shared tag: {e}")))?,
);
}
Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), body).tags(tags))
}
/// Build a kind:30176 team event from an already-projected content body.
///
/// `d_tag` is the team's stable id, used verbatim. Do NOT pass it through
/// [`normalize_d_tag`]: the relay only length-bounds team ids, and Buzz Desktop
/// writes raw UUIDs and ids like `builtin-team:welcome`, so normalizing would
/// address a different coordinate than the one Desktop published. Returns an
/// unsigned builder; the caller signs and submits.
pub fn build_team_event(d_tag: &str, content: &TeamEventContent) -> Result<EventBuilder, SdkError> {
let body = serde_json::to_string(content)
.map_err(|e| SdkError::InvalidInput(format!("failed to serialize team content: {e}")))?;
Ok(EventBuilder::new(Kind::Custom(KIND_TEAM as u16), body).tags(vec![d_tag_of(d_tag)?]))
}
/// Build a NIP-09 deletion targeting a persona's kind:30175 coordinate.
pub fn build_persona_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result<EventBuilder, SdkError> {
build_coordinate_delete(KIND_PERSONA, d_tag, owner_pubkey_hex)
}
/// Build a NIP-09 deletion targeting a team's kind:30176 coordinate.
pub fn build_team_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result<EventBuilder, SdkError> {
build_coordinate_delete(KIND_TEAM, d_tag, owner_pubkey_hex)
}
/// Build a kind:5 deletion carrying a single NIP-33 coordinate `a`-tag.
///
/// Deliberately no `e`-tag: an `e`-tag routes the relay to the event-id
/// deletion path, which leaves the parameterized-replaceable coordinate live.
/// The coordinate delete removes the definition for every client and across
/// reboots.
fn build_coordinate_delete(
kind: u32,
d_tag: &str,
owner_pubkey_hex: &str,
) -> Result<EventBuilder, SdkError> {
let coord = format!("{kind}:{owner_pubkey_hex}:{d_tag}");
let tag = Tag::parse(["a", coord.as_str()])
.map_err(|e| SdkError::InvalidTag(format!("invalid a-tag: {e}")))?;
Ok(EventBuilder::new(Kind::Custom(KIND_DELETE), "").tags(vec![tag]))
}
/// Parse a kind:30175 event's content into its projection.
pub fn persona_content_from_event(event: &nostr::Event) -> Result<PersonaEventContent, SdkError> {
serde_json::from_str(event.content.as_ref())
.map_err(|e| SdkError::InvalidInput(format!("failed to parse persona event content: {e}")))
}
/// Parse a kind:30176 event's content into its projection.
///
/// Returns the projection, NOT a client record: install-specific local fields
/// cannot be represented, so an inbound event can only overwrite the shared
/// fields. The caller patches them onto its local record, matching on the
/// d-tag.
pub fn team_content_from_event(event: &nostr::Event) -> Result<TeamEventContent, SdkError> {
serde_json::from_str(event.content.as_ref())
.map_err(|e| SdkError::InvalidInput(format!("failed to parse team event content: {e}")))
}
/// Read an event's `d` tag value.
pub fn event_d_tag(event: &nostr::Event) -> Option<&str> {
event.tags.iter().find_map(|tag| {
let values = tag.as_slice();
match (values.first().map(String::as_str), values.get(1)) {
(Some("d"), Some(value)) => Some(value.as_str()),
_ => None,
}
})
}
fn d_tag_of(d_tag: &str) -> Result<Tag, SdkError> {
Tag::parse(["d", d_tag]).map_err(|e| SdkError::InvalidTag(format!("invalid d-tag: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::Keys;
const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
fn persona() -> PersonaEventContent {
PersonaEventContent {
display_name: "Herring".into(),
system_prompt: Some("You own output quality.".into()),
avatar_url: None,
runtime: Some("buzz-agent".into()),
model: Some("databricks-kimi-3".into()),
provider: None,
name_pool: vec!["Herring".into()],
respond_to: Some("owner-only".into()),
respond_to_allowlist: Vec::new(),
parallelism: Some(24),
}
}
fn team() -> TeamEventContent {
TeamEventContent {
name: "Red team".into(),
description: Some("Three skeptics.".into()),
instructions: Some(None),
persona_ids: Some(vec!["quinby".into(), "herring".into()]),
}
}
fn sign(builder: EventBuilder) -> nostr::Event {
builder.sign_with_keys(&Keys::generate()).unwrap()
}
#[test]
fn persona_event_carries_kind_d_tag_and_content() {
let event = sign(build_persona_event("herring", &persona(), false).unwrap());
assert_eq!(event.kind.as_u16() as u32, KIND_PERSONA);
assert_eq!(event_d_tag(&event), Some("herring"));
assert_eq!(persona_content_from_event(&event).unwrap(), persona());
}
/// A d-tag is a coordinate: normalizing inside the builder would move an
/// existing definition to a new address behind the caller's back.
#[test]
fn builders_use_the_d_tag_verbatim() {
let persona_event = sign(build_persona_event("CodeReviewer", &persona(), false).unwrap());
assert_eq!(event_d_tag(&persona_event), Some("CodeReviewer"));
let team_event = sign(build_team_event("Sietch-Tabr", &team()).unwrap());
assert_eq!(event_d_tag(&team_event), Some("Sietch-Tabr"));
}
#[test]
fn shared_flag_controls_the_shared_tag() {
for shared in [false, true] {
let event = sign(build_persona_event("herring", &persona(), shared).unwrap());
let has = event
.tags
.iter()
.any(|t| t.as_slice().first().map(String::as_str) == Some("shared"));
assert_eq!(has, shared, "shared={shared}");
}
}
/// Field order pins the content bytes and therefore the event id; a reorder
/// breaks cross-implementation interop and every stored content hash.
#[test]
fn persona_content_field_order_matches_nip_ap() {
// Byte-exact, every field populated: a substring-order check would miss
// a field appearing in the wrong place among the ones it doesn't name.
let full = PersonaEventContent {
display_name: "Herring".into(),
system_prompt: Some("Ask the annoying question.".into()),
avatar_url: Some("https://example.invalid/h.png".into()),
runtime: Some("claude".into()),
model: Some("claude-opus-5".into()),
provider: Some("anthropic".into()),
name_pool: vec!["Herring".into()],
respond_to: Some("allowlist".into()),
respond_to_allowlist: vec!["ab".repeat(32)],
parallelism: Some(2),
};
let allowlisted = "ab".repeat(32);
let expected = concat!(
r#"{"display_name":"Herring","system_prompt":"Ask the annoying question.","#,
r#""avatar_url":"https://example.invalid/h.png","runtime":"claude","#,
r#""model":"claude-opus-5","provider":"anthropic","name_pool":["Herring"],"#,
r#""respond_to":"allowlist","respond_to_allowlist":["{ALLOWLISTED}"],"parallelism":2}"#,
)
.replace("{ALLOWLISTED}", &allowlisted);
assert_eq!(serde_json::to_string(&full).unwrap(), expected);
// Absent optionals stay absent so pre-revision bytes are unchanged.
let json = serde_json::to_string(&persona()).unwrap();
assert!(!json.contains("avatar_url"), "{json}");
assert!(!json.contains("provider"), "{json}");
assert!(!json.contains("respond_to_allowlist"), "{json}");
}
#[test]
fn team_event_round_trips_through_content() {
let event = sign(build_team_event("red-team", &team()).unwrap());
assert_eq!(event.kind.as_u16() as u32, KIND_TEAM);
assert_eq!(event_d_tag(&event), Some("red-team"));
assert_eq!(team_content_from_event(&event).unwrap(), team());
}
/// Absent vs explicitly-null must stay distinguishable: absent means
/// "publisher predates always-publish, preserve local", null means
/// "cleared". Collapsing them wipes team state.
#[test]
fn absent_and_null_instructions_are_distinct() {
let absent: TeamEventContent =
serde_json::from_str(r#"{"name":"T","persona_ids":[]}"#).unwrap();
assert_eq!(absent.instructions, None);
let cleared: TeamEventContent =
serde_json::from_str(r#"{"name":"T","instructions":null,"persona_ids":[]}"#).unwrap();
assert_eq!(cleared.instructions, Some(None));
let set: TeamEventContent =
serde_json::from_str(r#"{"name":"T","instructions":"go","persona_ids":[]}"#).unwrap();
assert_eq!(set.instructions, Some(Some("go".into())));
// A cleared value must serialize as null, not vanish.
let json = serde_json::to_string(&cleared).unwrap();
assert!(json.contains(r#""instructions":null"#), "{json}");
}
/// Absent membership means "unknown, preserve local"; an empty array means
/// "deliberately emptied". These must not serialize identically — the
/// absent shape is the Sietch Tabr wipe event.
#[test]
fn absent_and_empty_persona_ids_are_distinct() {
let absent: TeamEventContent = serde_json::from_str(r#"{"name":"T"}"#).unwrap();
assert_eq!(absent.persona_ids, None);
assert!(!serde_json::to_string(&absent)
.unwrap()
.contains("persona_ids"));
let emptied = TeamEventContent {
persona_ids: Some(Vec::new()),
..absent
};
assert!(serde_json::to_string(&emptied)
.unwrap()
.contains(r#""persona_ids":[]"#));
}
#[test]
fn d_tag_normalization_follows_the_relay_grammar() {
assert_eq!(normalize_d_tag("CodeReviewer"), "codereviewer");
assert_eq!(normalize_d_tag("_ops"), "a_ops", "leading _ is prefixed");
assert_eq!(normalize_d_tag("-x"), "a-x", "leading - is prefixed");
assert_eq!(normalize_d_tag("a b.c"), "a-b-c");
assert_eq!(normalize_d_tag(&"z".repeat(80)).len(), MAX_D_TAG_LEN);
assert_eq!(normalize_d_tag(""), "a", "empty stays grammar-valid");
// Already-valid slugs pass through untouched.
assert_eq!(normalize_d_tag("red-team"), "red-team");
}
#[test]
fn delete_uses_one_coordinate_tag_and_no_e_tag() {
for (builder, kind, d_tag) in [
(
build_persona_delete("herring", OWNER).unwrap(),
KIND_PERSONA,
"herring",
),
(
build_team_delete("red-team", OWNER).unwrap(),
KIND_TEAM,
"red-team",
),
] {
let event = sign(builder);
assert_eq!(event.kind, Kind::Custom(KIND_DELETE));
let a_tags: Vec<&[String]> = event
.tags
.iter()
.map(|t| t.as_slice())
.filter(|v| v.first().map(String::as_str) == Some("a"))
.collect();
assert_eq!(a_tags.len(), 1);
assert_eq!(a_tags[0][1], format!("{kind}:{OWNER}:{d_tag}"));
// An e-tag would route to the event-id deletion path and leave the
// replaceable coordinate live.
assert!(event
.tags
.iter()
.all(|t| t.as_slice().first().map(String::as_str) != Some("e")));
}
}
}
+1
View File
@@ -12,6 +12,7 @@
//! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`.
//! No keys are held here. No network calls are made.
pub mod agent_definitions;
pub mod builders;
pub mod mentions;
pub mod nip_oa;
@@ -5,50 +5,18 @@
use std::collections::BTreeMap;
use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA};
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
use buzz_core_pkg::kind::event_is_shared;
use buzz_sdk_pkg::agent_definitions::normalize_d_tag;
use nostr::EventBuilder;
use super::{AgentDefinition, ManagedAgentRecord};
use crate::app_state::AppState;
/// The JSON body stored in a persona event's content field.
///
/// Field order MUST match the NIP-AP reference vectors (`docs/nips/NIP-AP.md`
/// content body: `display_name, system_prompt, avatar_url, runtime, model,
/// provider, name_pool`). serde emits fields in declaration order, so this
/// order pins the exact content bytes and therefore the NIP-01 event id — a
/// reorder here breaks cross-implementation interop. Guarded by
/// The published persona wire shape, owned by the SDK so the CLI publishes the
/// identical body — including the field order that pins content bytes and
/// therefore `persona_content_hash`. Guarded by
/// `content_matches_nip_ap_vector`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersonaEventContent {
pub display_name: String,
/// Optional since the unified agent model (NIP-AP revision): a definition
/// can be pure configuration. Writers emit `Some` whenever the record has
/// a prompt (including the empty string) so pre-revision content bytes —
/// and therefore `persona_content_hash` — are unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub name_pool: Vec<String>,
/// Definition-level defaults copied onto instances at creation
/// (NIP-AP behavioral fields). Absent = defer to client defaults;
/// `skip_serializing_if` keeps pre-revision hashes stable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub respond_to_allowlist: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallelism: Option<u32>,
}
pub use buzz_sdk_pkg::agent_definitions::PersonaEventContent;
/// Derive the d-tag (persona slug) from a `AgentDefinition`.
///
@@ -70,45 +38,6 @@ pub fn persona_d_tag(record: &AgentDefinition) -> String {
normalize_d_tag(raw)
}
/// Normalize a raw slug to the NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.
///
/// - ASCII-lowercase every char (pack slugs are `[a-zA-Z0-9_-]+`, so this is
/// the only transform uppercase slugs need).
/// - Map any char outside `[a-z0-9_-]` to `-` (defensive; pack slugs never
/// contain such chars, but `id` fallbacks and future inputs might).
/// - If the first char is not `[a-z0-9]` (i.e. a leading `_`/`-`), prepend `a`
/// rather than trimming — trimming `_ops`→`ops` would collide with a real
/// `ops` pack, whereas the prefix keeps distinct inputs distinct.
/// - Truncate to 64 bytes (the grammar's max).
///
/// The transform is deterministic. It is NOT globally injective (`A-b` and
/// `a_b` both contain only safe chars and stay distinct, but two slugs
/// differing only in case — e.g. `Ops` and `ops` — collapse to the same
/// d-tag). That case-fold collision is inherent to the lowercase relay grammar
/// and is the correct NIP-33 behavior: same logical persona, one coordinate.
fn normalize_d_tag(raw: &str) -> String {
let mut out: String = raw
.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect();
if !out
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
out.insert(0, 'a');
}
out.truncate(64);
out
}
/// Compute the NIP-AP monotonic `created_at` for a write (`docs/nips/NIP-AP.md:117`
/// step 3): `max(now, T + 1)` where `T` is the retained head's `created_at`
/// (or 0 when no head exists).
@@ -121,9 +50,12 @@ fn normalize_d_tag(raw: &str) -> String {
/// Bumping past the head guarantees a fresh write always supersedes regardless
/// of clock skew.
pub fn monotonic_created_at(prior_head_created_at: Option<i64>) -> nostr::Timestamp {
let now = nostr::Timestamp::now().as_secs() as i64;
let floor = prior_head_created_at.map_or(0, |t| t + 1);
nostr::Timestamp::from(now.max(floor) as u64)
// Arithmetic lives in buzz-core (pure, `now` injected, unit-tested there);
// this wrapper only supplies the clock.
nostr::Timestamp::from(buzz_core_pkg::engram::monotonic_created_at(
nostr::Timestamp::now().as_secs(),
prior_head_created_at.map(|t| t.max(0) as u64),
))
}
/// Build a kind:30175 event from a `AgentDefinition`.
@@ -132,19 +64,12 @@ pub fn monotonic_created_at(prior_head_created_at: Option<i64>) -> nostr::Timest
pub fn build_persona_event(record: &AgentDefinition) -> Result<EventBuilder, String> {
// Single projection point — persona_event_content owns the field mapping
// (and the hash-stability rules that come with it).
let content = persona_event_content(record);
let content_json = serde_json::to_string(&content)
.map_err(|e| format!("failed to serialize persona content: {e}"))?;
let d_tag = persona_d_tag(record);
let mut tags =
vec![Tag::parse(["d", d_tag.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?];
if record.shared {
tags.push(Tag::parse(["shared", "true"]).map_err(|e| format!("invalid shared tag: {e}"))?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), content_json).tags(tags))
buzz_sdk_pkg::agent_definitions::build_persona_event(
&persona_d_tag(record),
&persona_event_content(record),
record.shared,
)
.map_err(|e| e.to_string())
}
/// Build a NIP-09 deletion (kind:5) targeting a persona's kind:30175 event.
@@ -154,30 +79,20 @@ pub fn build_persona_event(record: &AgentDefinition) -> Result<EventBuilder, Str
/// which leaves the parameterized-replaceable coordinate live. The coordinate
/// delete removes the persona for every client and across reboots.
pub fn build_persona_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result<EventBuilder, String> {
let coord = format!("{KIND_PERSONA}:{owner_pubkey_hex}:{d_tag}");
let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?;
Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag]))
buzz_sdk_pkg::agent_definitions::build_persona_delete(d_tag, owner_pubkey_hex)
.map_err(|e| e.to_string())
}
/// Parse a kind:30175 event back into a `AgentDefinition`.
///
/// The event's d-tag becomes the persona ID and slug.
pub fn persona_from_event(event: &nostr::Event) -> Result<AgentDefinition, String> {
let d_tag = event
.tags
.iter()
.find_map(|tag| {
let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if values.first() == Some(&"d") {
values.get(1).map(|s| s.to_string())
} else {
None
}
})
.ok_or("persona event missing d-tag")?;
let d_tag = buzz_sdk_pkg::agent_definitions::event_d_tag(event)
.ok_or("persona event missing d-tag")?
.to_string();
let content: PersonaEventContent = serde_json::from_str(event.content.as_ref())
.map_err(|e| format!("failed to parse persona event content: {e}"))?;
let content = buzz_sdk_pkg::agent_definitions::persona_content_from_event(event)
.map_err(|e| e.to_string())?;
let created_at = event.created_at.to_human_datetime();
@@ -1,5 +1,7 @@
use super::*;
use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo};
use buzz_core_pkg::kind::KIND_PERSONA;
use nostr::{Kind, Tag};
/// A linked instance record with no persona-derived fields set yet — the
/// state right after creation, before any snapshot apply.
@@ -6,46 +6,14 @@
//! flush loop publish them — this module only owns the kind-specific
//! projection, build, and tombstone.
use buzz_core_pkg::kind::KIND_TEAM;
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
use nostr::EventBuilder;
use super::TeamRecord;
/// The JSON body stored in a team event's content field.
///
/// Explicit opt-IN projection of the public team fields. A team carries no
/// secrets, but the projection is still explicit so a future `TeamRecord`
/// field is published only when deliberately added here. Local-only fields
/// (`source_dir`, `is_symlink`, `is_builtin`, timestamps) are intentionally
/// omitted — they describe this client's install, not the shared team.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamEventContent {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Runtime-layered instructions. `Option` is PERMANENT wire semantics
/// (not a transitional shim): absent = publisher predates always-publish
/// (true value unknown — reconcile must preserve local), `null` =
/// explicitly cleared, a string = set. New clients always publish this
/// field (outer always `Some`), using `null` for "no instructions" so
/// that state round-trips instead of being read back as "unknown".
#[serde(
default,
deserialize_with = "crate::util::double_option",
skip_serializing_if = "Option::is_none"
)]
pub instructions: Option<Option<String>>,
/// Pack persona members. `Option` is PERMANENT wire semantics, not a
/// transitional shim: `None` = publisher predates always-publish (its
/// true membership is unknown — reconcile must preserve local), while
/// `Some(vec![])` = explicitly emptied. New clients always publish
/// `Some(...)`, even when empty. "Cleaning up" the Option later
/// reintroduces the bug where an old client's event silently wipes team
/// membership (see the Sietch Tabr incident).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona_ids: Option<Vec<String>>,
}
/// The published team wire shape, owned by the SDK so the CLI publishes the
/// identical body — including the `Option` nesting that separates "publisher
/// predates always-publish, preserve local" from "explicitly emptied".
pub use buzz_sdk_pkg::agent_definitions::TeamEventContent;
/// Project a `TeamRecord` onto the content fields published in team events.
/// Centralizes the field mapping so a new published field is added in exactly
@@ -66,11 +34,8 @@ pub fn team_event_content(record: &TeamRecord) -> TeamEventContent {
///
/// Returns an unsigned `EventBuilder` — the caller signs and submits.
pub fn build_team_event(record: &TeamRecord) -> Result<EventBuilder, String> {
let content = serde_json::to_string(&team_event_content(record))
.map_err(|e| format!("failed to serialize team content: {e}"))?;
let tags =
vec![Tag::parse(["d", record.id.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?];
Ok(EventBuilder::new(Kind::Custom(KIND_TEAM as u16), content).tags(tags))
buzz_sdk_pkg::agent_definitions::build_team_event(&record.id, &team_event_content(record))
.map_err(|e| e.to_string())
}
/// Parse a kind:30176 event's content into the projection — the inbound
@@ -83,8 +48,7 @@ pub fn build_team_event(record: &TeamRecord) -> Result<EventBuilder, String> {
/// patches them onto the local record (see `apply_inbound_team`), matching on
/// the d-tag (the team's id).
pub fn team_content_from_event(event: &nostr::Event) -> Result<TeamEventContent, String> {
serde_json::from_str(event.content.as_ref())
.map_err(|e| format!("failed to parse team event content: {e}"))
buzz_sdk_pkg::agent_definitions::team_content_from_event(event).map_err(|e| e.to_string())
}
/// Build a NIP-09 deletion (kind:5) targeting a team's kind:30176 event.
@@ -94,14 +58,15 @@ pub fn team_content_from_event(event: &nostr::Event) -> Result<TeamEventContent,
/// leaving the parameterized-replaceable coordinate live. The coordinate
/// delete removes the team for every client and across reboots.
pub fn build_team_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result<EventBuilder, String> {
let coord = format!("{KIND_TEAM}:{owner_pubkey_hex}:{d_tag}");
let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?;
Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag]))
buzz_sdk_pkg::agent_definitions::build_team_delete(d_tag, owner_pubkey_hex)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core_pkg::kind::KIND_TEAM;
use nostr::Kind;
use std::path::PathBuf;
fn sample_team() -> TeamRecord {
+4 -7
View File
@@ -11,13 +11,10 @@ pub fn now_iso() -> String {
/// "clear this field" indistinguishable from "leave it unchanged". Paired with
/// `#[serde(default)]`, this yields the tri-state needed for nullable patches:
/// absent → `None`, `null` → `Some(None)`, value → `Some(Some(value))`.
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
///
/// Re-exported from the SDK: the persona/team wire shapes live there and depend
/// on this exact tri-state, so both sides must share one implementation.
pub use buzz_sdk_pkg::agent_definitions::double_option;
/// Turn a human-readable name into a filesystem-safe slug.
///