diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs index 0672013693..16b1a0b726 100644 --- a/crates/buzz-ifc/src/domain.rs +++ b/crates/buzz-ifc/src/domain.rs @@ -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); 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(operations: I) -> Self where I: IntoIterator, @@ -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) -> 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, - /// 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, - /// 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, - /// Optional human owner of the executing agent. + /// Owner of the executing agent, if one is configured. pub owner: Option, } -/// 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, @@ -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, } diff --git a/crates/buzz-ifc/src/label.rs b/crates/buzz-ifc/src/label.rs index 140b3482ba..566b708643 100644 --- a/crates/buzz-ifc/src/label.rs +++ b/crates/buzz-ifc/src/label.rs @@ -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 { value .parse::() @@ -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 { 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; pub(crate) type ReaderSet = ifc_core::ReaderSet; diff --git a/crates/buzz-ifc/src/lib.rs b/crates/buzz-ifc/src/lib.rs index d223893b69..2d164049e0 100644 --- a/crates/buzz-ifc/src/lib.rs +++ b/crates/buzz-ifc/src/lib.rs @@ -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; diff --git a/crates/buzz-ifc/src/tests.rs b/crates/buzz-ifc/src/tests.rs index 1e564423bf..c6e8fbe112 100644 --- a/crates/buzz-ifc/src/tests.rs +++ b/crates/buzz-ifc/src/tests.rs @@ -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::>().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!(