Files
buzz/crates/buzz-sdk/src/lib.rs
Bradley Axen b622003f74 broker: define the agent-to-broker action contract (#6742)
Addresses the interface half of #6467.

This is the contract for how a keyless agent asks a host to act on its
behalf — and only that. No host, no transport, no signing, no Desktop
code; the only implementation is a test double. An agent that holds no
secret key still has to read its channels, answer a mention, react, keep
its profile, address its memory, and create the agents it needs. This PR
defines the vocabulary for those requests and the rules both sides must
follow.

**#6467's open question, answered: per-operation, not "sign this".** A
raw signing primitive tells the host *who* is asking but not *what for*,
so the only policies it can express are all-or-nothing. A closed set of
named operations lets a host serve channel reads while refusing agent
creation, and gives any later policy layer something to attach to. The
cost is that adding an operation means changing the contract — which is
the point. Signing, publishing, and the auth/encryption NIPs are
deliberately *not* operations; they are mechanisms inside whoever holds
the key, and a test pins that none of those names resolve.

**Nine operations in v1:** read a channel (whole channel, one thread, or
mentions-only — these differ by filter, not permission, so they share
one operation), post, reply, react, set profile, derive a storage
address, and create / update / delete agents. Presence and typing are
deferred as housekeeping a host may decline regardless; streaming is
deferred, so waking on a mention is a polled mentions-only read. Each
operation has typed, validated arguments and a typed outcome.

**What the contract enforces structurally rather than by instruction:**

- *Retry means identical bytes.* A request is validated and serialized
exactly once and the transport only ever sees that frozen form. There is
no way to re-render it, so a retry can't drift and be mistaken for a
conflicting request.
- *Read cursors are opaque.* Host-issued strings, validated for shape
only, never parsed or compared. The host owns ordering and paging
stability instead of being locked into timestamps.
- *Reads return signed Nostr events.* A keyless agent verifies
authorship and content locally and trusts the host only for completeness
and authorization. Reply ancestry and mentions are derived from the
signed tags, so nothing can disagree with the signature.
- *Validation is the only door to a response.* The transport primitive
hands back an unjudged envelope; the only response a caller can actually
obtain is one already checked against the request it answers — request
id correlates, the outcome's operation matches, identifiers and cursors
are well-formed. A mismatch counts as "no answer", not a failure
verdict. The raw primitive can't be invoked from outside the crate
(pinned by compile-failure checks), so skipping that step isn't an
option.
- *Authority is never asserted by a body.* The envelope has no
requester, owner, scope, or credential field; all of that comes from the
session the host authenticated. Ownership of a created agent is
implicitly the requester, never a field — which is what lets agents own
agents while the chain still ends at a human.
- *No secret crosses the wire.* Agent creation returns a pubkey and
handle, never the minted key. Every payload type rejects unknown fields,
and a test pins the exact JSON key set of all eighteen argument and
outcome types, so no field can be added without a reviewer touching that
table. Two honest limits: a string can physically hold secret text, so
keeping secrets out of message content is host policy; and nothing stops
a host from *holding* keys — it just may not hand them over.
- *Null is never legal; omission is the only spelling of absence.* Every
optional member rejects an explicit null. Otherwise null and omitted
would be indistinguishable, and a contradiction check on the response
envelope could be skipped by sending one (a real hole in an earlier
revision). Coverage is derived by walking every path in the real
fixtures, so new optional members are covered automatically. Serializers
that emit null by default (Go without `omitempty`, Python's default
dump) need configuring — called out in the module docs.

**Wire mapping.** One POST endpoint, JSON body is the frozen request,
opaque bearer credential in the auth header. Every verdict the host
reached — including failure and including a rejected credential — comes
back as a well-formed envelope with 200, because a second copy of the
verdict in the status line could only disagree with it. Clients still
try to parse an envelope regardless of status (intermediaries may remap)
and fall back to the status only when none is present. A rejected
credential is a host verdict carrying the promise "the action did not
run"; transport errors are reserved for "nothing can be concluded about
side effects." Binding the credential to a specific agent and
conversation is the host's job and deliberately unenforceable here.

**Relationship to #6543.** Envelope, request-id rules, protocol version,
the three-way succeeded / failed / indeterminate result, error codes,
and the agent-management argument shapes carry over. Changed
deliberately: pubkeys are a validated type rather than bare strings,
"capability" became "action" throughout, and a new "unsupported" error
lets a host decline a best-effort operation without it reading as a
fault. Both PRs land in the same module path — whichever merges second
needs a hand reconcile, which I own as the author of #6543.

**Validation at `7c853e0fb`.** 299 tests in the sdk crate (full package
run), workspace tests green apart from the pre-existing relay timeout
flake, which I reproduced on unmodified `main`. Clippy with warnings
denied, fmt, warning-free docs, and the pre-push gate all pass. Review
findings were closed with mutation tests — each fix verified by
reverting it and watching a named test fail. One of those exposed a gap
in my own coverage (response-side fields weren't pinned); that's fixed
and floored separately from the request side so neither can mask the
other.

**Size.** About 5,400 lines across 8 files, roughly half tests. Every
source file is under the repo's 1,000-line cap except the test file;
#6759 splits it mechanically, is stacked on this branch, retargets to
`main` after merge, and lands before PR 2 of the stack.

---------

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: ss-core-02 <cca2c695c61d409321106cc399009c2cc674d72cc8900b2999a68fe54577a411@buzz.block.builderlab.xyz>
Co-authored-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: ss-core-02 <cca2c695c61d409321106cc399009c2cc674d72cc8900b2999a68fe54577a411@buzz.block.builderlab.xyz>
2026-08-26 19:00:45 -07:00

114 lines
3.7 KiB
Rust

#![deny(unsafe_code)]
#![warn(missing_docs)]
//! `buzz-sdk` — typed Nostr event builders for Buzz operations.
//!
//! # Mental Model
//!
//! ```text
//! caller params → builder fn → validates → EventBuilder → caller signs → Event
//! ```
//!
//! Each builder function validates its inputs and returns an [`nostr::EventBuilder`].
//! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`.
//! No keys are held here. No network calls are made.
pub mod broker;
pub mod builders;
pub mod mentions;
pub mod nip_oa;
pub use builders::*;
/// Re-export kind constants so consumers don't need buzz-core directly.
pub use buzz_core::kind;
/// Thread reference for reply builders (NIP-10 markers).
///
/// - Direct reply (root == parent): emits `["e", root, "", "reply"]`
/// - Nested reply (root ≠ parent): emits `["e", root, "", "root"]` + `["e", parent, "", "reply"]`
pub struct ThreadRef {
/// The root event of the thread.
pub root_event_id: nostr::EventId,
/// The immediate parent being replied to.
pub parent_event_id: nostr::EventId,
}
/// Metadata for diff/patch messages (kind 40008).
pub struct DiffMeta {
/// Repository URL — required, must start with `http://` or `https://`.
pub repo_url: String,
/// Commit SHA — required, minimum 7 hex characters.
pub commit_sha: String,
/// Optional file path within the repository.
pub file_path: Option<String>,
/// Optional parent commit SHA — minimum 7 hex chars if present.
pub parent_commit: Option<String>,
/// Optional branch pair `(source, target)` — both or neither.
pub branch: Option<(String, String)>,
/// Optional pull request number — must be positive.
pub pr_number: Option<u32>,
/// Optional programming language identifier.
pub language: Option<String>,
/// Optional human-readable description.
pub description: Option<String>,
/// Whether the diff was truncated due to size.
pub truncated: bool,
/// Optional alt text for accessibility.
pub alt_text: Option<String>,
}
/// Vote direction for `build_vote`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoteDirection {
/// Upvote — content `"+"`.
Up,
/// Downvote — content `"-"`.
Down,
}
/// A NIP-30 custom emoji tag payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomEmoji {
/// The shortcode without surrounding colons.
pub shortcode: String,
/// Image URL for this custom emoji.
pub url: String,
}
/// Return a channel name without client-rendered leading hash prefixes.
pub use buzz_core::channel::canonical_channel_name;
/// Channel type.
pub use buzz_core::channel::ChannelType as ChannelKind;
/// Channel visibility.
pub use buzz_core::channel::ChannelVisibility as Visibility;
/// Member role.
pub use buzz_core::channel::MemberRole;
/// Errors returned by SDK builder functions.
#[derive(Debug, thiserror::Error)]
pub enum SdkError {
/// Content exceeds the maximum allowed size.
#[error("content exceeds maximum size of {max} bytes (got {got})")]
ContentTooLarge {
/// Maximum allowed bytes.
max: usize,
/// Actual byte count.
got: usize,
},
/// A tag could not be constructed.
#[error("invalid tag: {0}")]
InvalidTag(String),
/// Emoji string exceeds 64 characters.
#[error("emoji exceeds maximum length of 64 characters")]
EmojiTooLong,
/// More than 50 mentions were supplied.
#[error("too many mentions (max 50)")]
TooManyMentions,
/// Diff metadata failed validation.
#[error("invalid diff metadata: {0}")]
InvalidDiffMeta(String),
/// Input failed validation (e.g. malformed pubkey).
#[error("invalid input: {0}")]
InvalidInput(String),
}