//! Signal scope: where a signal lives and whether it may ever leave the node. //! //! [`SignalScope`] is the load-bearing M9 primitive. The default is //! [`SignalScope::Local`], and local-scoped signals **never ship** to a //! community and are **never purged** by community operations — this is the //! "local profile remains intact" guarantee that every M9/M10 surface upholds. //! //! On the WAL wire the scope is persisted as a single discriminant byte (see //! [`SignalScope::discriminant`]). The community id carried by //! [`SignalScope::Community`] is *not* stored in the base event record: which //! community a share-eligible event flows into is decided at ship time from the //! writer's [memberships](crate::governance::membership), so the persisted byte //! records only the scope *class*. Decoding a community byte therefore yields //! `Community(CommunityId::NONE)`; the concrete id lives in the in-memory API //! value and in the community ledger's keys, not in the per-event envelope. use crate::wal::error::WalError; /// Identifies a community personalization overlay. /// /// `CommunityId(0)` ([`CommunityId::NONE`]) is reserved to mean "no community" /// and is what a community scope decodes to when read back from the WAL (the /// concrete id is membership-driven, not stored per event). #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] pub struct CommunityId(pub u64); impl CommunityId { /// Sentinel for "no community". pub const NONE: Self = Self(0); /// The raw community id. #[must_use] pub const fn as_u64(self) -> u64 { self.0 } /// Whether this is the reserved [`CommunityId::NONE`] sentinel. #[must_use] pub const fn is_none(self) -> bool { self.0 == 0 } } impl std::fmt::Display for CommunityId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "c{}", self.0) } } /// Scope of a signal: where it lives and whether it may ever ship. /// /// - [`Local`](SignalScope::Local) — default; never ships, never community-purged. /// - [`Community`](SignalScope::Community) — eligible to contribute to community /// overlays subject to the writer's [`SharePolicy`](super::share_policy::SharePolicy). /// - [`Session`](SignalScope::Session) — scoped to one agent session. /// - [`Agent`](SignalScope::Agent) — attributed to an agent; removable by agent scope. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, )] pub enum SignalScope { /// The default. Stays on the local node forever. #[default] Local, /// Contributes to a community overlay when share-eligible. Community(CommunityId), /// Scoped to an agent session. Session, /// Attributed to an agent. Agent, } impl SignalScope { /// The 1-byte wire discriminant for this scope class. #[must_use] pub const fn discriminant(self) -> u8 { match self { Self::Local => 0, Self::Community(_) => 1, Self::Session => 2, Self::Agent => 3, } } /// Decode a scope class from its wire discriminant. /// /// A community byte decodes to `Community(CommunityId::NONE)` — the concrete /// id is membership-driven and not stored in the base event (see module docs). /// /// # Errors /// /// Returns [`WalError::Corruption`] for any discriminant outside `0..=3`. pub fn from_discriminant(b: u8) -> Result { match b { 0 => Ok(Self::Local), 1 => Ok(Self::Community(CommunityId::NONE)), 2 => Ok(Self::Session), 3 => Ok(Self::Agent), other => Err(WalError::Corruption { message: format!("invalid SignalScope discriminant: {other}"), }), } } /// Whether an event of this scope is ever eligible to leave the node. /// /// `Local` is never shippable; everything else *may* ship subject to a /// [`SharePolicy`](super::share_policy::SharePolicy). #[must_use] pub const fn is_shippable(self) -> bool { !matches!(self, Self::Local) } /// Whether this scope is a community scope. #[must_use] pub const fn is_community(self) -> bool { matches!(self, Self::Community(_)) } /// The concrete community id, if this is a community scope. #[must_use] pub const fn community_id(self) -> Option { match self { Self::Community(id) => Some(id), _ => None, } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn default_is_local() { assert_eq!(SignalScope::default(), SignalScope::Local); } #[test] fn discriminant_roundtrip_all_classes() { for scope in [ SignalScope::Local, SignalScope::Community(CommunityId(7)), SignalScope::Session, SignalScope::Agent, ] { let b = scope.discriminant(); let decoded = SignalScope::from_discriminant(b).unwrap(); // Community loses its id on the wire (decodes to NONE), so compare classes. assert_eq!(decoded.discriminant(), b); } } #[test] fn community_decodes_to_none() { let decoded = SignalScope::from_discriminant(1).unwrap(); assert_eq!(decoded, SignalScope::Community(CommunityId::NONE)); } #[test] fn invalid_discriminant_is_corruption() { assert!(SignalScope::from_discriminant(4).is_err()); assert!(SignalScope::from_discriminant(255).is_err()); } #[test] fn local_is_never_shippable() { assert!(!SignalScope::Local.is_shippable()); assert!(SignalScope::Community(CommunityId(1)).is_shippable()); assert!(SignalScope::Session.is_shippable()); assert!(SignalScope::Agent.is_shippable()); } #[test] fn community_accessors() { let c = SignalScope::Community(CommunityId(42)); assert!(c.is_community()); assert_eq!(c.community_id(), Some(CommunityId(42))); assert!(!SignalScope::Local.is_community()); assert_eq!(SignalScope::Local.community_id(), None); } #[test] fn community_id_display_and_none() { assert_eq!(CommunityId(5).to_string(), "c5"); assert!(CommunityId::NONE.is_none()); assert!(!CommunityId(1).is_none()); assert_eq!(CommunityId(9).as_u64(), 9); } }