From 0cb52d3f83ba09a38dbc09a2d2dc74055e288feb Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Fri, 4 Sep 2026 13:31:54 -0700 Subject: [PATCH] Add Buzz IFC execution domains Signed-off-by: Jordan Mecom --- Cargo.lock | 14 + Cargo.toml | 3 + crates/buzz-ifc/Cargo.toml | 18 ++ crates/buzz-ifc/src/domain.rs | 478 ++++++++++++++++++++++++++++++++++ crates/buzz-ifc/src/label.rs | 53 ++++ crates/buzz-ifc/src/lib.rs | 19 ++ crates/buzz-ifc/src/tests.rs | 345 ++++++++++++++++++++++++ 7 files changed, 930 insertions(+) create mode 100644 crates/buzz-ifc/Cargo.toml create mode 100644 crates/buzz-ifc/src/domain.rs create mode 100644 crates/buzz-ifc/src/label.rs create mode 100644 crates/buzz-ifc/src/lib.rs create mode 100644 crates/buzz-ifc/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b4986b67b4..078d41b990 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,6 +1269,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "buzz-ifc" +version = "0.1.0" +dependencies = [ + "buzz-core", + "hex", + "ifc-core", + "nostr 0.44.7", + "serde", + "sha2 0.11.0", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "buzz-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d27c63d234..d7df3364a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/buzz-relay", "crates/buzz-core", "crates/ifc-core", + "crates/buzz-ifc", "crates/buzz-conformance", "crates/buzz-push-gateway", "crates/buzz-db", @@ -140,6 +141,8 @@ schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } +ifc-core = { path = "crates/ifc-core" } +buzz-ifc = { path = "crates/buzz-ifc" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-deletion = { path = "crates/buzz-deletion" } diff --git a/crates/buzz-ifc/Cargo.toml b/crates/buzz-ifc/Cargo.toml new file mode 100644 index 0000000000..d34b57059a --- /dev/null +++ b/crates/buzz-ifc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "buzz-ifc" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Buzz execution-domain derivation for information-flow control" + +[dependencies] +buzz-core = { workspace = true } +hex = { workspace = true } +ifc-core = { workspace = true } +nostr = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs new file mode 100644 index 0000000000..0672013693 --- /dev/null +++ b/crates/buzz-ifc/src/domain.rs @@ -0,0 +1,478 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::label::{CommunityId, ConfidentialityLabel, Principal, ReaderSet}; + +/// Defines where an agent's retained state may be reused. +/// +/// 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. +/// +/// 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. +#[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. + Conversation { + /// The Buzz community containing the conversation. + community: CommunityId, + /// The channel, DM, or group-DM identifier. + channel_id: Uuid, + }, + /// State visible only to the bot owner. + OwnerPrivate { + /// The Buzz community containing the owner relationship. + community: CommunityId, + /// The bot owner. + owner: Principal, + }, +} + +impl DomainContext { + /// Return the community containing this context. + fn community(&self) -> CommunityId { + match self { + Self::CommunityPublic(community) + | Self::Conversation { community, .. } + | Self::OwnerPrivate { community, .. } => *community, + } + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + match self { + Self::CommunityPublic(community) => { + hash_field(hasher, b"community-public"); + hash_field(hasher, community.as_uuid().as_bytes()); + } + Self::Conversation { + community, + channel_id, + } => { + hash_field(hasher, b"conversation"); + hash_field(hasher, community.as_uuid().as_bytes()); + hash_field(hasher, channel_id.as_bytes()); + } + Self::OwnerPrivate { community, owner } => { + hash_field(hasher, b"owner-private"); + hash_field(hasher, community.as_uuid().as_bytes()); + hash_field(hasher, &owner.to_bytes()); + } + } + } +} + +/// Whether invoking an operation can publish information outside the current +/// execution boundary. +/// +/// 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. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationEffect { + /// The operation cannot publish information outside the execution boundary. + NonEgressing, + /// The operation publishes information to a broker-resolved destination. + Publication, +} + +impl OperationEffect { + fn most_restrictive(self, other: Self) -> Self { + match (self, other) { + (Self::NonEgressing, Self::NonEgressing) => Self::NonEgressing, + (Self::Publication, _) | (_, Self::Publication) => Self::Publication, + } + } + + fn stable_hash(self, hasher: &mut Sha256) { + match self { + Self::NonEgressing => hash_field(hasher, b"non-egressing"), + Self::Publication => hash_field(hasher, b"publication"), + } + } +} + +/// The complete set of operations admitted for one execution domain. +/// +/// Raw membership is intentionally private because it is not an authorization +/// decision: +/// +/// ```compile_fail +/// let capabilities = buzz_ifc::CapabilitySet::default(); +/// let _ = capabilities.contains("buzz.post"); +/// ``` +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CapabilitySet(BTreeMap); + +impl CapabilitySet { + /// Build a set from stable operation names and trusted effect classes. + /// + /// If an operation is repeated with different effects, publication wins so + /// conflicting configuration cannot downgrade an egressing operation. + pub fn from_operations(operations: I) -> Self + where + I: IntoIterator, + S: Into, + { + let mut capabilities: BTreeMap = BTreeMap::new(); + for (name, effect) in operations { + capabilities + .entry(name.into()) + .and_modify(|existing| *existing = existing.most_restrictive(effect)) + .or_insert(effect); + } + Self(capabilities) + } + + /// Compute the operations admitted by every independent capability + /// ceiling: the bot, the authenticated requester, and the execution + /// domain. + /// + /// 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. + pub(crate) fn effective(bot: &Self, requester: &Self, domain: &Self) -> Self { + let mut effective = BTreeMap::new(); + for (name, bot_effect) in &bot.0 { + let (Some(requester_effect), Some(domain_effect)) = + (requester.0.get(name), domain.0.get(name)) + else { + continue; + }; + effective.insert( + name.clone(), + bot_effect + .most_restrictive(*requester_effect) + .most_restrictive(*domain_effect), + ); + } + Self(effective) + } + + fn stable_hash(&self, hasher: &mut Sha256) { + hash_field(hasher, &(self.0.len() as u64).to_be_bytes()); + for (name, effect) in &self.0 { + hash_field(hasher, name.as_bytes()); + effect.stable_hash(hasher); + } + } +} + +/// Capability ceilings used while deriving an invocation's effective set. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityPolicy { + bot: CapabilitySet, + conversation: CapabilitySet, +} + +impl CapabilityPolicy { + /// Construct policy from the bot's full ceiling and the ceiling permitted + /// in shared Buzz conversations. + pub fn new(bot: CapabilitySet, conversation: CapabilitySet) -> Self { + Self { bot, conversation } + } +} + +/// The membership or policy version under which state was created. +#[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. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } +} + +/// Buzz conversation classification after signed metadata verification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConversationKind { + /// Community-wide public channel. All public channels intentionally share + /// one execution domain. + Public, + /// Invite-only channel or group DM with conversation-specific state. + Restricted, + /// A DM. A two-party owner/bot DM becomes owner-private; group DMs remain + /// conversation-specific. + DirectMessage, +} + +/// Verified Buzz facts from which the shared policy derives an execution +/// domain. +/// +/// The trusted broker constructs this only after checking trigger signatures, +/// channel binding, and the relay signature on metadata and membership. +pub struct DomainFacts { + /// Community selected by the trusted broker's resolved tenant context. + pub community: CommunityId, + /// Channel, DM, or group-DM identifier that triggered the invocation. + pub channel_id: Uuid, + /// Verified conversation classification. + pub kind: ConversationKind, + /// Relay-controlled membership or community policy version. + pub epoch: MembershipEpoch, + /// Complete verified roster. Public derivation does not consume this set. + pub members: BTreeSet, + /// Managed Buzz identity whose work the execution domain contains. + pub executing_agent: Principal, + /// Authors whose events are included in this invocation. + pub requesters: BTreeSet, + /// Optional relay principal allowed to author trusted workflow events. + pub system_principal: Option, + /// Optional human owner of the executing agent. + pub owner: Option, +} + +/// Domain derivation failed despite the broker's claim that its facts were +/// already verified. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DerivationError { + /// Every invocation must contain at least one authenticated requester. + #[error("invocation has no authenticated requester")] + EmptyRequesters, + /// Restricted conversations must include the executing agent in their + /// verified roster. + #[error("executing agent is absent from channel membership")] + AgentNotMember, + /// A non-system requester is absent from restricted membership. + #[error("requester is absent from channel membership")] + RequesterNotMember, + /// Removing the executing processor left no authorized recipient. + #[error("restricted conversation has no recipient audience")] + EmptyRestrictedAudience, + /// The derived audience and context violated a domain invariant. + #[error("derived execution domain is inconsistent")] + InvalidDomain, +} + +/// Derive a complete execution domain from verified Buzz facts. +/// +/// 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. +/// +/// 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. +pub fn derive_execution_domain( + facts: DomainFacts, + policy: &CapabilityPolicy, +) -> Result { + if facts.requesters.is_empty() { + return Err(DerivationError::EmptyRequesters); + } + + let (audience, context) = match facts.kind { + ConversationKind::Public => ( + ConfidentialityLabel::public(facts.community), + DomainContext::CommunityPublic(facts.community), + ), + ConversationKind::Restricted | ConversationKind::DirectMessage => { + if !facts.members.contains(&facts.executing_agent) { + return Err(DerivationError::AgentNotMember); + } + if facts.requesters.iter().any(|requester| { + facts.system_principal.as_ref() != Some(requester) + && !facts.members.contains(requester) + }) { + return Err(DerivationError::RequesterNotMember); + } + + let mut readers = facts.members.clone(); + readers.remove(&facts.executing_agent); + let context = match &facts.owner { + Some(owner) + if facts.kind == ConversationKind::DirectMessage + && readers.len() == 1 + && readers.contains(owner) => + { + DomainContext::OwnerPrivate { + community: facts.community, + owner: *owner, + } + } + _ => DomainContext::Conversation { + community: facts.community, + channel_id: facts.channel_id, + }, + }; + let audience = ConfidentialityLabel::restricted(facts.community, readers) + .map_err(|_| DerivationError::EmptyRestrictedAudience)?; + (audience, context) + } + }; + let capabilities = effective_capabilities(&context, &facts, policy); + ExecutionDomain::new( + facts.executing_agent, + facts.owner, + audience, + context, + facts.epoch, + capabilities, + ) + .map_err(|_| DerivationError::InvalidDomain) +} + +fn effective_capabilities( + context: &DomainContext, + facts: &DomainFacts, + policy: &CapabilityPolicy, +) -> CapabilitySet { + let requester_is_owner = facts + .owner + .as_ref() + .is_some_and(|owner| facts.requesters.iter().all(|requester| requester == owner)); + let requester = if requester_is_owner { + &policy.bot + } else { + &policy.conversation + }; + let domain = if matches!(context, DomainContext::OwnerPrivate { .. }) { + &policy.bot + } else { + &policy.conversation + }; + CapabilitySet::effective(&policy.bot, requester, domain) +} + +/// Opaque routing key for one complete execution domain. +#[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. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// `D = (Agent, Owner, Audience, Context, Epoch, Capabilities)`. +/// +/// 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). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionDomain { + agent: Principal, + owner: Option, + pub(crate) audience: ConfidentialityLabel, + pub(crate) context: DomainContext, + pub(crate) epoch: MembershipEpoch, + pub(crate) capabilities: CapabilitySet, +} + +impl ExecutionDomain { + /// Construct a domain after the trusted broker has resolved its inputs. + pub(crate) fn new( + agent: Principal, + owner: Option, + audience: ConfidentialityLabel, + context: DomainContext, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Result { + if *audience.universe() != context.community() { + return Err(DomainError::ContextCommunityMismatch); + } + if !audience_context_shape_matches(&audience, &context) { + return Err(DomainError::AudienceContextMismatch); + } + Ok(Self { + agent, + owner, + audience, + context, + epoch, + capabilities, + }) + } + + /// Return the opaque key used to route turns to retained state. + pub fn key(&self) -> DomainKey { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-ifc-domain-v6"); + hash_field(&mut hasher, &self.agent.to_bytes()); + match &self.owner { + Some(owner) => { + hash_field(&mut hasher, b"owner"); + hash_field(&mut hasher, &owner.to_bytes()); + } + None => hash_field(&mut hasher, b"no-owner"), + } + hash_field(&mut hasher, self.audience.universe().as_uuid().as_bytes()); + hash_reader_set(self.audience.reader_set(), &mut hasher); + self.context.stable_hash(&mut hasher); + hash_field(&mut hasher, self.epoch.0.as_bytes()); + self.capabilities.stable_hash(&mut hasher); + DomainKey(hex::encode(hasher.finalize())) + } +} + +fn hash_reader_set(readers: &ReaderSet, hasher: &mut Sha256) { + match readers { + ReaderSet::Everyone => hash_field(hasher, b"everyone"), + ReaderSet::Only(readers) => { + hash_field(hasher, b"only"); + hash_field(hasher, &(readers.len() as u64).to_be_bytes()); + for reader in readers { + hash_field(hasher, &reader.to_bytes()); + } + } + } +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + let length = u64::try_from(value.len()).unwrap_or(u64::MAX); + hasher.update(length.to_be_bytes()); + hasher.update(value); +} + +fn audience_context_shape_matches( + audience: &ConfidentialityLabel, + context: &DomainContext, +) -> bool { + match (audience.reader_set(), context) { + (ReaderSet::Everyone, DomainContext::CommunityPublic(_)) => true, + (ReaderSet::Only(readers), DomainContext::Conversation { .. }) => !readers.is_empty(), + (ReaderSet::Only(readers), DomainContext::OwnerPrivate { owner, .. }) => { + readers.len() == 1 && readers.contains(owner) + } + _ => false, + } +} + +/// An execution domain contains inconsistent communities. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum DomainError { + /// The audience and retained context belong to different Buzz communities. + #[error("execution-domain audience and context belong to different communities")] + ContextCommunityMismatch, + /// Public, conversation, and owner-private contexts require their + /// corresponding audience shape. + #[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 new file mode 100644 index 0000000000..140b3482ba --- /dev/null +++ b/crates/buzz-ifc/src/label.rs @@ -0,0 +1,53 @@ +pub use buzz_core::CommunityId; +pub use ifc_core::LabelError; +use nostr::{secp256k1::XOnlyPublicKey, PublicKey}; +use serde::Serialize; + +/// A Buzz principal represented by a validated, normalized Nostr public key. +#[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. + pub fn from_hex(value: &str) -> Result { + value + .parse::() + .map(Self) + .map_err(|_| PrincipalError::InvalidPublicKey) + } + + /// Validate and convert a Nostr public key. + /// + /// 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. + pub fn from_public_key(value: &PublicKey) -> Result { + let key = value + .xonly() + .map_err(|_| PrincipalError::InvalidPublicKey)?; + Ok(Self(key)) + } + + /// Return the normalized hexadecimal public key. + pub fn to_hex(&self) -> String { + self.0.to_string() + } + + pub(crate) fn to_bytes(self) -> [u8; 32] { + self.0.serialize() + } +} + +/// A principal could not be constructed from the supplied key. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PrincipalError { + /// The value is not a valid Nostr public key. + #[error("invalid Nostr public key")] + InvalidPublicKey, +} + +/// A reader-set confidentiality label within one Buzz community. +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 new file mode 100644 index 0000000000..d223893b69 --- /dev/null +++ b/crates/buzz-ifc/src/lib.rs @@ -0,0 +1,19 @@ +//! Buzz-specific execution domains built on `ifc-core`. +//! +//! 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. + +mod domain; +mod label; + +pub use domain::{ + derive_execution_domain, CapabilityPolicy, CapabilitySet, ConversationKind, DerivationError, + DomainFacts, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect, +}; +pub use label::{CommunityId, ConfidentialityLabel, LabelError, Principal, PrincipalError}; + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-ifc/src/tests.rs b/crates/buzz-ifc/src/tests.rs new file mode 100644 index 0000000000..1e564423bf --- /dev/null +++ b/crates/buzz-ifc/src/tests.rs @@ -0,0 +1,345 @@ +use std::collections::BTreeSet; + +use nostr::Keys; +use uuid::Uuid; + +use super::*; +use crate::domain::{DomainContext, DomainError}; + +fn principal(value: u8) -> Principal { + let keys = Keys::parse(&format!("{value:064x}")).expect("test secret key"); + Principal::from_public_key(&keys.public_key()).expect("valid test principal") +} + +fn readers(values: &[u8]) -> BTreeSet { + values.iter().copied().map(principal).collect() +} + +fn community() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(1)) +} + +fn other_community() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(2)) +} + +fn label(values: &[u8]) -> ConfidentialityLabel { + ConfidentialityLabel::restricted(community(), readers(values)).expect("non-empty readers") +} + +fn non_egressing(names: [&str; N]) -> CapabilitySet { + CapabilitySet::from_operations(names.map(|name| (name, OperationEffect::NonEgressing))) +} + +fn conversation_capabilities() -> CapabilitySet { + CapabilitySet::from_operations([ + ("buzz.read.current", OperationEffect::NonEgressing), + ("buzz.post", OperationEffect::Publication), + ("buzz.reply", OperationEffect::Publication), + ]) +} + +fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain { + ExecutionDomain::new( + principal(9), + Some(principal(1)), + label(values), + DomainContext::Conversation { + community: community(), + channel_id, + }, + MembershipEpoch::new(epoch), + conversation_capabilities(), + ) + .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. +#[test] +fn principals_must_be_valid_x_only_secp256k1_points() { + for invalid in ["00".repeat(32), "ff".repeat(32), format!("{:064x}", 5)] { + assert_eq!( + Principal::from_hex(&invalid), + Err(PrincipalError::InvalidPublicKey) + ); + } + + let lowercase = principal(1).to_hex(); + assert_eq!( + Principal::from_hex(&lowercase.to_ascii_uppercase()) + .expect("uppercase hexadecimal principal") + .to_hex(), + lowercase + ); +} + +/// 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. +#[test] +fn buzz_labels_intersect_readers_and_never_cross_communities() { + let combined = label(&[1, 2]) + .join(&label(&[1, 3])) + .expect("same community"); + assert!(combined.can_flow_to(&label(&[1]))); + assert!(!combined.can_flow_to(&label(&[1, 2]))); + + let other = ConfidentialityLabel::public(other_community()); + assert!(!ConfidentialityLabel::public(community()).can_flow_to(&other)); + assert_eq!( + ConfidentialityLabel::public(community()).join(&other), + Err(LabelError::CrossUniverse) + ); +} + +/// 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. +#[test] +fn capability_intersection_keeps_the_most_restrictive_effect() { + let bot = CapabilitySet::from_operations([ + ("buzz.post", OperationEffect::Publication), + ("email.read", OperationEffect::NonEgressing), + ]); + let requester = CapabilitySet::from_operations([("buzz.post", OperationEffect::Publication)]); + let domain = CapabilitySet::from_operations([ + ("buzz.post", OperationEffect::NonEgressing), + ("drive.read", OperationEffect::NonEgressing), + ]); + + assert_eq!( + CapabilitySet::effective(&bot, &requester, &domain), + CapabilitySet::from_operations([("buzz.post", OperationEffect::Publication)]) + ); +} + +/// 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. +#[test] +fn derivation_grants_personal_capabilities_only_to_owner_private_work() { + let owner = principal(1); + let agent = principal(9); + let policy = CapabilityPolicy::new( + non_egressing(["buzz.read.current", "email.read"]), + non_egressing(["buzz.read.current"]), + ); + let owner_dm = derive_execution_domain( + DomainFacts { + community: community(), + channel_id: Uuid::from_u128(1), + kind: ConversationKind::DirectMessage, + epoch: MembershipEpoch::new("membership:v1"), + members: BTreeSet::from([agent, owner]), + executing_agent: agent, + requesters: BTreeSet::from([owner]), + system_principal: None, + owner: Some(owner), + }, + &policy, + ) + .expect("owner DM domain"); + assert!(matches!( + &owner_dm.context, + DomainContext::OwnerPrivate { + owner: candidate, + .. + } if candidate == &owner + )); + assert_eq!( + owner_dm.capabilities, + non_egressing(["buzz.read.current", "email.read"]) + ); + assert_eq!(owner_dm.audience, label(&[1])); + + let public = derive_execution_domain( + DomainFacts { + community: community(), + channel_id: Uuid::from_u128(2), + kind: ConversationKind::Public, + epoch: MembershipEpoch::new("community:v1"), + members: BTreeSet::new(), + executing_agent: agent, + requesters: BTreeSet::from([owner]), + system_principal: None, + owner: Some(owner), + }, + &policy, + ) + .expect("public domain"); + assert_eq!( + &public.context, + &DomainContext::CommunityPublic(community()) + ); + assert!(public.audience.is_public()); + 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. +#[test] +fn restricted_derivation_requires_verified_membership() { + let owner = principal(1); + let agent = principal(9); + let outsider = principal(8); + let policy = CapabilityPolicy::new( + non_egressing(["buzz.read.current"]), + non_egressing(["buzz.read.current"]), + ); + let facts = |members, requesters| DomainFacts { + community: community(), + channel_id: Uuid::from_u128(1), + kind: ConversationKind::Restricted, + epoch: MembershipEpoch::new("membership:v1"), + members, + executing_agent: agent, + requesters, + system_principal: None, + owner: Some(owner), + }; + + assert_eq!( + derive_execution_domain(facts(BTreeSet::new(), BTreeSet::new()), &policy), + Err(DerivationError::EmptyRequesters) + ); + assert_eq!( + derive_execution_domain( + facts(BTreeSet::from([owner]), BTreeSet::from([owner]),), + &policy, + ), + Err(DerivationError::AgentNotMember) + ); + assert_eq!( + derive_execution_domain( + facts(BTreeSet::from([owner, agent]), BTreeSet::from([outsider]),), + &policy, + ), + Err(DerivationError::RequesterNotMember) + ); +} + +/// 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. +#[test] +fn every_domain_component_changes_the_routing_key() { + let base = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let build = |agent, owner, audience, channel_id, epoch, capabilities| { + ExecutionDomain::new( + agent, + owner, + audience, + DomainContext::Conversation { + community: community(), + channel_id, + }, + MembershipEpoch::new(epoch), + capabilities, + ) + .expect("coherent domain") + }; + let ids = [ + base.key(), + build( + principal(8), + Some(principal(1)), + label(&[1, 2]), + Uuid::from_u128(1), + "v1", + conversation_capabilities(), + ) + .key(), + build( + principal(9), + None, + label(&[1, 2]), + Uuid::from_u128(1), + "v1", + conversation_capabilities(), + ) + .key(), + build( + principal(9), + Some(principal(1)), + label(&[1, 3]), + Uuid::from_u128(1), + "v1", + conversation_capabilities(), + ) + .key(), + build( + principal(9), + Some(principal(1)), + label(&[1, 2]), + Uuid::from_u128(2), + "v1", + conversation_capabilities(), + ) + .key(), + build( + principal(9), + Some(principal(1)), + label(&[1, 2]), + Uuid::from_u128(1), + "v2", + conversation_capabilities(), + ) + .key(), + build( + principal(9), + Some(principal(1)), + label(&[1, 2]), + Uuid::from_u128(1), + "v1", + non_egressing(["buzz.read.current"]), + ) + .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. +#[test] +fn domain_key_has_a_canonical_golden_value() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1"); + assert_eq!( + domain.key().as_str(), + "c83fb1c188109526fca87b73297eb00f9bf0b6545e36c6f0b8d3fb2771ce7962" + ); +} + +/// 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. +#[test] +fn domains_reject_incoherent_audience_context_pairs() { + assert_eq!( + ExecutionDomain::new( + principal(9), + Some(principal(1)), + ConfidentialityLabel::public(community()), + DomainContext::CommunityPublic(other_community()), + MembershipEpoch::new("v1"), + CapabilitySet::default(), + ), + Err(DomainError::ContextCommunityMismatch) + ); + assert_eq!( + ExecutionDomain::new( + principal(9), + Some(principal(1)), + ConfidentialityLabel::public(community()), + DomainContext::Conversation { + community: community(), + channel_id: Uuid::from_u128(2), + }, + MembershipEpoch::new("v1"), + CapabilitySet::default(), + ), + Err(DomainError::AudienceContextMismatch) + ); +}