- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
194 lines
6.4 KiB
Rust
194 lines
6.4 KiB
Rust
//! 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<Self, WalError> {
|
|
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<CommunityId> {
|
|
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);
|
|
}
|
|
}
|