broker: add trusted-operation wire protocol to buzz-sdk

Adds the request/result envelope for the Buzz trusted-operation broker,
with agent CRUD as its first capability. This is protocol and validation
only -- no host, no dispatch, no execution.

The envelope is deliberately minimal:

  { type, protocolVersion, requestId, capabilityVersion, capability, args }

It carries no owner, requester, or relay identity. Those are derived on
the host from the verified frame, because a request body that could name
its own owner would let any signer act on another owner's agents. There
is no `context` and no `authorization` field yet: a field that looks
security-bearing while enforcing nothing is worse than its absence. One
gets added, as a discriminated object, when a real grant format and
verifier exist.

There is no client-computed digest. Idempotency is decided host-side, so
only the host needs the hash -- shipping a second canonicalizer in a
second language would freeze two implementations that must agree
byte-for-byte forever. Retrying means resending identical bytes.

Results are a three-variant discriminated union so invalid combinations
are unrepresentable rather than merely discouraged:

  Succeeded { outcome } | Failed { error } | Indeterminate { error }

Indeterminate is distinct from Failed on purpose. Failed promises no side
effects took hold; Indeterminate promises nothing and demands
reconciliation.

Capabilities stay three separate names (agents.create/update/delete)
rather than one agents.manage action union, so a host can permit one
without permitting the others. Only business operations are addressable:
signing, publishing, credential access, and tool execution are not
capabilities and cannot be named.

channelId appears only on create, where attachment genuinely needs it.
Update and delete identify their target by the agent itself.

Every args type is deny_unknown_fields, so a smuggled api key or nsec
fails to deserialize instead of reaching a mutation, and outcome types
can structurally hold only public identifiers.

Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
ss-dev-00
2026-08-21 16:18:24 -07:00
parent 074561233e
commit b677972826
4 changed files with 1190 additions and 0 deletions

View File

@@ -0,0 +1,406 @@
//! Broker capabilities — the named business operations a host will perform.
//!
//! A capability is the unit of policy: `agents.create`, `agents.update`, and
//! `agents.delete` are separate names so a host can permit one without
//! permitting the others. There is deliberately no `agents.manage` action
//! union, which would collapse three policy decisions into one.
//!
//! Every args type is `deny_unknown_fields`, so an api key, env-var map, or
//! nsec smuggled into a payload fails to deserialize instead of reaching a
//! mutation. Every outcome type can structurally hold only public identifiers —
//! it cannot carry the owner nsec or a freshly minted agent secret.
use serde::{Deserialize, Serialize};
use crate::SdkError;
/// Maximum characters in a display name or agent name.
pub const MAX_NAME_CHARS: usize = 120;
/// Maximum characters in a system prompt.
pub const MAX_PROMPT_CHARS: usize = 20_000;
/// Maximum characters in a short scalar field (runtime, provider, model).
pub const MAX_SCALAR_CHARS: usize = 300;
/// Inbound author gate modes a requester may ask for.
///
/// `allowlist` is deliberately absent: it needs a pubkey list this narrow
/// request shape does not carry, and a mode without its list would mint an
/// agent nobody can talk to.
pub const RESPOND_TO_MODES: [&str; 2] = ["owner-only", "anyone"];
/// A capability name the broker can dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Capability {
/// Mint a managed agent and attach it to a channel.
AgentsCreate,
/// Patch one managed agent belonging to the owner.
AgentsUpdate,
/// Remove one managed agent belonging to the owner.
AgentsDelete,
}
impl Capability {
/// Stable wire name.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::AgentsCreate => "agents.create",
Self::AgentsUpdate => "agents.update",
Self::AgentsDelete => "agents.delete",
}
}
/// The capability contract version this build implements.
#[must_use]
pub fn current_version(self) -> u16 {
match self {
Self::AgentsCreate | Self::AgentsUpdate | Self::AgentsDelete => 1,
}
}
/// Resolve a wire name.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for an unknown capability name.
pub fn parse(name: &str) -> Result<Self, SdkError> {
match name {
"agents.create" => Ok(Self::AgentsCreate),
"agents.update" => Ok(Self::AgentsUpdate),
"agents.delete" => Ok(Self::AgentsDelete),
other => Err(SdkError::InvalidInput(format!(
"unknown broker capability \"{other}\""
))),
}
}
}
/// Which agent an update or delete targets.
///
/// Exactly one selector, because a request naming the target twice is ambiguous
/// and the host would have to guess which wins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentTarget {
/// Target by agent pubkey (64 lowercase hex characters).
Pubkey(String),
/// Target by the agent's current name.
Name(String),
}
impl AgentTarget {
/// Validate and normalize the selector.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for an empty value, an over-long
/// name, or a pubkey that is not 64 hex characters.
pub fn validated(&self) -> Result<Self, SdkError> {
match self {
Self::Pubkey(pubkey) => {
let pubkey = required(pubkey, "agent pubkey", 64)?;
if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(
"agent pubkey must be 64 hex characters".into(),
));
}
Ok(Self::Pubkey(pubkey.to_ascii_lowercase()))
}
Self::Name(name) => Ok(Self::Name(required(name, "agent name", MAX_NAME_CHARS)?)),
}
}
}
/// Arguments for `agents.create`.
///
/// `channelId` is present because creation genuinely needs it: the new agent is
/// attached to that channel. Update and delete carry no channel, since the
/// target is identified by the agent itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsCreateArgs {
/// Channel the new agent is attached to.
pub channel_id: String,
/// Name for the new agent.
pub display_name: String,
/// Instructions the new agent runs with.
pub system_prompt: String,
/// Preferred harness id; the host refuses a runtime it cannot resolve.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
/// Inference provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Model identifier, interpreted relative to the runtime.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Inbound author gate mode; absent = the host's owner-only default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
}
impl AgentsCreateArgs {
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, an
/// empty or over-long name or prompt, or an unsupported respond-to mode.
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(Self {
channel_id: channel(&self.channel_id)?,
display_name: required(&self.display_name, "display name", MAX_NAME_CHARS)?,
system_prompt: required(&self.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
runtime: optional(self.runtime.as_ref(), "runtime", MAX_SCALAR_CHARS)?,
provider: optional(self.provider.as_ref(), "provider", MAX_SCALAR_CHARS)?,
model: optional(self.model.as_ref(), "model", MAX_SCALAR_CHARS)?,
respond_to: respond_to(self.respond_to.as_ref())?,
})
}
}
/// Arguments for `agents.update`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsUpdateArgs {
/// Which agent to patch.
pub target: AgentTarget,
/// Rename the agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// Replacement instructions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// Harness id to pin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
/// Inference provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Model identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Inbound author gate mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
}
impl AgentsUpdateArgs {
/// Validate and normalize, requiring at least one field to change.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed target, an over-long
/// field, an unsupported respond-to mode, or a request that changes nothing.
pub fn validated(&self) -> Result<Self, SdkError> {
let normalized = Self {
target: self.target.validated()?,
display_name: optional(self.display_name.as_ref(), "display name", MAX_NAME_CHARS)?,
system_prompt: optional(
self.system_prompt.as_ref(),
"system prompt",
MAX_PROMPT_CHARS,
)?,
runtime: optional(self.runtime.as_ref(), "runtime", MAX_SCALAR_CHARS)?,
provider: optional(self.provider.as_ref(), "provider", MAX_SCALAR_CHARS)?,
model: optional(self.model.as_ref(), "model", MAX_SCALAR_CHARS)?,
respond_to: respond_to(self.respond_to.as_ref())?,
};
let unchanged = normalized.display_name.is_none()
&& normalized.system_prompt.is_none()
&& normalized.runtime.is_none()
&& normalized.provider.is_none()
&& normalized.model.is_none()
&& normalized.respond_to.is_none();
if unchanged {
return Err(SdkError::InvalidInput(
"include at least one field to update".into(),
));
}
Ok(normalized)
}
}
/// Arguments for `agents.delete`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsDeleteArgs {
/// Which agent to remove.
pub target: AgentTarget,
}
impl AgentsDeleteArgs {
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed target selector.
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(Self {
target: self.target.validated()?,
})
}
}
/// A capability name paired with its strictly typed arguments.
///
/// Flattened into [`super::BrokerRequest`], so the wire form is
/// `{ "capability": "agents.create", "args": { … } }` and an args shape can
/// never be paired with the wrong capability name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "capability", content = "args")]
pub enum CapabilityArgs {
/// Mint a managed agent.
#[serde(rename = "agents.create")]
AgentsCreate(AgentsCreateArgs),
/// Patch a managed agent.
#[serde(rename = "agents.update")]
AgentsUpdate(AgentsUpdateArgs),
/// Remove a managed agent.
#[serde(rename = "agents.delete")]
AgentsDelete(AgentsDeleteArgs),
}
impl CapabilityArgs {
/// The capability these args belong to.
#[must_use]
pub fn capability(&self) -> Capability {
match self {
Self::AgentsCreate(_) => Capability::AgentsCreate,
Self::AgentsUpdate(_) => Capability::AgentsUpdate,
Self::AgentsDelete(_) => Capability::AgentsDelete,
}
}
/// Validate the arguments in place.
///
/// # Errors
///
/// Propagates the per-capability validation error.
pub fn validate(&self) -> Result<(), SdkError> {
match self {
Self::AgentsCreate(args) => args.validated().map(|_| ()),
Self::AgentsUpdate(args) => args.validated().map(|_| ()),
Self::AgentsDelete(args) => args.validated().map(|_| ()),
}
}
/// Return a normalized copy with every field validated.
///
/// # Errors
///
/// Propagates the per-capability validation error.
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(match self {
Self::AgentsCreate(args) => Self::AgentsCreate(args.validated()?),
Self::AgentsUpdate(args) => Self::AgentsUpdate(args.validated()?),
Self::AgentsDelete(args) => Self::AgentsDelete(args.validated()?),
})
}
}
/// Outcome of a successful `agents.create`.
///
/// Carries the new agent's **public** key only. There is no field for the
/// minted secret: the nsec stays on the host, and this type could not transport
/// it even if a handler tried.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsCreateOutcome {
/// The new agent's pubkey (hex).
pub agent_pubkey: String,
/// The new agent's name as stored.
pub display_name: String,
/// Channel the agent was attached to.
pub channel_id: String,
}
/// Outcome of a successful `agents.update`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsUpdateOutcome {
/// The patched agent's pubkey (hex).
pub agent_pubkey: String,
/// The agent's name after the update.
pub display_name: String,
/// Names of the fields the host actually changed, sorted.
pub updated_fields: Vec<String>,
}
/// Outcome of a successful `agents.delete`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsDeleteOutcome {
/// The removed agent's pubkey (hex).
pub agent_pubkey: String,
/// The removed agent's name.
pub display_name: String,
}
/// A capability-specific success payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "capability", content = "outcome")]
pub enum CapabilityOutcome {
/// `agents.create` succeeded.
#[serde(rename = "agents.create")]
AgentsCreate(AgentsCreateOutcome),
/// `agents.update` succeeded.
#[serde(rename = "agents.update")]
AgentsUpdate(AgentsUpdateOutcome),
/// `agents.delete` succeeded.
#[serde(rename = "agents.delete")]
AgentsDelete(AgentsDeleteOutcome),
}
impl CapabilityOutcome {
/// The capability that produced this outcome.
#[must_use]
pub fn capability(&self) -> Capability {
match self {
Self::AgentsCreate(_) => Capability::AgentsCreate,
Self::AgentsUpdate(_) => Capability::AgentsUpdate,
Self::AgentsDelete(_) => Capability::AgentsDelete,
}
}
}
// ── Shared validators ───────────────────────────────────────────────────────
fn required(value: &str, label: &str, max: usize) -> Result<String, SdkError> {
let value = value.trim();
if value.is_empty() {
return Err(SdkError::InvalidInput(format!("{label} must not be empty")));
}
if value.chars().count() > max {
return Err(SdkError::InvalidInput(format!(
"{label} is too long (max {max} characters)"
)));
}
Ok(value.to_owned())
}
fn optional(value: Option<&String>, label: &str, max: usize) -> Result<Option<String>, SdkError> {
value.map(|value| required(value, label, max)).transpose()
}
fn channel(value: &str) -> Result<String, SdkError> {
let value = required(value, "channel", 128)?;
uuid::Uuid::parse_str(&value)
.map_err(|_| SdkError::InvalidInput(format!("invalid channel UUID: {value}")))?;
Ok(value)
}
fn respond_to(value: Option<&String>) -> Result<Option<String>, SdkError> {
let value = optional(value, "respond-to", MAX_SCALAR_CHARS)?;
if let Some(mode) = value.as_deref() {
if !RESPOND_TO_MODES.contains(&mode) {
return Err(SdkError::InvalidInput(format!(
"respond-to must be one of {}",
RESPOND_TO_MODES.join(", ")
)));
}
}
Ok(value)
}

View File

@@ -0,0 +1,401 @@
//! Buzz trusted-operation broker — wire protocol.
//!
//! The broker lets a requester ask an owner's host to perform a small set of
//! named business operations ("capabilities") that require the owner's
//! credentials. It exposes business capabilities only: never signing,
//! publishing, credential access, or arbitrary tool execution.
//!
//! # Mental model
//!
//! ```text
//! requester → BrokerRequest → (signed, encrypted frame) → host broker
//! host: verify → authorize → validate → claim → dispatch → BrokerResult
//! ```
//!
//! # What is *not* in this envelope
//!
//! Owner, requester, and relay scope are **transport-derived** on the host from
//! the verified frame. They are deliberately absent here: a request body that
//! could name its own owner would let any signer act on another owner's agents.
//!
//! There is no `authorization` field yet. One gets added, as a discriminated
//! object, when a real grant format and verifier exist — not before, so that no
//! field looks security-bearing while enforcing nothing.
//!
//! There is no client-computed digest. Idempotency is decided host-side; see
//! [`BrokerRequest`] for the retry contract.
use serde::{Deserialize, Serialize};
use crate::SdkError;
pub mod capabilities;
pub use capabilities::{
AgentTarget, AgentsCreateArgs, AgentsCreateOutcome, AgentsDeleteArgs, AgentsDeleteOutcome,
AgentsUpdateArgs, AgentsUpdateOutcome, Capability, CapabilityArgs, CapabilityOutcome,
};
/// Wire `type` discriminator for a broker request payload.
pub const BROKER_REQUEST_TYPE: &str = "broker_request";
/// Wire `type` discriminator for a broker result payload.
pub const BROKER_RESULT_TYPE: &str = "broker_result";
/// Current broker protocol version.
///
/// There is no "absent means 1" compatibility rule: the protocol is unshipped,
/// so `protocolVersion` is required and an unknown value is rejected outright.
pub const BROKER_PROTOCOL_VERSION: u16 = 1;
/// Maximum accepted length of a `requestId`, in bytes.
pub const MAX_REQUEST_ID_LEN: usize = 128;
/// A request to execute one broker capability.
///
/// # Retry contract
///
/// Retrying an operation means **resending the identical serialized request**
/// with the same `requestId`. The host hashes what it receives and compares
/// that digest against the digest recorded under the same idempotency key:
///
/// - same key, same digest → the recorded outcome is replayed, nothing re-runs
/// - same key, different digest → rejected as a request-ID conflict
///
/// Callers therefore must not regenerate, re-order, or re-render the payload
/// between attempts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct BrokerRequest {
/// Payload discriminator — must equal [`BROKER_REQUEST_TYPE`].
pub r#type: String,
/// Protocol version — must equal [`BROKER_PROTOCOL_VERSION`].
pub protocol_version: u16,
/// Caller-chosen idempotency key, unique per logical operation.
pub request_id: String,
/// Capability contract version the caller wrote `args` against.
pub capability_version: u16,
/// The capability to invoke, with its strictly typed arguments.
#[serde(flatten)]
pub capability: CapabilityArgs,
}
impl BrokerRequest {
/// Build a request for `capability` with the current protocol version.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] if `request_id` is empty, longer than
/// [`MAX_REQUEST_ID_LEN`], or contains non-printable-ASCII bytes.
pub fn new(
request_id: impl Into<String>,
capability: CapabilityArgs,
) -> Result<Self, SdkError> {
let request_id = request_id.into();
let request = Self {
r#type: BROKER_REQUEST_TYPE.to_string(),
protocol_version: BROKER_PROTOCOL_VERSION,
request_id,
capability_version: capability.capability().current_version(),
capability,
};
request.validate()?;
Ok(request)
}
/// The capability this request invokes.
#[must_use]
pub fn capability(&self) -> Capability {
self.capability.capability()
}
/// Validate every field the host must agree on before executing anything.
///
/// Callers validate before sealing a frame; the host revalidates after
/// decrypting, because only the host's verdict is authoritative.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a wrong `type`, an unsupported
/// `protocolVersion` or `capabilityVersion`, a malformed `requestId`, or
/// capability arguments that fail their own validation.
pub fn validate(&self) -> Result<(), SdkError> {
if self.r#type != BROKER_REQUEST_TYPE {
return Err(SdkError::InvalidInput(format!(
"broker request type must be \"{BROKER_REQUEST_TYPE}\", got \"{}\"",
self.r#type
)));
}
if self.protocol_version != BROKER_PROTOCOL_VERSION {
return Err(SdkError::InvalidInput(format!(
"unsupported broker protocolVersion {} (expected {BROKER_PROTOCOL_VERSION})",
self.protocol_version
)));
}
validate_request_id(&self.request_id)?;
let capability = self.capability();
if self.capability_version != capability.current_version() {
return Err(SdkError::InvalidInput(format!(
"unsupported capabilityVersion {} for {} (expected {})",
self.capability_version,
capability.as_str(),
capability.current_version()
)));
}
self.capability.validate()
}
}
/// Validate a `requestId`: non-empty, bounded, printable ASCII.
///
/// The bound and character set exist because this value becomes part of a
/// durable primary key and appears in audit records.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] when the id is empty, exceeds
/// [`MAX_REQUEST_ID_LEN`] bytes, or contains a byte outside `0x21..=0x7e`.
pub fn validate_request_id(request_id: &str) -> Result<(), SdkError> {
if request_id.is_empty() {
return Err(SdkError::InvalidInput("requestId must not be empty".into()));
}
if request_id.len() > MAX_REQUEST_ID_LEN {
return Err(SdkError::InvalidInput(format!(
"requestId exceeds {MAX_REQUEST_ID_LEN} bytes (got {})",
request_id.len()
)));
}
if let Some(bad) = request_id
.bytes()
.find(|b| !(0x21..=0x7e).contains(b))
.map(|b| format!("0x{b:02x}"))
{
return Err(SdkError::InvalidInput(format!(
"requestId must be printable ASCII without spaces (found byte {bad})"
)));
}
Ok(())
}
/// Machine-readable broker error code.
///
/// These name failures the *broker* is responsible for. Capability-specific
/// failures arrive as [`BrokerErrorCode::CapabilityFailed`] with detail in the
/// accompanying message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrokerErrorCode {
/// The envelope or capability arguments failed validation.
InvalidRequest,
/// The `protocolVersion` is not supported by this host.
UnsupportedProtocolVersion,
/// The capability name is unknown to this host.
UnknownCapability,
/// The `capabilityVersion` is not supported for this capability.
UnsupportedCapabilityVersion,
/// The requester's signature or frame could not be verified.
Unauthenticated,
/// The requester is authenticated but not permitted this operation.
Unauthorized,
/// Reuse of a `requestId` with different request content.
RequestIdConflict,
/// The capability handler ran and reported a domain failure.
CapabilityFailed,
/// The host could not determine whether side effects occurred.
///
/// Only ever paired with [`BrokerResult::Indeterminate`].
OutcomeUnknown,
/// An unexpected host-side fault.
Internal,
}
impl BrokerErrorCode {
/// Stable wire string for this code.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::InvalidRequest => "invalid_request",
Self::UnsupportedProtocolVersion => "unsupported_protocol_version",
Self::UnknownCapability => "unknown_capability",
Self::UnsupportedCapabilityVersion => "unsupported_capability_version",
Self::Unauthenticated => "unauthenticated",
Self::Unauthorized => "unauthorized",
Self::RequestIdConflict => "request_id_conflict",
Self::CapabilityFailed => "capability_failed",
Self::OutcomeUnknown => "outcome_unknown",
Self::Internal => "internal",
}
}
}
/// A broker error: a machine-readable code plus a human-readable message.
///
/// Messages are for operators and must never carry secrets — no nsec, no
/// credentials, no decrypted payloads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BrokerError {
/// Machine-readable failure code.
pub code: BrokerErrorCode,
/// Operator-facing description. Secret-free.
pub message: String,
}
impl BrokerError {
/// Construct an error from a code and message.
pub fn new(code: BrokerErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// An [`BrokerErrorCode::InvalidRequest`] error.
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::new(BrokerErrorCode::InvalidRequest, message)
}
/// An [`BrokerErrorCode::Unauthorized`] error.
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(BrokerErrorCode::Unauthorized, message)
}
}
/// The terminal disposition of a broker request.
///
/// A discriminated union, so "succeeded with an error" and "failed with an
/// outcome" are unrepresentable rather than merely discouraged.
///
/// [`Self::Indeterminate`] is distinct from [`Self::Failed`] on purpose:
/// `Failed` promises no side effects took hold, while `Indeterminate` promises
/// nothing at all and demands operator or capability-level reconciliation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BrokerResult {
/// The capability completed and produced this outcome.
Succeeded {
/// Capability-specific success payload.
#[serde(flatten)]
outcome: CapabilityOutcome,
},
/// The capability did not complete; no side effects are expected to persist.
Failed {
/// Why it failed.
error: BrokerError,
},
/// Whether side effects occurred could not be determined.
Indeterminate {
/// What is unknown, and why.
error: BrokerError,
},
}
impl BrokerResult {
/// A successful result carrying `outcome`.
#[must_use]
pub fn succeeded(outcome: CapabilityOutcome) -> Self {
Self::Succeeded { outcome }
}
/// A failed result carrying `error`.
#[must_use]
pub fn failed(error: BrokerError) -> Self {
Self::Failed { error }
}
/// An indeterminate result carrying `error`.
#[must_use]
pub fn indeterminate(error: BrokerError) -> Self {
Self::Indeterminate { error }
}
/// Whether this is a terminal success.
#[must_use]
pub fn is_succeeded(&self) -> bool {
matches!(self, Self::Succeeded { .. })
}
/// The error, for the two non-success variants.
#[must_use]
pub fn error(&self) -> Option<&BrokerError> {
match self {
Self::Succeeded { .. } => None,
Self::Failed { error } | Self::Indeterminate { error } => Some(error),
}
}
}
/// A broker result addressed back to the requester.
///
/// `replayed` is **response metadata**: it describes this delivery, not the
/// domain outcome, and is never persisted as part of the stored result. A
/// replayed response is byte-identical in `result` to the original.
///
/// Note: this struct cannot use `deny_unknown_fields`, because serde does not
/// support combining it with `#[serde(flatten)]`. Strictness is enforced where
/// it guards execution — on [`BrokerRequest`] and each capability args type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrokerResponse {
/// Payload discriminator — must equal [`BROKER_RESULT_TYPE`].
pub r#type: String,
/// Protocol version — must equal [`BROKER_PROTOCOL_VERSION`].
pub protocol_version: u16,
/// Correlates with the originating [`BrokerRequest::request_id`].
pub request_id: String,
/// The terminal disposition.
#[serde(flatten)]
pub result: BrokerResult,
/// True when this response replays a previously recorded outcome.
#[serde(default, skip_serializing_if = "is_false")]
pub replayed: bool,
}
fn is_false(value: &bool) -> bool {
!*value
}
impl BrokerResponse {
/// Build a fresh (non-replayed) response for `request_id`.
pub fn new(request_id: impl Into<String>, result: BrokerResult) -> Self {
Self {
r#type: BROKER_RESULT_TYPE.to_string(),
protocol_version: BROKER_PROTOCOL_VERSION,
request_id: request_id.into(),
result,
replayed: false,
}
}
/// Mark this response as replaying a recorded outcome.
#[must_use]
pub fn replayed(mut self) -> Self {
self.replayed = true;
self
}
/// Validate discriminator, version, and request id.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] on a wrong `type`, an unsupported
/// `protocolVersion`, or a malformed `requestId`.
pub fn validate(&self) -> Result<(), SdkError> {
if self.r#type != BROKER_RESULT_TYPE {
return Err(SdkError::InvalidInput(format!(
"broker result type must be \"{BROKER_RESULT_TYPE}\", got \"{}\"",
self.r#type
)));
}
if self.protocol_version != BROKER_PROTOCOL_VERSION {
return Err(SdkError::InvalidInput(format!(
"unsupported broker protocolVersion {} (expected {BROKER_PROTOCOL_VERSION})",
self.protocol_version
)));
}
validate_request_id(&self.request_id)
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,382 @@
//! Wire-contract tests for the broker envelope.
use super::*;
const CHANNEL: &str = "b2c38ca8-9ec3-411e-bab5-f9deab34d52e";
const PUBKEY: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971";
fn create_args() -> CapabilityArgs {
CapabilityArgs::AgentsCreate(AgentsCreateArgs {
channel_id: CHANNEL.into(),
display_name: "Research helper".into(),
system_prompt: "Find sources.".into(),
runtime: None,
provider: None,
model: None,
respond_to: None,
})
}
#[test]
fn request_serializes_capability_beside_args() {
let request = BrokerRequest::new("req-1", create_args()).unwrap();
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["type"], BROKER_REQUEST_TYPE);
assert_eq!(json["protocolVersion"], 1);
assert_eq!(json["requestId"], "req-1");
assert_eq!(json["capability"], "agents.create");
assert_eq!(json["capabilityVersion"], 1);
assert_eq!(json["args"]["displayName"], "Research helper");
// Unset optionals stay off the wire, so the payload cannot imply a
// runtime/provider/model the caller never chose.
assert!(json["args"].get("runtime").is_none());
assert!(json["args"].get("respondTo").is_none());
let parsed: BrokerRequest = serde_json::from_value(json).unwrap();
assert_eq!(parsed, request);
}
/// The envelope must not carry owner/requester/relay identity: those are
/// transport-derived. A body that could name its own owner would let any
/// signer act on another owner's agents.
#[test]
fn request_has_no_caller_supplied_identity_or_context() {
let json = serde_json::to_value(BrokerRequest::new("req-1", create_args()).unwrap()).unwrap();
let object = json.as_object().unwrap();
for forbidden in [
"ownerPubkey",
"owner",
"requesterPubkey",
"requester",
"relayUrl",
"relayScope",
"context",
"authorizer",
"scope",
"expiry",
"authorization",
"requestDigest",
"digest",
] {
assert!(
!object.contains_key(forbidden),
"broker request must not carry \"{forbidden}\""
);
}
let mut keys = object.keys().map(String::as_str).collect::<Vec<_>>();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"args",
"capability",
"capabilityVersion",
"protocolVersion",
"requestId",
"type",
]
);
}
#[test]
fn unknown_request_field_is_rejected() {
let mut json =
serde_json::to_value(BrokerRequest::new("req-1", create_args()).unwrap()).unwrap();
json.as_object_mut()
.unwrap()
.insert("ownerPubkey".into(), serde_json::json!(PUBKEY));
let error = serde_json::from_value::<BrokerRequest>(json).unwrap_err();
assert!(
error.to_string().contains("ownerPubkey"),
"unexpected error: {error}"
);
}
/// A secret smuggled into args must fail to deserialize rather than reach a
/// mutation.
#[test]
fn secret_bearing_args_fail_to_deserialize() {
let json = serde_json::json!({
"type": BROKER_REQUEST_TYPE,
"protocolVersion": 1,
"requestId": "req-1",
"capability": "agents.create",
"capabilityVersion": 1,
"args": {
"channelId": CHANNEL,
"displayName": "Sneaky",
"systemPrompt": "hi",
"envVars": { "ANTHROPIC_API_KEY": "sk-live" },
},
});
assert!(serde_json::from_value::<BrokerRequest>(json).is_err());
}
#[test]
fn wrong_protocol_version_is_refused() {
let mut request = BrokerRequest::new("req-1", create_args()).unwrap();
request.protocol_version = 2;
let error = request.validate().unwrap_err().to_string();
assert!(error.contains("protocolVersion"), "unexpected: {error}");
}
#[test]
fn wrong_capability_version_is_refused() {
let mut request = BrokerRequest::new("req-1", create_args()).unwrap();
request.capability_version = 7;
let error = request.validate().unwrap_err().to_string();
assert!(error.contains("capabilityVersion"), "unexpected: {error}");
}
#[test]
fn unknown_capability_name_is_refused() {
let json = serde_json::json!({
"type": BROKER_REQUEST_TYPE,
"protocolVersion": 1,
"requestId": "req-1",
"capability": "agents.exfiltrate",
"capabilityVersion": 1,
"args": {},
});
assert!(serde_json::from_value::<BrokerRequest>(json).is_err());
assert!(Capability::parse("agents.exfiltrate").is_err());
}
/// Signing, publishing, and credential access are not capabilities. Only
/// business operations are addressable.
#[test]
fn only_business_capabilities_are_addressable() {
for forbidden in [
"sign",
"publish",
"keys.export",
"identity.nsec",
"tool.exec",
"agents.manage",
"agents.start",
] {
assert!(
Capability::parse(forbidden).is_err(),
"\"{forbidden}\" must not be a capability"
);
}
for allowed in ["agents.create", "agents.update", "agents.delete"] {
assert_eq!(Capability::parse(allowed).unwrap().as_str(), allowed);
}
}
#[test]
fn request_id_must_be_present_bounded_and_printable() {
assert!(BrokerRequest::new("", create_args()).is_err());
assert!(BrokerRequest::new("a".repeat(MAX_REQUEST_ID_LEN), create_args()).is_ok());
assert!(BrokerRequest::new("a".repeat(MAX_REQUEST_ID_LEN + 1), create_args()).is_err());
assert!(BrokerRequest::new("has space", create_args()).is_err());
assert!(BrokerRequest::new("has\nnewline", create_args()).is_err());
}
#[test]
fn update_requires_at_least_one_change() {
let empty = AgentsUpdateArgs {
target: AgentTarget::Pubkey(PUBKEY.into()),
display_name: None,
system_prompt: None,
runtime: None,
provider: None,
model: None,
respond_to: None,
};
let error = empty.validated().unwrap_err().to_string();
assert!(error.contains("at least one field"), "unexpected: {error}");
}
#[test]
fn target_pubkey_must_be_hex_and_normalizes_case() {
assert!(AgentTarget::Pubkey("nothex".into()).validated().is_err());
assert!(AgentTarget::Pubkey(PUBKEY[..40].into())
.validated()
.is_err());
let upper = AgentTarget::Pubkey(PUBKEY.to_ascii_uppercase());
assert_eq!(
upper.validated().unwrap(),
AgentTarget::Pubkey(PUBKEY.into())
);
}
#[test]
fn create_rejects_a_malformed_channel_uuid() {
let args = AgentsCreateArgs {
channel_id: "not-a-uuid".into(),
display_name: "A".into(),
system_prompt: "B".into(),
runtime: None,
provider: None,
model: None,
respond_to: None,
};
let error = args.validated().unwrap_err().to_string();
assert!(error.contains("channel"), "unexpected: {error}");
}
#[test]
fn create_rejects_an_unsupported_respond_to_mode() {
let args = AgentsCreateArgs {
channel_id: CHANNEL.into(),
display_name: "A".into(),
system_prompt: "B".into(),
runtime: None,
provider: None,
model: None,
respond_to: Some("allowlist".into()),
};
assert!(args.validated().is_err());
}
/// Update and delete carry no channel: per the approved design, channel
/// appears only where the operation needs it (create attachment).
#[test]
fn update_and_delete_carry_no_channel() {
let update = serde_json::to_value(AgentsUpdateArgs {
target: AgentTarget::Pubkey(PUBKEY.into()),
display_name: Some("New".into()),
system_prompt: None,
runtime: None,
provider: None,
model: None,
respond_to: None,
})
.unwrap();
assert!(update.get("channelId").is_none());
let delete = serde_json::to_value(AgentsDeleteArgs {
target: AgentTarget::Pubkey(PUBKEY.into()),
})
.unwrap();
assert!(delete.get("channelId").is_none());
}
// ── Results ─────────────────────────────────────────────────────────────────
#[test]
fn succeeded_result_carries_outcome_and_no_error() {
let response = BrokerResponse::new(
"req-1",
BrokerResult::succeeded(CapabilityOutcome::AgentsCreate(AgentsCreateOutcome {
agent_pubkey: PUBKEY.into(),
display_name: "Research helper".into(),
channel_id: CHANNEL.into(),
})),
);
response.validate().unwrap();
let json = serde_json::to_value(&response).unwrap();
assert_eq!(json["type"], BROKER_RESULT_TYPE);
assert_eq!(json["status"], "succeeded");
assert_eq!(json["capability"], "agents.create");
assert_eq!(json["outcome"]["agentPubkey"], PUBKEY);
assert!(json.get("error").is_none());
// `replayed` is response metadata and stays off the wire when false.
assert!(json.get("replayed").is_none());
let parsed: BrokerResponse = serde_json::from_value(json).unwrap();
assert_eq!(parsed, response);
assert!(parsed.result.is_succeeded());
assert!(parsed.result.error().is_none());
}
#[test]
fn failed_and_indeterminate_are_distinct_and_carry_no_outcome() {
let failed = BrokerResult::failed(BrokerError::new(
BrokerErrorCode::CapabilityFailed,
"runtime not installed",
));
let failed_json = serde_json::to_value(BrokerResponse::new("r", failed.clone())).unwrap();
assert_eq!(failed_json["status"], "failed");
assert_eq!(failed_json["error"]["code"], "capability_failed");
assert!(failed_json.get("outcome").is_none());
let indeterminate = BrokerResult::indeterminate(BrokerError::new(
BrokerErrorCode::OutcomeUnknown,
"host restarted mid-execution",
));
let json = serde_json::to_value(BrokerResponse::new("r", indeterminate.clone())).unwrap();
assert_eq!(json["status"], "indeterminate");
assert_eq!(json["error"]["code"], "outcome_unknown");
assert!(json.get("outcome").is_none());
assert_ne!(failed, indeterminate);
assert!(!indeterminate.is_succeeded());
}
#[test]
fn replay_metadata_rides_the_response_not_the_result() {
let result = BrokerResult::succeeded(CapabilityOutcome::AgentsDelete(AgentsDeleteOutcome {
agent_pubkey: PUBKEY.into(),
display_name: "Gone".into(),
}));
let fresh = BrokerResponse::new("req-9", result.clone());
let replayed = BrokerResponse::new("req-9", result.clone()).replayed();
// The domain outcome is identical; only the delivery metadata differs.
assert_eq!(fresh.result, replayed.result);
assert!(!fresh.replayed);
assert!(replayed.replayed);
assert_eq!(
serde_json::to_value(&replayed).unwrap()["replayed"],
serde_json::json!(true)
);
// `replayed` is not part of the stored result encoding.
let stored = serde_json::to_value(&result).unwrap();
assert!(stored.get("replayed").is_none());
}
#[test]
fn every_error_code_has_a_stable_wire_string() {
for (code, expected) in [
(BrokerErrorCode::InvalidRequest, "invalid_request"),
(
BrokerErrorCode::UnsupportedProtocolVersion,
"unsupported_protocol_version",
),
(BrokerErrorCode::UnknownCapability, "unknown_capability"),
(
BrokerErrorCode::UnsupportedCapabilityVersion,
"unsupported_capability_version",
),
(BrokerErrorCode::Unauthenticated, "unauthenticated"),
(BrokerErrorCode::Unauthorized, "unauthorized"),
(BrokerErrorCode::RequestIdConflict, "request_id_conflict"),
(BrokerErrorCode::CapabilityFailed, "capability_failed"),
(BrokerErrorCode::OutcomeUnknown, "outcome_unknown"),
(BrokerErrorCode::Internal, "internal"),
] {
assert_eq!(code.as_str(), expected);
assert_eq!(
serde_json::to_value(code).unwrap(),
serde_json::json!(expected)
);
}
}
/// An outcome type must be structurally unable to carry secret material.
#[test]
fn outcomes_cannot_carry_secrets() {
let json = serde_json::json!({
"capability": "agents.create",
"outcome": {
"agentPubkey": PUBKEY,
"displayName": "A",
"channelId": CHANNEL,
"privateKeyNsec": "nsec1deadbeef",
},
});
assert!(
serde_json::from_value::<CapabilityOutcome>(json).is_err(),
"an outcome carrying a secret must not deserialize"
);
}

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 broker;
pub mod builders;
pub mod mentions;
pub mod nip_oa;