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>
This commit is contained in:
Bradley Axen
2026-08-26 19:00:45 -07:00
committed by GitHub
parent cada3028ce
commit b622003f74
10 changed files with 5264 additions and 1 deletions

View File

@@ -77,7 +77,7 @@ postcard = { version = "1", default-features = false, features = ["use-std"]
# Inter-relay mesh transport (buzz-relay-mesh)
iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] }
serde_json = "1"
serde_json = { version = "1", features = ["raw_value"] }
serde_yaml = "0.9"
evalexpr = "11"
cron = "0.16"

View File

@@ -0,0 +1,563 @@
//! Argument types — one per [`Action`], plus the tagged union that pairs a
//! wire action name with its arguments. Each type carries a `validated()`
//! returning a normalized copy; the shared validators and the contract-wide
//! strictness rules live in the [parent module](super).
use serde::{Deserialize, Serialize};
use super::{
absent_or_valued, absent_or_valued_hex64, channel, channel_id, content, cursor, event_id,
hex64_field, is_false, limit, mentions, optional, required, respond_to, validate_slug, Action,
PubkeyHex, DEFAULT_PAGE_LIMIT, MAX_ABOUT_CHARS, MAX_EMOJI_CHARS, MAX_NAME_CHARS,
MAX_PROMPT_CHARS, MAX_SCALAR_CHARS,
};
use crate::SdkError;
/// Arguments for `channel.read` — the one read action.
///
/// One action covers channel, thread, and mention-feed scope, because they
/// differ only by filter and a name per scope would split one permission —
/// *may this agent see this channel* — across three policy decisions.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChannelReadArgs {
/// Channel to read.
#[serde(deserialize_with = "channel_id")]
pub channel_id: String,
/// Narrow to one thread by its root event.
#[serde(
default,
deserialize_with = "absent_or_valued_hex64",
skip_serializing_if = "Option::is_none"
)]
pub root_event_id: Option<String>,
/// Narrow to messages mentioning the requester — the wake path. The
/// requester is never named; no body names its own subject.
#[serde(default, skip_serializing_if = "is_false")]
pub mentions_only: bool,
/// Opaque position to resume from, as returned in [`super::outcomes::MessagePage::next_cursor`].
///
/// Absent on a first read, which starts at the host's default window.
/// Callers must round-trip a cursor verbatim, never parse or synthesize
/// one: the host defines ordering and cursor stability, including whether
/// a cursor stays valid across restarts.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub cursor: Option<String>,
/// Maximum events to return, capped at [`super::MAX_PAGE_LIMIT`].
///
/// Absent means [`super::DEFAULT_PAGE_LIMIT`], not "unbounded": see
/// [`Self::effective_limit`].
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub limit: Option<u32>,
}
impl ChannelReadArgs {
/// The page size a response to these arguments is held to: explicit
/// `limit` when set, otherwise [`super::DEFAULT_PAGE_LIMIT`] — omitting a
/// limit asks for a sensible page, not an unbounded one.
#[must_use]
pub fn effective_limit(&self) -> u32 {
self.limit.unwrap_or(DEFAULT_PAGE_LIMIT)
}
/// Read a whole channel from the host's default window.
#[must_use]
pub fn channel(channel_id: impl Into<String>) -> Self {
Self {
channel_id: channel_id.into(),
..Self::default()
}
}
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a
/// malformed root event id, an over-long or non-printable cursor, or an
/// out-of-range limit.
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(Self {
channel_id: channel(&self.channel_id)?,
root_event_id: self
.root_event_id
.as_deref()
.map(|id| event_id(id, "rootEventId"))
.transpose()?,
mentions_only: self.mentions_only,
cursor: self.cursor.as_deref().map(cursor).transpose()?,
limit: limit(self.limit)?,
})
}
}
// ── Write arguments ─────────────────────────────────────────────────────────
/// Arguments for `message.post`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MessagePostArgs {
/// Channel to post in.
#[serde(deserialize_with = "channel_id")]
pub channel_id: String,
/// Message body.
pub content: String,
/// Pubkeys to notify.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mentions: Vec<PubkeyHex>,
}
impl MessagePostArgs {
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID or empty
/// content, [`SdkError::ContentTooLarge`] for oversized content, and
/// [`SdkError::TooManyMentions`] past [`super::MAX_MENTIONS`].
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(Self {
channel_id: channel(&self.channel_id)?,
content: content(&self.content)?,
mentions: mentions(&self.mentions)?,
})
}
}
/// Arguments for `message.reply`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MessageReplyArgs {
/// Channel containing the parent.
#[serde(deserialize_with = "channel_id")]
pub channel_id: String,
/// Event being replied to.
#[serde(deserialize_with = "hex64_field")]
pub reply_to_event_id: String,
/// Reply body.
pub content: String,
/// Pubkeys to notify.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mentions: Vec<PubkeyHex>,
}
impl MessageReplyArgs {
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a
/// malformed event id, or empty content; [`SdkError::ContentTooLarge`] for
/// oversized content; [`SdkError::TooManyMentions`] past [`super::MAX_MENTIONS`].
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(Self {
channel_id: channel(&self.channel_id)?,
reply_to_event_id: event_id(&self.reply_to_event_id, "replyToEventId")?,
content: content(&self.content)?,
mentions: mentions(&self.mentions)?,
})
}
}
/// Arguments for `reaction.add`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReactionAddArgs {
/// Channel containing the target.
#[serde(deserialize_with = "channel_id")]
pub channel_id: String,
/// Event being reacted to.
#[serde(deserialize_with = "hex64_field")]
pub target_event_id: String,
/// Reaction payload — an emoji or a `:shortcode:`.
pub reaction: String,
}
impl ReactionAddArgs {
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a
/// malformed event id, or an empty reaction, and [`SdkError::EmojiTooLong`]
/// past [`MAX_EMOJI_CHARS`].
pub fn validated(&self) -> Result<Self, SdkError> {
let reaction = self.reaction.trim();
if reaction.is_empty() {
return Err(SdkError::InvalidInput("reaction must not be empty".into()));
}
if reaction.chars().count() > MAX_EMOJI_CHARS {
return Err(SdkError::EmojiTooLong);
}
Ok(Self {
channel_id: channel(&self.channel_id)?,
target_event_id: event_id(&self.target_event_id, "targetEventId")?,
reaction: reaction.to_owned(),
})
}
}
/// Arguments for `profile.set`.
///
/// Only the requester's own profile is addressable, so there is no subject
/// field. Absent fields are left as they are; the host does not clear them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProfileSetArgs {
/// Replacement display name.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub display_name: Option<String>,
/// Replacement bio.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub about: Option<String>,
/// Replacement avatar URL.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub picture: Option<String>,
}
impl ProfileSetArgs {
/// Validate and normalize, requiring at least one field to change.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for an over-long field or a request
/// that changes nothing.
pub fn validated(&self) -> Result<Self, SdkError> {
let normalized = Self {
display_name: optional(self.display_name.as_ref(), "display name", MAX_NAME_CHARS)?,
about: optional(self.about.as_ref(), "about", MAX_ABOUT_CHARS)?,
picture: optional(self.picture.as_ref(), "picture", MAX_SCALAR_CHARS)?,
};
if normalized.display_name.is_none()
&& normalized.about.is_none()
&& normalized.picture.is_none()
{
return Err(SdkError::InvalidInput(
"include at least one profile field to set".into(),
));
}
Ok(normalized)
}
}
/// Arguments for `storage.address`.
///
/// Deriving a record's address needs the secret this contract exists to avoid
/// holding, which is why it routes through the interface.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageAddressArgs {
/// Memory slug — `core` or `mem/…`, per NIP-AE.
pub slug: String,
}
impl StorageAddressArgs {
/// Validate and normalize.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] when the slug fails the NIP-AE
/// grammar.
pub fn validated(&self) -> Result<Self, SdkError> {
let slug = required(&self.slug, "slug", 255)?;
validate_slug(&slug).map_err(|e| SdkError::InvalidInput(e.to_string()))?;
Ok(Self { slug })
}
}
// ── Agent arguments ─────────────────────────────────────────────────────────
/// Which agent an update or delete targets — exactly one selector, so a host
/// never has to guess which of two names wins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentTarget {
/// Target by agent pubkey.
Pubkey(PubkeyHex),
/// Target by the agent's current name.
Name(String),
}
impl AgentTarget {
/// Validate and normalize the selector.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for an empty or over-long name.
pub fn validated(&self) -> Result<Self, SdkError> {
match self {
Self::Pubkey(pubkey) => Ok(Self::Pubkey(PubkeyHex::parse(pubkey.as_str())?)),
Self::Name(name) => Ok(Self::Name(required(name, "agent name", MAX_NAME_CHARS)?)),
}
}
}
/// Arguments for `agents.create`.
///
/// There is no owner field: the owner is whoever the host authenticated. See
/// the [contract docs](crate::broker) on ownership recursion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsCreateArgs {
/// Channel the new agent is attached to.
#[serde(deserialize_with = "channel_id")]
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,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub runtime: Option<String>,
/// Inference provider.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub provider: Option<String>,
/// Model identifier, interpreted relative to the runtime.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub model: Option<String>,
/// Inbound author gate mode; absent = the host's owner-only default.
#[serde(
default,
deserialize_with = "absent_or_valued",
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,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub display_name: Option<String>,
/// Replacement instructions.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub system_prompt: Option<String>,
/// Harness id to pin.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub runtime: Option<String>,
/// Inference provider.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub provider: Option<String>,
/// Model identifier.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub model: Option<String>,
/// Inbound author gate mode.
#[serde(
default,
deserialize_with = "absent_or_valued",
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()?,
})
}
}
// ── Action union ────────────────────────────────────────────────────────────
/// An action name paired with its strictly typed arguments.
///
/// Flattened into [`crate::broker::BrokerRequest`], so the wire form is
/// `{ "action": "message.post", "args": { … } }` and an args shape can never be
/// paired with the wrong action name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", content = "args", deny_unknown_fields)]
pub enum ActionArgs {
/// Read a channel, thread, or mention feed.
#[serde(rename = "channel.read")]
ChannelRead(ChannelReadArgs),
/// Post a message.
#[serde(rename = "message.post")]
MessagePost(MessagePostArgs),
/// Reply to a message.
#[serde(rename = "message.reply")]
MessageReply(MessageReplyArgs),
/// React to a message.
#[serde(rename = "reaction.add")]
ReactionAdd(ReactionAddArgs),
/// Set the requester's profile.
#[serde(rename = "profile.set")]
ProfileSet(ProfileSetArgs),
/// Derive an encrypted-memory address.
#[serde(rename = "storage.address")]
StorageAddress(StorageAddressArgs),
/// 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 ActionArgs {
/// The action these args belong to.
#[must_use]
pub fn action(&self) -> Action {
match self {
Self::ChannelRead(_) => Action::ChannelRead,
Self::MessagePost(_) => Action::MessagePost,
Self::MessageReply(_) => Action::MessageReply,
Self::ReactionAdd(_) => Action::ReactionAdd,
Self::ProfileSet(_) => Action::ProfileSet,
Self::StorageAddress(_) => Action::StorageAddress,
Self::AgentsCreate(_) => Action::AgentsCreate,
Self::AgentsUpdate(_) => Action::AgentsUpdate,
Self::AgentsDelete(_) => Action::AgentsDelete,
}
}
/// Return a normalized copy with every field validated.
///
/// There is deliberately no non-consuming `validate(&self)` beside this;
/// see [`crate::broker::BrokerRequest::validated`] for the trap it was.
///
/// # Errors
///
/// Propagates the per-action validation error.
pub fn validated(&self) -> Result<Self, SdkError> {
Ok(match self {
Self::ChannelRead(args) => Self::ChannelRead(args.validated()?),
Self::MessagePost(args) => Self::MessagePost(args.validated()?),
Self::MessageReply(args) => Self::MessageReply(args.validated()?),
Self::ReactionAdd(args) => Self::ReactionAdd(args.validated()?),
Self::ProfileSet(args) => Self::ProfileSet(args.validated()?),
Self::StorageAddress(args) => Self::StorageAddress(args.validated()?),
Self::AgentsCreate(args) => Self::AgentsCreate(args.validated()?),
Self::AgentsUpdate(args) => Self::AgentsUpdate(args.validated()?),
Self::AgentsDelete(args) => Self::AgentsDelete(args.validated()?),
})
}
}

View File

@@ -0,0 +1,414 @@
//! Broker actions — the closed set of operations an agent may ask a host to
//! perform. [`Action`] and the shared validators live here; the payload types
//! are split into [`args`] and [`outcomes`] so each side of a call reviews on
//! its own.
use serde::{Deserialize, Serialize};
use crate::SdkError;
use buzz_core::engram::validate_slug;
pub mod args;
pub mod outcomes;
pub use args::{
ActionArgs, AgentTarget, AgentsCreateArgs, AgentsDeleteArgs, AgentsUpdateArgs, ChannelReadArgs,
MessagePostArgs, MessageReplyArgs, ProfileSetArgs, ReactionAddArgs, StorageAddressArgs,
};
pub use outcomes::{
ActionOutcome, AgentsCreateOutcome, AgentsDeleteOutcome, AgentsUpdateOutcome, BrokerMessage,
EventPublished, MessagePage, StorageAddress,
};
/// 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;
/// Maximum characters in a profile `about` blurb.
pub const MAX_ABOUT_CHARS: usize = 2_000;
/// Maximum bytes of message content, matching the SDK's channel-message cap.
pub const MAX_CONTENT_BYTES: usize = 64 * 1024;
/// Maximum characters in a reaction payload (emoji or `:shortcode:`).
pub const MAX_EMOJI_CHARS: usize = 66;
/// Maximum mentions attachable to one message.
pub const MAX_MENTIONS: usize = 50;
/// Maximum events a single read may return.
pub const MAX_PAGE_LIMIT: u32 = 500;
/// Events a read returns when the request sets no explicit `limit`.
///
/// A caller that omits `limit` is not agreeing to an unbounded page, so this is
/// the number a response is held to in that case — see
/// [`crate::broker::BrokerResponse::validate_for`]. It is deliberately well
/// under [`MAX_PAGE_LIMIT`]: the cap is what a host may ever send, this is what
/// it may send unasked.
pub const DEFAULT_PAGE_LIMIT: u32 = 100;
/// Maximum accepted length of a read cursor, in bytes.
pub const MAX_CURSOR_LEN: usize = 256;
/// Inbound author gate modes a requester may ask for.
///
/// `allowlist` is deliberately absent: it needs a pubkey list this 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 public key in lowercase hex — the only identity this contract has. No
/// secret-key counterpart exists in this module (#6467's identity/signing
/// separation, made structural).
///
/// A value of this type is a **real x-only secp256k1 point**, not just 64 hex
/// characters — most 32-byte values lie on no curve. Accepting shape alone
/// would defer the first real rejection to whichever consumer eventually
/// converts the string to a key, after the request was already accepted. The
/// curve check is the `nostr` crate's, so the contract and the events it
/// carries agree on what a key is by construction.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct PubkeyHex(String);
impl PubkeyHex {
/// Parse a 64-character hex x-only public key, normalizing to lowercase.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] unless `value` is exactly 64 hex
/// characters **and** those bytes are a point on secp256k1.
pub fn parse(value: impl AsRef<str>) -> Result<Self, SdkError> {
let value = value.as_ref().trim();
if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(
"pubkey must be 64 hex characters".into(),
));
}
let value = value.to_ascii_lowercase();
// `from_hex` only decodes hex; `xonly` is what actually rejects a
// value that is not on the curve.
nostr::PublicKey::from_hex(&value)
.and_then(|key| key.xonly().map(|_| ()))
.map_err(|_| {
SdkError::InvalidInput("pubkey is not a valid secp256k1 x-only public key".into())
})?;
Ok(Self(value))
}
/// The hex representation.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for PubkeyHex {
type Error = SdkError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl From<PubkeyHex> for String {
fn from(value: PubkeyHex) -> Self {
value.0
}
}
impl std::fmt::Display for PubkeyHex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// An action name the broker can dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Action {
/// Read messages from a channel, thread, or mention feed after a cursor.
ChannelRead,
/// Post a top-level channel message.
MessagePost,
/// Reply to an existing message.
MessageReply,
/// React to an existing message.
ReactionAdd,
/// Publish the requester's own profile metadata.
ProfileSet,
/// Derive the address of one encrypted-memory record.
StorageAddress,
/// Mint a managed agent owned by the requester.
AgentsCreate,
/// Patch a managed agent the requester owns.
AgentsUpdate,
/// Remove a managed agent the requester owns.
AgentsDelete,
}
impl Action {
/// Every action in this protocol version, in wire-name order.
pub const ALL: [Self; 9] = [
Self::AgentsCreate,
Self::AgentsDelete,
Self::AgentsUpdate,
Self::ChannelRead,
Self::MessagePost,
Self::MessageReply,
Self::ProfileSet,
Self::ReactionAdd,
Self::StorageAddress,
];
/// Stable wire name.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ChannelRead => "channel.read",
Self::MessagePost => "message.post",
Self::MessageReply => "message.reply",
Self::ReactionAdd => "reaction.add",
Self::ProfileSet => "profile.set",
Self::StorageAddress => "storage.address",
Self::AgentsCreate => "agents.create",
Self::AgentsUpdate => "agents.update",
Self::AgentsDelete => "agents.delete",
}
}
/// The action contract version this build implements.
#[must_use]
pub fn current_version(self) -> u16 {
1
}
/// Whether a host may refuse this action without harming the agent.
///
/// #6467 requires non-essential signed housekeeping to be skippable, so an
/// agent can still run where it is unavailable. See
/// [`super::BrokerErrorCode::Unsupported`] for how a caller reacts.
#[must_use]
pub fn is_best_effort(self) -> bool {
matches!(self, Self::ReactionAdd)
}
/// Resolve a wire name.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for an unknown action name.
pub fn parse(name: &str) -> Result<Self, SdkError> {
Self::ALL
.into_iter()
.find(|action| action.as_str() == name)
.ok_or_else(|| SdkError::InvalidInput(format!("unknown broker action \"{name}\"")))
}
}
// ── Shared validators ───────────────────────────────────────────────────────
fn is_false(value: &bool) -> bool {
!*value
}
/// Deserialize an optional member that may be **absent but never `null`**.
///
/// This is the contract's one spelling-of-absence rule, and this is its
/// canonical rationale. `#[serde(default)] Option<T>` maps an explicit `null`
/// to `None`, *indistinguishable from absent* to downstream code — so a reader
/// that decides something from absence (the status match in
/// [`crate::broker::BrokerResponse`], or
/// [`args::ChannelReadArgs::effective_limit`]) would silently treat a member
/// the sender did supply as one it did not. In the response envelope that was
/// a real hole: `{"status":"failed","outcome":null}` parsed as a plain failure
/// and skipped the per-status contradiction check. Rejecting `null` outright
/// leaves exactly one way to say "absent" and no layer guessing what a
/// present-but-empty member meant.
///
/// Used with `#[serde(default, deserialize_with = "…")]`: serde calls this only
/// when the key is present, so reaching the `None` arm below means the member
/// was present and `null`. `deny_unknown_fields` stays in force alongside it.
///
/// A required member of a non-`Option` type already rejects `null` as a type
/// error; the guard is only load-bearing where `Option` plus `default` would
/// otherwise conflate `null` with absent.
pub(super) fn absent_or_valued<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
match Option::<T>::deserialize(deserializer)? {
Some(value) => Ok(Some(value)),
None => Err(D::Error::custom(
"must not be null; omit the member to mean absent",
)),
}
}
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()
}
/// Validate a channel id and return its **canonical** spelling — lowercase
/// hyphenated. `Uuid::parse_str` accepts several spellings of one channel, and
/// freezing the caller's spelling would make the host's canonical echo of the
/// same identity look like a mismatch in
/// [`crate::broker::BrokerResponse::validate_for`]. Same treatment
/// [`PubkeyHex::parse`] gives the other identity in this contract.
fn channel(value: &str) -> Result<String, SdkError> {
let value = required(value, "channel", 128)?;
uuid::Uuid::parse_str(&value)
.map(|id| id.as_hyphenated().to_string())
.map_err(|_| SdkError::InvalidInput(format!("invalid channel UUID: {value}")))
}
/// Deserialize a `channelId`, canonicalizing it and rejecting a non-UUID.
///
/// The wire is the one door a validator cannot cover: fields holding a channel
/// id are public `String`s, so a payload parsed from JSON reaches a caller
/// without passing through any `validated()`. Delegating to [`channel`] keeps
/// the wire form and the constructed form canonicalized by the same code.
pub(super) fn channel_id<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let raw = String::deserialize(deserializer)?;
channel(&raw).map_err(D::Error::custom)
}
fn event_id(value: &str, label: &str) -> Result<String, SdkError> {
let value = required(value, label, 64)?;
if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"{label} must be 64 hex characters"
)));
}
Ok(value.to_ascii_lowercase())
}
/// Deserialize a 64-hex identifier (`eventId`, `dTag`), lowercasing it —
/// the [`channel_id`] rule applied to the contract's other multi-spelling
/// identities. The label is generic because serde already reports which
/// member failed.
pub(super) fn hex64_field<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let raw = String::deserialize(deserializer)?;
event_id(&raw, "identifier").map_err(D::Error::custom)
}
/// [`hex64_field`] for an optional member: `null` is still rejected (see
/// [`absent_or_valued`]). One function because `deserialize_with` takes one,
/// and both rules apply to the same member.
pub(super) fn absent_or_valued_hex64<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
match Option::<String>::deserialize(deserializer)? {
Some(raw) => Ok(Some(
event_id(&raw, "identifier").map_err(D::Error::custom)?,
)),
None => Err(D::Error::custom(
"must not be null; omit the member to mean absent",
)),
}
}
fn content(value: &str) -> Result<String, SdkError> {
if value.trim().is_empty() {
return Err(SdkError::InvalidInput("content must not be empty".into()));
}
if value.len() > MAX_CONTENT_BYTES {
return Err(SdkError::ContentTooLarge {
max: MAX_CONTENT_BYTES,
got: value.len(),
});
}
Ok(value.to_owned())
}
fn mentions(values: &[PubkeyHex]) -> Result<Vec<PubkeyHex>, SdkError> {
if values.len() > MAX_MENTIONS {
return Err(SdkError::TooManyMentions);
}
values
.iter()
.map(|pubkey| PubkeyHex::parse(pubkey.as_str()))
.collect()
}
fn limit(value: Option<u32>) -> Result<Option<u32>, SdkError> {
match value {
None => Ok(None),
Some(0) => Err(SdkError::InvalidInput("limit must be at least 1".into())),
Some(limit) if limit > MAX_PAGE_LIMIT => Err(SdkError::InvalidInput(format!(
"limit exceeds {MAX_PAGE_LIMIT} (got {limit})"
))),
Some(limit) => Ok(Some(limit)),
}
}
/// Validate an opaque read cursor: printable ASCII, bounded, never parsed.
///
/// The bound exists so a host cannot be made to store an unbounded token; the
/// character set keeps it safe to log. Nothing here interprets the value.
fn cursor(value: &str) -> Result<String, SdkError> {
if value.is_empty() {
return Err(SdkError::InvalidInput(
"cursor must not be empty (omit it to start from the host's default window)".into(),
));
}
if value.len() > MAX_CURSOR_LEN {
return Err(SdkError::InvalidInput(format!(
"cursor exceeds {MAX_CURSOR_LEN} bytes (got {})",
value.len()
)));
}
if !value.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
return Err(SdkError::InvalidInput(
"cursor must be printable ASCII without spaces".into(),
));
}
Ok(value.to_owned())
}
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,298 @@
//! Outcome types — the success payload of each [`Action`], and the tagged union
//! that pairs a wire action name with its outcome. Outcomes are shared where
//! actions agree on what success means: the four event-publishing actions all
//! return [`EventPublished`].
use serde::{Deserialize, Serialize};
use super::{
absent_or_valued, channel, channel_id, cursor, event_id, hex64_field, required, Action,
PubkeyHex, MAX_NAME_CHARS, MAX_PAGE_LIMIT,
};
use crate::SdkError;
use nostr::{Event, EventId, Kind, PublicKey, Tags, Timestamp};
/// The seven canonical members of a Nostr event object, and nothing else.
///
/// `nostr`'s own `Event` deserializer accepts and *discards* unknown members,
/// which would put read results outside the contract's strict-wire rule.
/// Routing through a `deny_unknown_fields` intermediary restores the rule at
/// the one place the contract does not own the type.
///
/// Field names are the wire names from NIP-01 (`created_at`, not `createdAt`) —
/// this is the event's own encoding, not ours to rename.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictEvent {
id: EventId,
pubkey: PublicKey,
created_at: Timestamp,
kind: Kind,
tags: Tags,
content: String,
sig: nostr::secp256k1::schnorr::Signature,
}
/// One message returned by a read: the signed Nostr event, verbatim.
///
/// The event is carried whole — signature and tags included — rather than
/// reduced to a projection, because Schnorr verification is local (see
/// [`Self::verify`]): a keyless agent gets independently verifiable authorship
/// and content, and only trusts the host for *completeness* and authorization.
/// Ancestry and mentions are derived accessors rather than sibling fields, so
/// nothing can disagree with the signed bytes.
///
/// Deserialization is **strict**, via a private `deny_unknown_fields`
/// intermediary; serialization is the event's own, so the wire form is
/// unchanged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct BrokerMessage(pub Event);
impl<'de> Deserialize<'de> for BrokerMessage {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let strict = StrictEvent::deserialize(deserializer)?;
Ok(Self(Event::new(
strict.id,
strict.pubkey,
strict.created_at,
strict.kind,
strict.tags,
strict.content,
strict.sig,
)))
}
}
impl BrokerMessage {
/// The signed event.
#[must_use]
pub fn event(&self) -> &Event {
&self.0
}
/// Verify the event's id and Schnorr signature — entirely local; a host
/// that fabricated or altered a message fails here regardless of what it
/// claims. Deliberately *not* called by
/// [`crate::broker::BrokerResponse::validate_for`]: whether to pay for
/// verification, and what to do when it fails, is the caller's policy.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] when the id does not match the
/// content or the signature does not match the author.
pub fn verify(&self) -> Result<(), SdkError> {
self.0.verify().map_err(|e| {
SdkError::InvalidInput(format!("broker returned an unverifiable event: {e}"))
})
}
/// The author's pubkey, in this contract's identity type.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] if the event's author is not
/// expressible as 64 hex characters.
pub fn author(&self) -> Result<PubkeyHex, SdkError> {
PubkeyHex::parse(self.0.pubkey.to_hex())
}
/// NIP-10 `root`/`reply` ancestry, parsed from the signed tags.
#[must_use]
pub fn thread(&self) -> buzz_core::nip10::ThreadMarkers {
buzz_core::nip10::parse_thread_markers(&self.0.tags)
}
/// Pubkeys this message mentions, from the signed `p` tags.
#[must_use]
pub fn mentions(&self) -> Vec<String> {
self.0
.tags
.public_keys()
.map(nostr::PublicKey::to_hex)
.collect()
}
}
/// Outcome of any read action.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MessagePage {
/// Messages in the host's declared order.
pub messages: Vec<BrokerMessage>,
/// Opaque cursor to pass as [`super::args::ChannelReadArgs::cursor`] on the next call.
///
/// Absent when the host has nothing further, which is how a caller learns
/// to stop rather than by comparing lengths against a limit it may not have
/// set.
#[serde(
default,
deserialize_with = "absent_or_valued",
skip_serializing_if = "Option::is_none"
)]
pub next_cursor: Option<String>,
}
/// Outcome of an action that published one event.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EventPublished {
/// The published event's id (hex).
#[serde(deserialize_with = "hex64_field")]
pub event_id: String,
/// The published event's kind.
pub kind: u32,
/// Creation time the host stamped, Unix seconds.
pub created_at: u64,
}
/// Outcome of `storage.address`.
///
/// Addressing material only. A `d` tag is a keyed hash of the slug, so it
/// identifies a record without revealing the slug or the key that derived it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageAddress {
/// Author the record is addressed under.
pub author_pubkey: PubkeyHex,
/// Event kind holding the record.
pub kind: u32,
/// Derived `d` tag (64 hex characters).
#[serde(deserialize_with = "hex64_field")]
pub d_tag: String,
}
/// Outcome of a successful `agents.create`.
///
/// Carries the new agent's **public** identity only — there is no field for
/// the minted secret, and `deny_unknown_fields` plus the key-set test is what
/// enforces that rather than a comment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgentsCreateOutcome {
/// The new agent's pubkey.
pub agent_pubkey: PubkeyHex,
/// The new agent's name as stored.
pub display_name: String,
/// Channel the agent was attached to.
#[serde(deserialize_with = "channel_id")]
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.
pub agent_pubkey: PubkeyHex,
/// 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.
pub agent_pubkey: PubkeyHex,
/// The removed agent's name.
pub display_name: String,
}
/// An action-specific success payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", content = "outcome", deny_unknown_fields)]
pub enum ActionOutcome {
/// `channel.read` succeeded.
#[serde(rename = "channel.read")]
ChannelRead(MessagePage),
/// `message.post` succeeded.
#[serde(rename = "message.post")]
MessagePost(EventPublished),
/// `message.reply` succeeded.
#[serde(rename = "message.reply")]
MessageReply(EventPublished),
/// `reaction.add` succeeded.
#[serde(rename = "reaction.add")]
ReactionAdd(EventPublished),
/// `profile.set` succeeded.
#[serde(rename = "profile.set")]
ProfileSet(EventPublished),
/// `storage.address` succeeded.
#[serde(rename = "storage.address")]
StorageAddress(StorageAddress),
/// `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 ActionOutcome {
/// The action that produced this outcome.
#[must_use]
pub fn action(&self) -> Action {
match self {
Self::ChannelRead(_) => Action::ChannelRead,
Self::MessagePost(_) => Action::MessagePost,
Self::MessageReply(_) => Action::MessageReply,
Self::ReactionAdd(_) => Action::ReactionAdd,
Self::ProfileSet(_) => Action::ProfileSet,
Self::StorageAddress(_) => Action::StorageAddress,
Self::AgentsCreate(_) => Action::AgentsCreate,
Self::AgentsUpdate(_) => Action::AgentsUpdate,
Self::AgentsDelete(_) => Action::AgentsDelete,
}
}
/// Validate the identifiers and cursors this outcome asserts.
///
/// A well-typed outcome can still carry a malformed id or an unusable
/// cursor. Signature verification is deliberately *not* here — see
/// [`BrokerMessage::verify`].
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] for a malformed event id, `d` tag,
/// channel UUID, or cursor, an empty name, or an over-long page.
pub fn validate(&self) -> Result<(), SdkError> {
match self {
Self::ChannelRead(page) => {
if page.messages.len() > MAX_PAGE_LIMIT as usize {
return Err(SdkError::InvalidInput(format!(
"page holds {} messages, over the {MAX_PAGE_LIMIT} cap",
page.messages.len()
)));
}
page.next_cursor.as_deref().map(cursor).transpose()?;
}
Self::MessagePost(published)
| Self::MessageReply(published)
| Self::ReactionAdd(published)
| Self::ProfileSet(published) => {
event_id(&published.event_id, "eventId")?;
}
Self::StorageAddress(address) => {
event_id(&address.d_tag, "dTag")?;
}
Self::AgentsCreate(outcome) => {
channel(&outcome.channel_id)?;
required(&outcome.display_name, "display name", MAX_NAME_CHARS)?;
}
Self::AgentsUpdate(AgentsUpdateOutcome { display_name, .. })
| Self::AgentsDelete(AgentsDeleteOutcome { display_name, .. }) => {
required(display_name, "display name", MAX_NAME_CHARS)?;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,223 @@
//! Client trait and HTTP binding for the broker contract.
//!
//! # HTTP binding
//!
//! ```text
//! POST /v1/action
//! Authorization: Bearer <opaque session credential>
//! Content-Type: application/json
//!
//! <PreparedRequest::body(), verbatim>
//! ```
//!
//! The response body is a [`BrokerResponse`] as JSON. Every terminal
//! disposition the *host* reached — including a rejected credential — is a
//! well-formed envelope returned with `200`: the verdict lives in `status`,
//! and a second copy in the status line could only ever disagree with it. A
//! client must nonetheless **attempt to parse an envelope regardless of HTTP
//! status** (an intermediary may map dispositions onto statuses); if a valid
//! envelope is present, it is the answer. Only when no envelope can be parsed
//! does the status matter, and then only as operator detail — see
//! [`BrokerTransportError`].
//!
//! # The credential
//!
//! The credential is **opaque to this contract**: a bearer token the agent
//! received at startup and can only replay — not a key, not a signature. A
//! rejected credential is a host verdict, not a transport failure: it arrives
//! as `Failed` with [`super::BrokerErrorCode::Unauthenticated`], which carries
//! the promise that the action did not run.
//!
//! Binding a credential to a specific (agent, conversation) pair **is the
//! host's concern**; the request body carries no requester and no scope,
//! precisely so the host's binding is the only thing that decides authority.
//! Since the credential is the whole of the agent's authority, serving this
//! over anything but a loopback socket or TLS is publishing it.
use std::future::Future;
use std::pin::Pin;
use super::{BrokerResponse, BrokerResult, PreparedRequest};
/// Path of the single broker endpoint.
pub const BROKER_ACTION_PATH: &str = "/v1/action";
/// Header carrying the opaque session credential, as `Bearer <credential>`.
pub const BROKER_CREDENTIAL_HEADER: &str = "authorization";
/// No usable [`BrokerResponse`] was obtained, so the request's fate is unknown.
///
/// Every variant means the same thing to a caller: nothing can be concluded
/// about side effects, and the only safe next step is to retry the identical
/// bytes (which the host will deduplicate) or to reconcile by reading state.
/// The variants differ only in what to tell an operator. Host verdicts never
/// appear here — this type is strictly for the absence of an answer.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum BrokerTransportError {
/// The host could not be reached, or the connection failed mid-request.
#[error("broker unreachable: {0}")]
Unreachable(String),
/// An HTTP response arrived carrying no parseable envelope.
///
/// Typically an intermediary answering instead of the host: a proxy `401`,
/// a `404` for a missing route, a `502`. The status is recorded for
/// operators and carries no contractual meaning — an intermediary's `401`
/// does not prove the host never ran the action.
#[error("no broker envelope in HTTP {status} response: {detail}")]
NoEnvelope {
/// The HTTP status observed.
status: u16,
/// Operator-facing detail about what arrived instead.
detail: String,
},
/// An envelope arrived but did not validate against the request that was
/// sent — wrong `requestId`, wrong action, a malformed outcome, or a status
/// contradicting its own error code.
///
/// A host that answers something other than what was asked has given no
/// verdict at all, which is why this is a transport failure rather than a
/// `Failed` result.
#[error("malformed broker response: {0}")]
MalformedResponse(String),
}
/// A future returned by [`BrokerClient::send`].
///
/// Spelled as a boxed future rather than `async fn` in the trait because this
/// trait must be object-safe: the harness holds one client and must not know
/// whether it talks to an in-process host or an HTTP one.
pub type BrokerFuture<'a> =
Pin<Box<dyn Future<Output = Result<BrokerResponse, BrokerTransportError>> + Send + 'a>>;
/// A future returned by [`BrokerClientExt::execute`].
pub type ValidatedFuture<'a> =
Pin<Box<dyn Future<Output = Result<ValidatedResponse, BrokerTransportError>> + Send + 'a>>;
/// A host response that has been checked against the request it answers.
///
/// The only way to obtain one is [`ValidatedResponse::validate`], which
/// [`BrokerClientExt::execute`] calls — so correlation is not advice an
/// implementation may skip. [`BrokerResponse::validate_for`] remains public for
/// a host validating its own output, but a client never has to remember to
/// call it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedResponse(BrokerResponse);
impl ValidatedResponse {
/// Check `response` against the request it claims to answer.
///
/// # Errors
///
/// Returns [`BrokerTransportError::MalformedResponse`] when the response
/// does not correlate, carries an outcome for a different action, asserts a
/// malformed identifier, or pairs a status with a code that contradicts it.
/// A response that fails here is not a host verdict — nothing can be
/// concluded about side effects from it.
pub fn validate(
response: BrokerResponse,
request: &PreparedRequest,
) -> Result<Self, BrokerTransportError> {
response
.validate_for(request)
.map_err(|e| BrokerTransportError::MalformedResponse(e.to_string()))?;
Ok(Self(response))
}
/// The terminal disposition the host reached.
#[must_use]
pub fn result(&self) -> &BrokerResult {
&self.0.result
}
/// The correlated `requestId`.
#[must_use]
pub fn request_id(&self) -> &str {
&self.0.request_id
}
/// Whether the host replayed a previously recorded outcome.
#[must_use]
pub fn replayed(&self) -> bool {
self.0.replayed
}
/// The underlying envelope, for logging or re-serialization.
#[must_use]
pub fn envelope(&self) -> &BrokerResponse {
&self.0
}
/// Consume this wrapper, yielding the validated envelope.
#[must_use]
pub fn into_envelope(self) -> BrokerResponse {
self.0
}
}
/// Permission to call [`BrokerClient::send`], which only
/// [`BrokerClientExt::execute`] can mint.
///
/// This is what makes validation *structurally* the only door. `send` must be
/// public — an out-of-crate implementation has to define it — but a caller
/// must not be able to invoke it and receive an uncorrelated envelope. A token
/// with a private field satisfies both: any crate can accept one in a
/// signature, only this module can construct one. An implementation should
/// ignore its value; it carries no data.
///
/// An implementation that stashes and exposes the envelope it saw has
/// deliberately built an exfiltrating transport — a different thing from a
/// caller forgetting to correlate. Closing the accidental path is the goal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dispatch(());
/// Something that can execute broker requests.
///
/// One method, because there is one endpoint: every operation is an
/// [`super::Action`] inside the request, so adding an action never changes
/// this trait. This is the **transport primitive** — frozen bytes out, one
/// envelope back — not the caller's interface; callers use
/// [`BrokerClientExt::execute`], where response correlation happens.
///
/// Implementations must be usable as `dyn BrokerClient`.
pub trait BrokerClient: Send + Sync {
/// Send `request`'s frozen bytes and return whatever envelope came back.
///
/// An implementation's whole job is transport: send
/// [`PreparedRequest::body`] verbatim, parse an envelope regardless of
/// HTTP status, and return it unjudged. The [`Dispatch`] argument is why
/// this cannot be called directly by an outside caller; that is deliberate.
///
/// # Errors
///
/// Returns [`BrokerTransportError`] when no envelope could be obtained or
/// parsed. A host that answered — even to refuse — returns `Ok`, with the
/// verdict in [`BrokerResponse::result`].
fn send<'a>(&'a self, request: &'a PreparedRequest, dispatch: Dispatch) -> BrokerFuture<'a>;
}
/// The caller-facing half of [`BrokerClient`]: send, then validate.
///
/// Blanket-implemented for every [`BrokerClient`], including `dyn BrokerClient`,
/// and **not overridable** — coherence forbids a second implementation, so there
/// is exactly one definition of what validating a response means and no client
/// can weaken it.
pub trait BrokerClientExt: BrokerClient {
/// Send `request` and return a response already checked against it.
///
/// # Errors
///
/// Returns [`BrokerTransportError`] when no envelope arrived, or
/// [`BrokerTransportError::MalformedResponse`] when the envelope that
/// arrived does not answer `request`. Both mean the same thing to a caller:
/// no verdict, so nothing is known about side effects.
fn execute<'a>(&'a self, request: &'a PreparedRequest) -> ValidatedFuture<'a>;
}
impl<C: BrokerClient + ?Sized> BrokerClientExt for C {
fn execute<'a>(&'a self, request: &'a PreparedRequest) -> ValidatedFuture<'a> {
Box::pin(async move {
let response = self.send(request, Dispatch(())).await?;
ValidatedResponse::validate(response, request)
})
}
}

View File

@@ -0,0 +1,92 @@
//! Response-to-request identity correlation.
//!
//! Split from [`super`] to keep that file within the repo's 1,000-line
//! ceiling; the rule it implements is part of response validation.
use super::{ActionArgs, ActionOutcome, AgentTarget, PubkeyHex, SdkError};
/// Reject an outcome that echoes back a different identity than the request
/// supplied.
///
/// What is and is not compared, and why comparison is on parsed identities
/// rather than bytes, is documented once on
/// [`BrokerResponse::validate_for`][super::BrokerResponse::validate_for] — the
/// public entry point a host author reads.
///
/// The match below is exhaustive over [`ActionArgs`], so adding an action is a
/// compile error here rather than a silent default to "not compared".
pub(super) fn correlate_identities(
args: &ActionArgs,
outcome: &ActionOutcome,
) -> Result<(), SdkError> {
/// Compare two channel ids as UUIDs, so two spellings of one channel match.
///
/// An unparseable id on either side is a mismatch rather than an error:
/// `validate`/`validated` already reject a malformed channel id with a
/// precise message, and duplicating that verdict here would report a
/// correlation failure for what is really a malformed payload.
fn same_channel(requested: &str, returned: &str) -> Result<(), SdkError> {
let parse = |value: &str| uuid::Uuid::parse_str(value).ok();
match (parse(requested), parse(returned)) {
(Some(requested_id), Some(returned_id)) if requested_id == returned_id => Ok(()),
_ => Err(mismatch("channelId", requested, returned)),
}
}
/// Compare two pubkeys. [`PubkeyHex::parse`] is the type's only
/// constructor and lowercases, so comparing typed values *is* the parsed
/// comparison.
fn same_pubkey(requested: &PubkeyHex, returned: &PubkeyHex) -> Result<(), SdkError> {
if requested == returned {
return Ok(());
}
Err(mismatch(
"agentPubkey",
requested.as_str(),
returned.as_str(),
))
}
/// The error every mismatch reports, naming the field and both spellings.
fn mismatch(field: &str, requested: &str, returned: &str) -> SdkError {
SdkError::InvalidInput(format!(
"response {field} \"{returned}\" does not match the requested \"{requested}\""
))
}
/// The pubkey a target names immutably, if it names one.
fn targeted(target: &AgentTarget) -> Option<&PubkeyHex> {
match target {
AgentTarget::Pubkey(pubkey) => Some(pubkey),
AgentTarget::Name(_) => None,
}
}
match (args, outcome) {
(ActionArgs::AgentsCreate(args), ActionOutcome::AgentsCreate(outcome)) => {
same_channel(&args.channel_id, &outcome.channel_id)
}
(ActionArgs::AgentsUpdate(args), ActionOutcome::AgentsUpdate(outcome)) => {
match targeted(&args.target) {
Some(requested) => same_pubkey(requested, &outcome.agent_pubkey),
None => Ok(()),
}
}
(ActionArgs::AgentsDelete(args), ActionOutcome::AgentsDelete(outcome)) => {
match targeted(&args.target) {
Some(requested) => same_pubkey(requested, &outcome.agent_pubkey),
None => Ok(()),
}
}
// These outcomes echo no identity the request supplied.
(ActionArgs::ChannelRead(_), _)
| (ActionArgs::MessagePost(_), _)
| (ActionArgs::MessageReply(_), _)
| (ActionArgs::ReactionAdd(_), _)
| (ActionArgs::ProfileSet(_), _)
| (ActionArgs::StorageAddress(_), _)
| (ActionArgs::AgentsCreate(_), _)
| (ActionArgs::AgentsUpdate(_), _)
| (ActionArgs::AgentsDelete(_), _) => Ok(()),
}
}

View File

@@ -0,0 +1,754 @@
//! Agent ↔ broker contract — the operations an agent asks a host to perform.
//!
//! This module is a **contract only**: the request envelope, the closed set of
//! [`Action`]s, the result shape, the HTTP binding, and a client trait. No
//! host, no transport, no signing. The full design rationale lives in the
//! English spec (`docs/agent-broker.md`); doc comments here explain only what
//! the code cannot say itself.
//!
//! ```text
//! agent → BrokerRequest → (POST /v1/action, bearer credential) → host
//! host: authenticate → authorize → validate → execute → BrokerResponse
//! ```
//!
//! The agent holds its public key and a session credential — no secret key, no
//! relay connection. Everything it wants to do, reading included, is an action.
//! Actions are named business operations rather than a `sign(bytes)` primitive
//! so a host can hold per-operation policy; that and the rest of the
//! [#6467](https://github.com/block/buzz/issues/6467) mapping are covered in
//! the spec.
//!
//! # Contract-wide rules
//!
//! - **No secret crosses this boundary, in either direction.** Every wire type
//! is strict — unknown members are rejected at every depth, and each type's
//! exact key set is pinned by test. Where a derive would have left a lax
//! reader ([`BrokerResponse`], [`BrokerMessage`], [`BrokerResult`]), the type
//! documents how its strictness is restored.
//! - **Identities have exactly one spelling.** UUIDs and hex admit several
//! legal spellings, so every identity is canonicalized at both doors —
//! `validated()` and deserialization. See [`actions`]'s shared validators.
//! - **Omission is the only spelling of absence.** An explicit `null` is
//! rejected anywhere, at any depth. Canonical rationale on the
//! `absent_or_valued` guard in [`actions`]; host implementers must configure
//! serializers to omit unset members.
//! - **No request names its own subject.** Requester, owner, and scope are
//! derived from the authenticated credential; see [`BrokerRequest`]. This is
//! also why `agents.create` has no owner field — the creator owns the agent,
//! and the ownership chain always terminates at a human. Bounding its depth
//! is a host concern.
//!
//! Two limits worth stating: a `String` field can physically hold secret text,
//! so keeping secrets out of content and error messages is host policy; and
//! nothing stops a host from *holding* keys — that is the point. It stops one
//! from handing them over.
//!
//! # Deferred operations
//!
//! Not in v1, all purely additive later: memory read/write (intent-level
//! operations over the encrypted store — until then [`Action::StorageAddress`]
//! only addresses a record, and the key holder remains the only reader/writer),
//! `presence.set`, `typing.set`, and streaming reads (waking on a mention is
//! `channel.read` with `mentionsOnly`, polled).
//!
//! # Non-goals
//!
//! Hosts (auth, idempotency storage, execution), transports, relay changes,
//! grant/authorization fields (added when a real verifier exists, not before),
//! and secret-key custody. [`BrokerClient`] exists so in-process and HTTP
//! implementations are interchangeable; neither is here.
use serde::{Deserialize, Serialize};
use crate::SdkError;
pub mod actions;
pub mod client;
mod correlate;
mod wire;
use actions::absent_or_valued;
pub use actions::{
Action, ActionArgs, ActionOutcome, AgentTarget, AgentsCreateArgs, AgentsCreateOutcome,
AgentsDeleteArgs, AgentsDeleteOutcome, AgentsUpdateArgs, AgentsUpdateOutcome, BrokerMessage,
ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs,
ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs,
};
pub use client::{
BrokerClient, BrokerClientExt, BrokerFuture, BrokerTransportError, Dispatch, ValidatedFuture,
ValidatedResponse, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER,
};
/// Wire `type` discriminator for a broker request payload.
pub const BROKER_REQUEST_TYPE: &str = "broker_request";
/// Wire `type` discriminator for a broker response 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 action.
///
/// There is deliberately no requester, owner, scope, or relay field: those are
/// derived by the host from the authenticated session credential. **A body
/// that could name its own subject would let any caller act as anyone.**
///
/// # Retry contract
///
/// Retrying means resending the identical bytes with the same `requestId` —
/// the host compares a digest of the bytes against what it recorded under that
/// idempotency key (same digest → replay the recorded outcome; different →
/// [`BrokerErrorCode::RequestIdConflict`]). Two serializations of one value
/// can differ in bytes, so a client never sends this type directly: call
/// [`Self::prepare`] to freeze it into a [`PreparedRequest`] and hand *that*
/// to [`BrokerClientExt::execute`].
#[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,
/// Action contract version the caller wrote `args` against.
pub action_version: u16,
/// The action to invoke, with its strictly typed arguments.
#[serde(flatten)]
pub action: ActionArgs,
}
impl BrokerRequest {
/// Build a request for `action` at the current protocol version.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] if `request_id` is empty, longer than
/// [`MAX_REQUEST_ID_LEN`], or not printable ASCII, or if the action's
/// arguments fail validation.
pub fn new(request_id: impl Into<String>, action: ActionArgs) -> Result<Self, SdkError> {
let request_id = request_id.into();
validate_request_id(&request_id)?;
// Store the normalized copy, not the caller's, so a padded-but-valid
// value cannot travel in the frozen body.
let action = action.validated()?;
Ok(Self {
r#type: BROKER_REQUEST_TYPE.to_string(),
protocol_version: BROKER_PROTOCOL_VERSION,
request_id,
action_version: action.action().current_version(),
action,
})
}
/// The action this request invokes.
#[must_use]
pub fn action(&self) -> Action {
self.action.action()
}
/// Validate and normalize into the only form execution-side code accepts.
///
/// This is the **one normalization door**, and it consumes the request.
/// There is deliberately no non-consuming `validate(&self)`: a verdict
/// about a value it does not replace can drift from the value the caller
/// keeps holding (an earlier one validated a normalized copy, discarded
/// it, and let the caller execute the un-normalized original). The only
/// way to learn a request is valid is to receive the normalized
/// [`ValidatedRequest`].
///
/// # Errors
///
/// Returns [`SdkError`] for a wrong `type`, an unsupported
/// `protocolVersion` or `actionVersion`, a malformed `requestId`, or
/// arguments that fail their own validation.
pub fn validated(mut self) -> Result<ValidatedRequest, SdkError> {
self.validate_envelope()?;
self.action = self.action.validated()?;
Ok(ValidatedRequest(self))
}
/// Validate and normalize, then serialize once into the bytes every attempt
/// will send — [`Self::validated`] followed by [`ValidatedRequest::prepare`].
///
/// # Errors
///
/// Returns [`SdkError`] when [`Self::validated`] fails, or
/// [`SdkError::InvalidInput`] if serialization fails.
pub fn prepare(self) -> Result<PreparedRequest, SdkError> {
self.validated()?.prepare()
}
/// Validate everything except the action arguments, which
/// [`Self::validated`] normalizes in the same step.
fn validate_envelope(&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 action = self.action();
if self.action_version != action.current_version() {
return Err(SdkError::InvalidInput(format!(
"unsupported actionVersion {} for {} (expected {})",
self.action_version,
action.as_str(),
action.current_version()
)));
}
Ok(())
}
}
/// Validate a `requestId`: non-empty, bounded, printable ASCII without spaces.
///
/// The bound and character set exist because this value becomes part of a
/// durable idempotency 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(())
}
/// A [`BrokerRequest`] that has been validated **and normalized**.
///
/// The type execution-side code accepts. The only way to obtain one is
/// [`BrokerRequest::validated`], which normalizes on the way through, so
/// holding one proves the value carries what the validator approved — not the
/// caller's spelling of it.
///
/// The inner request is private with no borrowing accessor: a borrow would let
/// execution-side code clone it, mutate a public field, and execute the result.
/// [`Self::into_request`] consumes the wrapper for a host that needs to move
/// the envelope onward; what it yields is no longer evidence of anything.
///
/// A host that receives bytes builds one the same way a client does — parse,
/// call `validated()`, execute what comes back — since only its own verdict is
/// authoritative.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedRequest(BrokerRequest);
impl ValidatedRequest {
/// The action to execute, with its normalized arguments.
#[must_use]
pub fn args(&self) -> &ActionArgs {
&self.0.action
}
/// The action being invoked.
#[must_use]
pub fn action(&self) -> Action {
self.0.action()
}
/// The idempotency key the host keys replay on.
#[must_use]
pub fn request_id(&self) -> &str {
&self.0.request_id
}
/// Freeze the normalized request into the bytes every attempt will send.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] if serialization fails.
pub fn prepare(self) -> Result<PreparedRequest, SdkError> {
let body = serde_json::to_vec(&self.0).map_err(|e| {
SdkError::InvalidInput(format!("broker request is not serializable: {e}"))
})?;
Ok(PreparedRequest {
request: self.0,
body,
})
}
/// Consume this wrapper, yielding the normalized envelope — a plain
/// [`BrokerRequest`] with public fields, no longer evidence that anything
/// was validated, which is why this consumes rather than borrows.
#[must_use]
pub fn into_request(self) -> BrokerRequest {
self.0
}
}
/// A validated request together with the exact bytes to send.
///
/// This is what [`BrokerClient::send`] takes, so the retry contract is
/// structural: every attempt sends `body` verbatim, and no implementation gets
/// the chance to reserialize. The typed request is deliberately not exposed —
/// only the correlation metadata ([`Self::request_id`], [`Self::action`]) an
/// implementation legitimately needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedRequest {
request: BrokerRequest,
body: Vec<u8>,
}
impl PreparedRequest {
/// The frozen JSON body. Every attempt sends exactly these bytes.
#[must_use]
pub fn body(&self) -> &[u8] {
&self.body
}
/// The idempotency key the host keys replay on.
#[must_use]
pub fn request_id(&self) -> &str {
&self.request.request_id
}
/// The action being invoked.
#[must_use]
pub fn action(&self) -> Action {
self.request.action()
}
}
/// Machine-readable broker error code.
///
/// These name failures the *broker* is responsible for; failures inside an
/// action arrive as [`BrokerErrorCode::ActionFailed`] with detail in the
/// message.
///
/// # Which status a code may carry
///
/// A code and a [`BrokerResult`] status are two statements about the same
/// thing — whether side effects landed — so they cannot be paired freely.
/// `Failed` promises no side effects took hold; `Indeterminate` promises
/// nothing. This is the whole table, and it lives only here:
///
/// | Code | with `failed` | with `indeterminate` |
/// |---|---|---|
/// | `outcome_unknown` | no | yes |
/// | `internal` | yes | yes |
/// | every other code | yes | no |
///
/// [`Self::Internal`] is the one code legitimately either: a host fault before
/// dispatch is a known no-op, the same fault mid-execution is not.
/// [`Self::may_be_failed`] and [`Self::may_be_indeterminate`] are this table in
/// code, consulted by [`BrokerResponse::validate`], which rejects a mismatched
/// pairing as malformed rather than trusting either half.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrokerErrorCode {
/// The envelope or action arguments failed validation.
InvalidRequest,
/// The `protocolVersion` is not supported by this host.
UnsupportedProtocolVersion,
/// The action name is unknown to this host.
UnknownAction,
/// The `actionVersion` is not supported for this action.
UnsupportedActionVersion,
/// The host knows this action but does not offer it.
///
/// For an action where [`Action::is_best_effort`] holds, this is a normal
/// answer and the agent carries on. Otherwise the agent cannot do its job
/// on this host.
Unsupported,
/// The session credential was missing, malformed, or rejected.
///
/// A host verdict, delivered as [`BrokerResult::Failed`], never as a
/// transport error: the request was refused before execution, so the caller
/// knows no side effects occurred.
Unauthenticated,
/// The requester is authenticated but not permitted this action.
Unauthorized,
/// Reuse of a `requestId` with different request content.
RequestIdConflict,
/// The action ran and reported a domain failure.
ActionFailed,
/// The host could not determine whether side effects occurred.
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::UnknownAction => "unknown_action",
Self::UnsupportedActionVersion => "unsupported_action_version",
Self::Unsupported => "unsupported",
Self::Unauthenticated => "unauthenticated",
Self::Unauthorized => "unauthorized",
Self::RequestIdConflict => "request_id_conflict",
Self::ActionFailed => "action_failed",
Self::OutcomeUnknown => "outcome_unknown",
Self::Internal => "internal",
}
}
/// Whether this code may appear with [`BrokerResult::Failed`].
///
/// One half of the table documented on [`BrokerErrorCode`], written as an
/// exhaustive match so adding a code forces a decision here.
#[must_use]
pub fn may_be_failed(self) -> bool {
match self {
Self::InvalidRequest
| Self::UnsupportedProtocolVersion
| Self::UnknownAction
| Self::UnsupportedActionVersion
| Self::Unsupported
| Self::Unauthenticated
| Self::Unauthorized
| Self::RequestIdConflict
| Self::ActionFailed
| Self::Internal => true,
Self::OutcomeUnknown => false,
}
}
/// Whether this code may appear with [`BrokerResult::Indeterminate`] —
/// the other half of the table documented on [`BrokerErrorCode`].
#[must_use]
pub fn may_be_indeterminate(self) -> bool {
match self {
Self::OutcomeUnknown | Self::Internal => true,
Self::InvalidRequest
| Self::UnsupportedProtocolVersion
| Self::UnknownAction
| Self::UnsupportedActionVersion
| Self::Unsupported
| Self::Unauthenticated
| Self::Unauthorized
| Self::RequestIdConflict
| Self::ActionFailed => false,
}
}
}
/// 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::Unsupported`] error.
pub fn unsupported(message: impl Into<String>) -> Self {
Self::new(BrokerErrorCode::Unsupported, 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. [`Self::Indeterminate`] is distinct from
/// [`Self::Failed`] on purpose: `Failed` promises no side effects took hold,
/// `Indeterminate` promises nothing and demands reconciliation. Which
/// [`BrokerErrorCode`] may carry which status is a closed table on that type.
///
/// # Why this type is not [`Deserialize`]
///
/// Its members reach the wire only flattened into [`BrokerResponse`], whose
/// strict reader enforces the exact key set per status. A derived reader here
/// was a second, laxer door onto the same bytes — it accepted and dropped
/// members the envelope rejects — and two copies of a strictness check drift.
/// [`Serialize`] is retained (it produces the envelope's flattened wire form),
/// so this is a read-side restriction only. Nothing is lost: a bare
/// `{"status": …}` object is not a payload this contract defines.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BrokerResult {
/// The action completed and produced this outcome.
Succeeded {
/// Action-specific success payload.
#[serde(flatten)]
outcome: ActionOutcome,
},
/// The action 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: ActionOutcome) -> 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 }
}
/// The outcome, when this is a success.
#[must_use]
pub fn outcome(&self) -> Option<&ActionOutcome> {
match self {
Self::Succeeded { outcome } => Some(outcome),
Self::Failed { .. } | Self::Indeterminate { .. } => None,
}
}
/// 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.
///
/// # Why deserialization goes through an intermediary
///
/// `#[serde(flatten)]` on `result` silently disables `deny_unknown_fields`, so
/// the derived reader accepted and discarded unknown members — exactly how a
/// secret-bearing host field crosses a boundary unnoticed. [`Deserialize`]
/// therefore routes through a private strict wire form (see `wire.rs`) with an
/// exact key set per status; anything else fails to parse and surfaces as
/// [`BrokerTransportError::MalformedResponse`]. Serialization is unchanged, and
/// a round-trip test pins that the strict reader accepts what the writer emits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[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.
///
/// A plain `bool`, so it needs no explicit null guard: `null` already fails
/// as a type error rather than defaulting to `false`.
#[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.
///
/// This checks only what a response asserts about itself. It cannot tell
/// whether the response answers the request that was sent — for that, and
/// for outcome-field validation, use [`Self::validate_for`]. A client should
/// always prefer `validate_for`.
///
/// # Errors
///
/// Returns [`SdkError::InvalidInput`] on a wrong `type`, an unsupported
/// `protocolVersion`, a malformed `requestId`, an outcome with malformed
/// identifiers, or an error code paired with the wrong status.
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)?;
match &self.result {
BrokerResult::Succeeded { outcome } => outcome.validate()?,
// A forbidden code/status pairing is a response contradicting
// itself; neither half can be trusted, so it is rejected. The
// table lives on `BrokerErrorCode`.
BrokerResult::Failed { error } if !error.code.may_be_failed() => {
return Err(SdkError::InvalidInput(format!(
"{} is not a valid code for a failed status",
error.code.as_str()
)));
}
BrokerResult::Indeterminate { error } if !error.code.may_be_indeterminate() => {
return Err(SdkError::InvalidInput(format!(
"{} is not a valid code for an indeterminate status",
error.code.as_str()
)));
}
BrokerResult::Failed { .. } | BrokerResult::Indeterminate { .. } => {}
}
Ok(())
}
/// Validate this response *as the answer to `request`*.
///
/// A response that validates in isolation can still be the wrong answer —
/// a success for a different action, or for the wrong subject. A client
/// never calls this directly: [`BrokerClientExt::execute`] runs it for
/// every implementation and returns a [`ValidatedResponse`]. It stays
/// public for a host validating its own output. Signature verification of
/// read results is deliberately not included; see [`BrokerMessage::verify`].
///
/// # Errors
///
/// Returns everything [`Self::validate`] returns, plus
/// [`SdkError::InvalidInput`] when the `requestId` does not correlate, a
/// success outcome names a different action than the request, a success
/// outcome echoes a different identity than the request supplied, or a
/// read returned more messages than the request allowed.
///
/// # What identity correlation compares
///
/// Every identity the request supplies and the outcome echoes must name
/// the same thing. Most outcomes echo nothing prior (host-minted ids, or a
/// page with no `channelId` echo; `storage.address` deliberately omits the
/// slug, whose `d` tag is a keyed hash of it). What remains: `agents.create`
/// compares `channelId` as UUIDs; `agents.update`/`agents.delete` compare
/// `agentPubkey` when targeted by pubkey. A name target is resolved
/// host-side and unverifiable by construction — a rename may be the very
/// thing the call performed.
///
/// **Comparison is on parsed identities, never on bytes**: both identity
/// types admit more than one legal spelling, and a byte comparison would
/// reject a correct answer spelled differently — a worse failure than the
/// one this check exists to catch.
pub fn validate_for(&self, request: &PreparedRequest) -> Result<(), SdkError> {
self.validate()?;
if self.request_id != request.request_id() {
return Err(SdkError::InvalidInput(format!(
"response requestId \"{}\" does not match request \"{}\"",
self.request_id,
request.request_id()
)));
}
if let BrokerResult::Succeeded { outcome } = &self.result {
let expected = request.action();
if outcome.action() != expected {
return Err(SdkError::InvalidInput(format!(
"response carries a {} outcome for a {} request",
outcome.action().as_str(),
expected.as_str()
)));
}
correlate::correlate_identities(&request.request.action, outcome)?;
// `ActionOutcome::validate` never sees the request, so it can only
// enforce the protocol-wide cap; the request's own limit is
// applied here, the one place both halves are in scope.
if let (
ActionArgs::ChannelRead(args),
ActionOutcome::ChannelRead(MessagePage { messages, .. }),
) = (&request.request.action, outcome)
{
let allowed = args.effective_limit() as usize;
if messages.len() > allowed {
return Err(SdkError::InvalidInput(format!(
"read returned {} messages for a limit of {allowed}",
messages.len()
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
//! The strict wire form of a [`BrokerResponse`].
//!
//! Split from [`super`] to keep that file within the repo's 1,000-line ceiling.
//! This is the response side's only reader, so it is the one place the envelope's
//! strictness is defined.
use serde::Deserialize;
use super::{absent_or_valued, ActionOutcome, BrokerError, BrokerResponse, BrokerResult};
/// The strict wire form of a [`BrokerResponse`]: every key spelled out, no
/// `flatten`, so `deny_unknown_fields` is actually in force.
///
/// The status-specific members are `Option` only because one struct describes
/// three shapes; the status match below requires the exact set per status.
/// They deserialize through [`absent_or_valued`] because the match reads
/// `None` as *absent*, and plain `#[serde(default)]` would map an explicit
/// `null` to the same `None` — letting a contradictory response like
/// `{"status":"failed","outcome":null}` skip the check.
///
/// `outcome` is held as a `RawValue` and re-parsed, so this reader is
/// JSON-specific — which is fine, JSON is the only encoding this contract has
/// ever specified.
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct WireResponse {
r#type: String,
protocol_version: u16,
request_id: String,
status: String,
#[serde(default, deserialize_with = "absent_or_valued")]
action: Option<String>,
#[serde(default, deserialize_with = "absent_or_valued")]
outcome: Option<Box<serde_json::value::RawValue>>,
#[serde(default, deserialize_with = "absent_or_valued")]
error: Option<BrokerError>,
#[serde(default)]
replayed: bool,
}
impl<'de> Deserialize<'de> for BrokerResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let wire = WireResponse::deserialize(deserializer)?;
// One arm per status, each naming the members that status may carry.
// Anything present that this status does not admit is rejected here, so
// "succeeded with an error" cannot be parsed and then ignored.
let result = match wire.status.as_str() {
"succeeded" => {
if wire.error.is_some() {
return Err(D::Error::custom(
"a succeeded response must not carry an error",
));
}
let action = wire
.action
.ok_or_else(|| D::Error::missing_field("action"))?;
let outcome = wire
.outcome
.ok_or_else(|| D::Error::missing_field("outcome"))?;
// Re-parse the outcome under its action tag from the original
// bytes — not via `serde_json::Value`, which collapses
// duplicate keys last-wins — so each outcome type's
// `deny_unknown_fields` applies to the bytes as sent.
let tagged = format!(
"{{\"action\":{},\"outcome\":{}}}",
serde_json::to_string(&action).map_err(D::Error::custom)?,
outcome.get()
);
let outcome: ActionOutcome =
serde_json::from_str(&tagged).map_err(D::Error::custom)?;
BrokerResult::Succeeded { outcome }
}
status @ ("failed" | "indeterminate") => {
if wire.action.is_some() || wire.outcome.is_some() {
return Err(D::Error::custom(format!(
"a {status} response must not carry an action or outcome"
)));
}
let error = wire.error.ok_or_else(|| D::Error::missing_field("error"))?;
if status == "failed" {
BrokerResult::Failed { error }
} else {
BrokerResult::Indeterminate { error }
}
}
other => {
return Err(D::Error::custom(format!(
"unknown broker result status \"{other}\""
)))
}
};
Ok(Self {
r#type: wire.r#type,
protocol_version: wire.protocol_version,
request_id: wire.request_id,
result,
replayed: wire.replayed,
})
}
}

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;