Clarify Buzz IFC domain documentation

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-09-04 14:25:00 -07:00
parent 0cb52d3f83
commit 96c29cd45e
4 changed files with 145 additions and 132 deletions

View File

@@ -6,34 +6,29 @@ use uuid::Uuid;
use crate::label::{CommunityId, ConfidentialityLabel, Principal, ReaderSet};
/// Defines where an agent's retained state may be reused.
/// Which conversations may share an agent's history, files, and caches.
///
/// After a turn, an agent may retain conversation history, files, or cached
/// data. Changing this context must select different retained state. Matching
/// contexts alone do not permit reuse: the broker must match the complete
/// [`DomainKey`], which also covers the agent, audience, membership epoch, and
/// capabilities.
/// Two private channels keep separate state even if they have the same members.
/// Public channels share a community-wide context. A DM between an agent and
/// its owner has a separate owner-private context.
///
/// Audience answers who may read information; context answers which
/// conversation history and managed memory may carry into later turns. These
/// are independent boundaries. Two conversations with identical participants
/// do not implicitly share state, while public conversations deliberately use
/// one community-wide public context. Owner-private state is likewise distinct
/// from ordinary conversation state, even within the same community.
/// A matching context is not enough to reuse state. The broker must compare the
/// full [`DomainKey`], so a change in agent, owner, audience, membership version,
/// or capabilities also prevents reuse.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) enum DomainContext {
/// Shared state for public conversations in one community.
CommunityPublic(CommunityId),
/// State retained for one specific restricted conversation.
/// State kept for one private channel or DM.
Conversation {
/// The Buzz community containing the conversation.
/// The community the conversation belongs to.
community: CommunityId,
/// The channel, DM, or group-DM identifier.
channel_id: Uuid,
},
/// State visible only to the bot owner.
/// State kept for conversations between the agent and its owner.
OwnerPrivate {
/// The Buzz community containing the owner relationship.
/// The community where this agent has this owner.
community: CommunityId,
/// The bot owner.
owner: Principal,
@@ -73,19 +68,17 @@ impl DomainContext {
}
}
/// Whether invoking an operation can publish information outside the current
/// execution boundary.
/// Whether an operation can send information out of the agent's environment.
///
/// This classification belongs to trusted policy configuration, not to the
/// agent's call request. Publication operations require both capability
/// admission and an information-flow decision before the broker may execute
/// them. Concrete destination policy remains the broker's responsibility.
/// The broker configures this; the agent does not get to classify its own calls.
/// For a publication, permission to call the operation is not enough: the broker
/// must also check whether the information may flow to the destination's readers.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationEffect {
/// The operation cannot publish information outside the execution boundary.
/// Does not expose information outside the agent's environment.
NonEgressing,
/// The operation publishes information to a broker-resolved destination.
/// Sends information to a destination the broker must resolve and check.
Publication,
}
@@ -105,10 +98,11 @@ impl OperationEffect {
}
}
/// The complete set of operations admitted for one execution domain.
/// Operations a policy allows, with each operation's publication behavior.
///
/// Raw membership is intentionally private because it is not an authorization
/// decision:
/// An operation's presence in this set does not authorize a call on its own.
/// Publications still need an information-flow check, so there is no public
/// membership check that a caller could mistake for permission to execute:
///
/// ```compile_fail
/// let capabilities = buzz_ifc::CapabilitySet::default();
@@ -118,10 +112,10 @@ impl OperationEffect {
pub struct CapabilitySet(BTreeMap<String, OperationEffect>);
impl CapabilitySet {
/// Build a set from stable operation names and trusted effect classes.
/// Build a set from stable operation names and broker-configured effects.
///
/// If an operation is repeated with different effects, publication wins so
/// conflicting configuration cannot downgrade an egressing operation.
/// If a name appears more than once, keep `Publication` if any entry uses it.
/// A duplicate must not remove the requirement to check the destination.
pub fn from_operations<I, S>(operations: I) -> Self
where
I: IntoIterator<Item = (S, OperationEffect)>,
@@ -137,15 +131,10 @@ impl CapabilitySet {
Self(capabilities)
}
/// Compute the operations admitted by every independent capability
/// ceiling: the bot, the authenticated requester, and the execution
/// domain.
/// Keep only operations allowed by the bot, requester, and domain policies.
///
/// An operation missing from any ceiling is denied. When the same
/// operation has conflicting effect classifications, the result preserves
/// the classification that requires more checking: publication wins over
/// non-egressing. No policy layer can accidentally downgrade an egressing
/// operation into an unchecked call.
/// All three must list an operation for it to survive. If any marks it as a
/// publication, the result does too, even if the others mark it non-egressing.
pub(crate) fn effective(bot: &Self, requester: &Self, domain: &Self) -> Self {
let mut effective = BTreeMap::new();
for (name, bot_effect) in &bot.0 {
@@ -173,7 +162,11 @@ impl CapabilitySet {
}
}
/// Capability ceilings used while deriving an invocation's effective set.
/// Limits on the operations available to an agent.
///
/// `bot` lists everything the agent may use. An owner-private DM uses this set
/// when every request comes from the owner. All other work is limited to
/// operations that also appear in `conversation`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CapabilityPolicy {
bot: CapabilitySet,
@@ -181,103 +174,106 @@ pub struct CapabilityPolicy {
}
impl CapabilityPolicy {
/// Construct policy from the bot's full ceiling and the ceiling permitted
/// in shared Buzz conversations.
/// Set the bot's allowed operations and the narrower set for shared
/// conversations. Entries in `conversation` cannot grant anything absent
/// from `bot`.
pub fn new(bot: CapabilitySet, conversation: CapabilitySet) -> Self {
Self { bot, conversation }
}
}
/// The membership or policy version under which state was created.
/// Version of the membership or access policy used to derive a domain.
///
/// The broker must change this value when the relevant membership or policy
/// changes. It becomes part of the domain key, preventing the broker from
/// selecting old state with the new key. This crate does not check freshness.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct MembershipEpoch(String);
impl MembershipEpoch {
/// Construct an epoch from a stable, verifier-controlled identifier.
/// Use a broker-verified version, such as a membership event ID.
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
/// Buzz conversation classification after signed metadata verification.
/// Conversation type read from metadata the broker has verified.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConversationKind {
/// Community-wide public channel. All public channels intentionally share
/// one execution domain.
/// A public channel, using the community's shared public context.
Public,
/// Invite-only channel or group DM with conversation-specific state.
/// A private channel or group DM that keeps its own state.
Restricted,
/// A DM. A two-party owner/bot DM becomes owner-private; group DMs remain
/// conversation-specific.
/// A DM. If its only members are the agent and its owner, use owner-private
/// context. Otherwise, keep state specific to this DM.
DirectMessage,
}
/// Verified Buzz facts from which the shared policy derives an execution
/// domain.
/// Inputs the broker must authenticate before deriving an execution domain.
///
/// The trusted broker constructs this only after checking trigger signatures,
/// channel binding, and the relay signature on metadata and membership.
/// Before constructing this value, the broker must verify the triggering
/// events' signatures and channel IDs, and the relay signatures on conversation
/// metadata and membership. The fields are public; constructing this struct
/// does not perform those checks.
pub struct DomainFacts {
/// Community selected by the trusted broker's resolved tenant context.
/// Community resolved by the broker, not chosen by the agent.
pub community: CommunityId,
/// Channel, DM, or group-DM identifier that triggered the invocation.
pub channel_id: Uuid,
/// Verified conversation classification.
/// Conversation type from verified metadata.
pub kind: ConversationKind,
/// Relay-controlled membership or community policy version.
/// Current membership or community policy version supplied by the relay.
pub epoch: MembershipEpoch,
/// Complete verified roster. Public derivation does not consume this set.
/// Complete verified member list, including the agent. Ignored for public
/// channels, whose audience is the whole community.
pub members: BTreeSet<Principal>,
/// Managed Buzz identity whose work the execution domain contains.
/// Agent processing the invocation. Removed from the member list when
/// deriving the audience: processing data does not make the agent a recipient.
pub executing_agent: Principal,
/// Authors whose events are included in this invocation.
/// Authors of the events being processed in this invocation.
pub requesters: BTreeSet<Principal>,
/// Optional relay principal allowed to author trusted workflow events.
/// Relay identity that may trigger workflows without being a channel member.
/// This exception does not add it to the audience or grant owner rights.
pub system_principal: Option<Principal>,
/// Optional human owner of the executing agent.
/// Owner of the executing agent, if one is configured.
pub owner: Option<Principal>,
}
/// Domain derivation failed despite the broker's claim that its facts were
/// already verified.
/// The supplied facts cannot form a valid execution domain.
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub enum DerivationError {
/// Every invocation must contain at least one authenticated requester.
/// No authenticated requester was supplied.
#[error("invocation has no authenticated requester")]
EmptyRequesters,
/// Restricted conversations must include the executing agent in their
/// verified roster.
/// The executing agent is missing from the private conversation's member list.
#[error("executing agent is absent from channel membership")]
AgentNotMember,
/// A non-system requester is absent from restricted membership.
/// A requester other than the trusted relay identity is not a member.
#[error("requester is absent from channel membership")]
RequesterNotMember,
/// Removing the executing processor left no authorized recipient.
/// No recipients remain after removing the executing agent from the members.
#[error("restricted conversation has no recipient audience")]
EmptyRestrictedAudience,
/// The derived audience and context violated a domain invariant.
/// The audience and context disagree on the community or permitted readers.
#[error("derived execution domain is inconsistent")]
InvalidDomain,
}
/// Derive a complete execution domain from verified Buzz facts.
/// Choose the audience, saved-state context, and allowed operations for a turn.
///
/// The trusted broker supplies authenticated requesters, verified conversation
/// membership, and a relay-controlled membership epoch; the agent cannot
/// assert any of these values. Public conversations receive the community-wide
/// audience and shared public context. Restricted conversations receive the
/// verified member audience, excluding the executing agent, and retain state
/// only for their channel. A two-party owner/agent DM instead receives the
/// owner's private context. Effective capabilities are the intersection of
/// the bot, requester, and context ceilings.
/// Public channels use the whole community as their audience and share a public
/// context. Private channels and DMs use their members, minus the executing
/// agent, as readers and keep state per conversation. A DM containing only the
/// agent and its owner uses owner-private context instead.
///
/// Agent identity, owner, audience, context, epoch, and capabilities all feed
/// the domain identifier used to select retained agent state. A change to any
/// component therefore selects a different domain rather than silently reusing
/// state that may contain information admitted under older authority. A broker
/// can use the resulting [`DomainKey`] when it selects an ACP or remote-agent
/// session.
/// When the context is owner-private and every requester is the owner, use the
/// bot's capability set. Otherwise, keep only operations allowed by both the bot
/// and conversation policies, even if the owner made the request.
///
/// The broker must supply verified [`DomainFacts`] and use the resulting
/// [`DomainKey`] to select saved state. This function does not load or clear
/// sessions itself.
pub fn derive_execution_domain(
facts: DomainFacts,
policy: &CapabilityPolicy,
@@ -359,22 +355,31 @@ fn effective_capabilities(
CapabilitySet::effective(&policy.bot, requester, domain)
}
/// Opaque routing key for one complete execution domain.
/// Key the broker uses to select an agent's saved state.
///
/// Compare the full key when deciding whether to reuse a session. Matching only
/// a channel or member list would miss changes in owner, policy, or capabilities.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct DomainKey(String);
impl DomainKey {
/// Return the full stable identifier used to route turns to retained state.
/// Return the key as a hexadecimal string for storage or lookup.
pub fn as_str(&self) -> &str {
&self.0
}
}
/// `D = (Agent, Owner, Audience, Context, Epoch, Capabilities)`.
/// The identity, readers, context, and policy for an agent invocation.
///
/// This is the executable form of the domain model in [Appendix B of the
/// design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-b-formal-execution-domains).
/// The audience says who may receive information. The context says which
/// conversations may share saved state. The epoch records the membership or
/// policy version, and the capabilities list the allowed operations. Agent and
/// owner identities keep different agents and ownership relationships separate.
///
/// This extends the domain model in
/// [Appendix B of the design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-b-formal-execution-domains)
/// by including the owner and capabilities in the key as well.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExecutionDomain {
agent: Principal,
@@ -386,7 +391,7 @@ pub struct ExecutionDomain {
}
impl ExecutionDomain {
/// Construct a domain after the trusted broker has resolved its inputs.
/// Check that the audience and context agree on community and readers.
pub(crate) fn new(
agent: Principal,
owner: Option<Principal>,
@@ -411,7 +416,10 @@ impl ExecutionDomain {
})
}
/// Return the opaque key used to route turns to retained state.
/// Hash every domain field into a stable key for session lookup.
///
/// Changing any field changes the key. The broker must use this complete key
/// so it cannot reuse a session that has seen data under a different policy.
pub fn key(&self) -> DomainKey {
let mut hasher = Sha256::new();
hasher.update(b"buzz-ifc-domain-v6");
@@ -465,14 +473,14 @@ fn audience_context_shape_matches(
}
}
/// An execution domain contains inconsistent communities.
/// The audience and context cannot be used together.
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum DomainError {
/// The audience and retained context belong to different Buzz communities.
/// The audience and context belong to different communities.
#[error("execution-domain audience and context belong to different communities")]
ContextCommunityMismatch,
/// Public, conversation, and owner-private contexts require their
/// corresponding audience shape.
/// Public context needs a public audience; conversation context needs a
/// nonempty reader set; owner-private context needs exactly the owner.
#[error("execution-domain audience does not match its context")]
AudienceContextMismatch,
}

View File

@@ -3,13 +3,16 @@ pub use ifc_core::LabelError;
use nostr::{secp256k1::XOnlyPublicKey, PublicKey};
use serde::Serialize;
/// A Buzz principal represented by a validated, normalized Nostr public key.
/// A person, agent, or relay identified by a valid Nostr public key.
///
/// The key is validated and stored in binary form. Hexadecimal case does not
/// affect equality or domain keys.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct Principal(XOnlyPublicKey);
impl Principal {
/// Parse and normalize a hexadecimal Nostr public key.
/// Parse a hexadecimal Nostr public key, rejecting invalid curve points.
pub fn from_hex(value: &str) -> Result<Self, PrincipalError> {
value
.parse::<XOnlyPublicKey>()
@@ -17,11 +20,10 @@ impl Principal {
.map_err(|_| PrincipalError::InvalidPublicKey)
}
/// Validate and convert a Nostr public key.
/// Convert a Nostr key after checking that it is a valid curve point.
///
/// PublicKey can hold any 32-byte value, including values that are not
/// valid x-only secp256k1 points. IFC identities must reject those values
/// before they enter reader sets or domain keys.
/// `PublicKey` can hold 32 bytes that do not represent an x-only secp256k1
/// point. Reject those values before using them as reader identities.
pub fn from_public_key(value: &PublicKey) -> Result<Self, PrincipalError> {
let key = value
.xonly()
@@ -29,7 +31,7 @@ impl Principal {
Ok(Self(key))
}
/// Return the normalized hexadecimal public key.
/// Return the public key as lowercase hexadecimal.
pub fn to_hex(&self) -> String {
self.0.to_string()
}
@@ -39,7 +41,7 @@ impl Principal {
}
}
/// A principal could not be constructed from the supplied key.
/// Invalid Nostr public-key input.
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PrincipalError {
/// The value is not a valid Nostr public key.
@@ -47,7 +49,10 @@ pub enum PrincipalError {
InvalidPublicKey,
}
/// A reader-set confidentiality label within one Buzz community.
/// Who may read a value in one Buzz community.
///
/// A public label allows everyone in that community; a restricted label names
/// the allowed readers. Labels from different communities cannot be combined.
pub type ConfidentialityLabel = ifc_core::ConfidentialityLabel<CommunityId, Principal>;
pub(crate) type ReaderSet = ifc_core::ReaderSet<Principal>;

View File

@@ -1,10 +1,14 @@
//! Buzz-specific execution domains built on `ifc-core`.
//! Derives an agent's execution domain from Buzz membership and capability policy.
//!
//! The trusted broker verifies events, conversation metadata, membership, and
//! requester identities before constructing [`DomainFacts`]. This crate
//! deterministically derives an [`ExecutionDomain`] from those facts. The
//! domain binds the executing agent, authorized audience, retained context,
//! membership epoch, and effective capabilities into one stable routing key.
//! An [`ExecutionDomain`] records which agent is running, who may receive its
//! output, which conversations may share its saved state, and which operations
//! it may use. Its [`DomainKey`] includes all of those decisions, plus the owner
//! and membership version, so a change produces a different key.
//!
//! The broker must verify events and membership before supplying [`DomainFacts`].
//! It must also use the resulting key when selecting a session. This crate
//! computes the domain; it does not verify signatures, manage sessions, or
//! enforce tool calls.
mod domain;
mod label;

View File

@@ -54,9 +54,8 @@ fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain
.expect("coherent conversation domain")
}
/// Reader identities enter the IFC lattice and domain key, so accepting an
/// arbitrary 32-byte string would let malformed identities become distinct
/// policy principals.
/// A correctly sized hex string is not necessarily a public key. Reject invalid
/// curve points, and check that uppercase input returns the same lowercase key.
#[test]
fn principals_must_be_valid_x_only_secp256k1_points() {
for invalid in ["00".repeat(32), "ff".repeat(32), format!("{:064x}", 5)] {
@@ -75,9 +74,8 @@ fn principals_must_be_valid_x_only_secp256k1_points() {
);
}
/// The Buzz specialization must preserve the generic reader-set lattice: the
/// combined label may flow only to readers authorized for every input, and it
/// must never cross communities.
/// Combining data for readers {1, 2} with data for {1, 3} leaves only reader 1.
/// Also check that even public data cannot flow into a different community.
#[test]
fn buzz_labels_intersect_readers_and_never_cross_communities() {
let combined = label(&[1, 2])
@@ -94,9 +92,8 @@ fn buzz_labels_intersect_readers_and_never_cross_communities() {
);
}
/// Every capability ceiling must admit an operation. Conflicting effect
/// declarations keep the classification that requires more checking, so an
/// egressing operation cannot become an ordinary call.
/// Only `buzz.post` appears in all three policies. It must remain a publication
/// even though one policy says otherwise, or the destination check could be lost.
#[test]
fn capability_intersection_keeps_the_most_restrictive_effect() {
let bot = CapabilitySet::from_operations([
@@ -115,9 +112,9 @@ fn capability_intersection_keeps_the_most_restrictive_effect() {
);
}
/// Appendix B's domain mapping gives personal capabilities only to a genuine
/// two-party owner/agent DM. Shared conversations must use the narrower
/// conversation ceiling even when the owner initiated the work.
/// With this policy, an owner's DM can use `email.read`; a public-channel request
/// from the same owner cannot. Check the audience and context too, so personal
/// capabilities cannot be paired with public state.
#[test]
fn derivation_grants_personal_capabilities_only_to_owner_private_work() {
let owner = principal(1);
@@ -177,8 +174,8 @@ fn derivation_grants_personal_capabilities_only_to_owner_private_work() {
assert_eq!(public.capabilities, non_egressing(["buzz.read.current"]));
}
/// Restricted-domain derivation fails closed when authenticated requesters or
/// the executing agent are absent from the verified membership snapshot.
/// Reject a private-channel invocation with no requester, a missing agent, or
/// an outside requester. Knowing the channel ID is not enough to act in it.
#[test]
fn restricted_derivation_requires_verified_membership() {
let owner = principal(1);
@@ -220,9 +217,9 @@ fn restricted_derivation_requires_verified_membership() {
);
}
/// The domain identifier is a security boundary for retained-state reuse. Every
/// field in `D = (agent, owner, audience, context, epoch, capabilities)` must
/// change the routing key independently.
/// Change each of the six domain fields separately and require a different key.
/// Omitting one from the hash could reuse history from a different agent, owner,
/// audience, conversation, membership version, or capability set.
#[test]
fn every_domain_component_changes_the_routing_key() {
let base = conversation(&[1, 2], Uuid::from_u128(1), "v1");
@@ -301,8 +298,8 @@ fn every_domain_component_changes_the_routing_key() {
assert_eq!(ids.iter().collect::<BTreeSet<_>>().len(), ids.len());
}
/// This fixed value detects accidental changes to the canonical domain-key
/// encoding, which would otherwise strand or incorrectly reuse retained state.
/// Keep the same key for the same domain across code changes. An accidental
/// encoding change would break lookups of state saved under the old key.
#[test]
fn domain_key_has_a_canonical_golden_value() {
let domain = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1");
@@ -312,9 +309,8 @@ fn domain_key_has_a_canonical_golden_value() {
);
}
/// Audience shape and retained context are independent inputs but must remain
/// coherent: public labels select public context, while restricted labels
/// select a conversation or exact owner-private context.
/// A public audience cannot be paired with another community's context or with
/// private conversation state. Check both errors at the domain constructor.
#[test]
fn domains_reject_incoherent_audience_context_pairs() {
assert_eq!(