lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 142f5e91478d87116dd55d72532e58dc14d0a04f
parent bd070be33bd5cba0f564a93be21db0f077497668
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Jul 2026 12:29:38 +0000

protocol: create versioned schema and module framework

- Materialize permanent files for every approved module generation.
- Validate canonical version-suffixed schema identifiers.
- Reject duplicate registrations and sort registries deterministically.
- Dispatch exact schemas to their owning module generation.

Diffstat:
Acrates/protocol/src/capability.rs | 4++++
Acrates/protocol/src/error.rs | 4++++
Acrates/protocol/src/event.rs | 4++++
Mcrates/protocol/src/lib.rs | 30++++++------------------------
Acrates/protocol/src/radrootsd.rs | 7+++++++
Acrates/protocol/src/runtime.rs | 4++++
Acrates/protocol/src/schema.rs | 426+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protocol/tests/package_boundary.rs | 26++++++++++++--------------
8 files changed, 467 insertions(+), 38 deletions(-)

diff --git a/crates/protocol/src/capability.rs b/crates/protocol/src/capability.rs @@ -0,0 +1,4 @@ +//! Versioned capability catalog contracts. + +/// Capability contracts for generation 1. +pub mod v1 {} diff --git a/crates/protocol/src/error.rs b/crates/protocol/src/error.rs @@ -0,0 +1,4 @@ +//! Versioned stable error-report contracts. + +/// Error-report contracts for generation 1. +pub mod v1 {} diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs @@ -0,0 +1,4 @@ +//! Versioned event wire contracts. + +/// Event wire contracts for generation 1. +pub mod v1 {} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs @@ -6,37 +6,19 @@ extern crate alloc; /// Versioned capability catalog contracts. -pub mod capability { - /// Capability contracts for generation 1. - pub mod v1 {} -} +pub mod capability; /// Versioned stable error-report contracts. -pub mod error { - /// Error-report contracts for generation 1. - pub mod v1 {} -} +pub mod error; /// Versioned event wire contracts. -pub mod event { - /// Event wire contracts for generation 1. - pub mod v1 {} -} +pub mod event; /// Versioned daemon protocol contracts. -pub mod radrootsd { - /// Versioned transport-publish contracts. - pub mod transport_publish { - /// Transport-publish contracts for generation 5. - pub mod v5 {} - } -} +pub mod radrootsd; /// Versioned runtime operation contracts. -pub mod runtime { - /// Runtime operation contracts for generation 1. - pub mod v1 {} -} +pub mod runtime; /// Schema identity and structural validation contracts. -pub mod schema {} +pub mod schema; diff --git a/crates/protocol/src/radrootsd.rs b/crates/protocol/src/radrootsd.rs @@ -0,0 +1,7 @@ +//! Versioned daemon protocol contracts. + +/// Versioned transport-publish contracts. +pub mod transport_publish { + /// Transport-publish contracts for generation 5. + pub mod v5 {} +} diff --git a/crates/protocol/src/runtime.rs b/crates/protocol/src/runtime.rs @@ -0,0 +1,4 @@ +//! Versioned runtime operation contracts. + +/// Runtime operation contracts for generation 1. +pub mod v1 {} diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs @@ -0,0 +1,426 @@ +//! Validated schema identities and deterministic module dispatch. + +use alloc::{string::String, vec::Vec}; +use core::{fmt, str::FromStr}; + +/// Maximum UTF-8 byte length accepted for a schema identifier. +pub const MAX_SCHEMA_ID_BYTES: usize = 255; + +/// A canonical, version-suffixed schema identifier. +/// +/// Schema identifiers contain one or more dot-separated namespace segments +/// followed by a positive canonical version segment such as `v1`. Namespace +/// segments begin with a lowercase ASCII letter and contain only lowercase +/// ASCII letters, digits, and underscores. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SchemaId { + value: String, + version: u16, +} + +impl SchemaId { + /// Parses and validates a schema identifier. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() { + return Err(Error::EmptySchemaId); + } + if value.len() > MAX_SCHEMA_ID_BYTES { + return Err(Error::SchemaIdTooLong { + actual: value.len(), + max: MAX_SCHEMA_ID_BYTES, + }); + } + + let (namespace, version_segment) = value + .rsplit_once('.') + .ok_or(Error::MissingSchemaNamespace)?; + if namespace.is_empty() { + return Err(Error::MissingSchemaNamespace); + } + for (index, segment) in namespace.split('.').enumerate() { + if !valid_namespace_segment(segment) { + return Err(Error::InvalidSchemaNamespaceSegment { index }); + } + } + + let version = parse_version_segment(version_segment)?; + Ok(Self { value, version }) + } + + /// Returns the canonical identifier text. + pub fn as_str(&self) -> &str { + self.value.as_str() + } + + /// Returns the positive schema generation encoded by the final segment. + pub const fn version(&self) -> u16 { + self.version + } +} + +impl AsRef<str> for SchemaId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for SchemaId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SchemaId { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl TryFrom<&str> for SchemaId { + type Error = Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl TryFrom<String> for SchemaId { + type Error = Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +/// A supported versioned module in the protocol package. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum ModuleVersion { + /// `capability::v1`. + CapabilityV1, + /// `error::v1`. + ErrorV1, + /// `event::v1`. + EventV1, + /// `runtime::v1`. + RuntimeV1, + /// `radrootsd::transport_publish::v5`. + RadrootsdTransportPublishV5, +} + +impl ModuleVersion { + /// Every module generation supported by this package version. + pub const ALL: [Self; 5] = [ + Self::CapabilityV1, + Self::ErrorV1, + Self::EventV1, + Self::RuntimeV1, + Self::RadrootsdTransportPublishV5, + ]; + + /// Returns the stable Rust module path relative to `radroots_protocol`. + pub const fn path(self) -> &'static str { + match self { + Self::CapabilityV1 => "capability::v1", + Self::ErrorV1 => "error::v1", + Self::EventV1 => "event::v1", + Self::RuntimeV1 => "runtime::v1", + Self::RadrootsdTransportPublishV5 => "radrootsd::transport_publish::v5", + } + } + + /// Returns the explicit contract generation for the module. + pub const fn generation(self) -> u16 { + match self { + Self::CapabilityV1 | Self::ErrorV1 | Self::EventV1 | Self::RuntimeV1 => 1, + Self::RadrootsdTransportPublishV5 => 5, + } + } +} + +/// One validated schema-to-module registration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Descriptor { + id: SchemaId, + module: ModuleVersion, +} + +impl Descriptor { + /// Creates a registration after validating its schema identifier. + pub fn try_new(id: impl Into<String>, module: ModuleVersion) -> Result<Self, Error> { + Ok(Self { + id: SchemaId::parse(id)?, + module, + }) + } + + /// Returns the schema identifier. + pub const fn id(&self) -> &SchemaId { + &self.id + } + + /// Returns the module generation that owns the schema. + pub const fn module(&self) -> ModuleVersion { + self.module + } +} + +/// A canonical registry of unique schema identifiers. +/// +/// Construction sorts descriptors by schema identifier so iteration and +/// lookup remain deterministic regardless of input order. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Registry { + descriptors: Vec<Descriptor>, +} + +impl Registry { + /// Builds a canonical registry and rejects duplicate schema identifiers. + pub fn try_new(descriptors: impl IntoIterator<Item = Descriptor>) -> Result<Self, Error> { + let mut descriptors: Vec<_> = descriptors.into_iter().collect(); + descriptors.sort_by(|left, right| left.id.cmp(&right.id)); + + for adjacent in descriptors.windows(2) { + if adjacent[0].id == adjacent[1].id { + return Err(Error::DuplicateSchemaId { + schema_id: adjacent[0].id.as_str().into(), + }); + } + } + + Ok(Self { descriptors }) + } + + /// Returns the canonical descriptor sequence. + pub fn descriptors(&self) -> &[Descriptor] { + self.descriptors.as_slice() + } + + /// Returns the number of registered schemas. + pub fn len(&self) -> usize { + self.descriptors.len() + } + + /// Reports whether the registry contains no schemas. + pub fn is_empty(&self) -> bool { + self.descriptors.is_empty() + } + + /// Returns the descriptor for an exact schema identifier. + pub fn descriptor(&self, id: &SchemaId) -> Option<&Descriptor> { + self.descriptors + .binary_search_by(|descriptor| descriptor.id.cmp(id)) + .ok() + .map(|index| &self.descriptors[index]) + } + + /// Dispatches an exact schema identifier to its owning module generation. + pub fn module_for(&self, id: &SchemaId) -> Option<ModuleVersion> { + self.descriptor(id).map(Descriptor::module) + } +} + +/// Schema identity or registry validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// The schema identifier is empty. + EmptySchemaId, + /// The schema identifier exceeds the byte-length limit. + SchemaIdTooLong { + /// Actual UTF-8 byte length. + actual: usize, + /// Maximum accepted UTF-8 byte length. + max: usize, + }, + /// The schema identifier does not include a namespace before its version. + MissingSchemaNamespace, + /// A namespace segment is empty or noncanonical. + InvalidSchemaNamespaceSegment { + /// Zero-based namespace segment index. + index: usize, + }, + /// The final segment is not a canonical positive `vN` generation. + InvalidSchemaVersion, + /// The registry contains an identifier more than once. + DuplicateSchemaId { + /// Duplicated canonical schema identifier. + schema_id: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySchemaId => formatter.write_str("schema id must not be empty"), + Self::SchemaIdTooLong { actual, max } => { + write!(formatter, "schema id length {actual} exceeds {max} bytes") + } + Self::MissingSchemaNamespace => { + formatter.write_str("schema id must contain a namespace and version") + } + Self::InvalidSchemaNamespaceSegment { index } => { + write!(formatter, "schema id namespace segment {index} is invalid") + } + Self::InvalidSchemaVersion => { + formatter.write_str("schema id version must be canonical positive vN") + } + Self::DuplicateSchemaId { schema_id } => { + write!(formatter, "duplicate schema id {schema_id}") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +fn valid_namespace_segment(segment: &str) -> bool { + let mut bytes = segment.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn parse_version_segment(segment: &str) -> Result<u16, Error> { + let Some(digits) = segment.strip_prefix('v') else { + return Err(Error::InvalidSchemaVersion); + }; + if digits.is_empty() + || digits.starts_with('0') + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(Error::InvalidSchemaVersion); + } + digits + .parse::<u16>() + .ok() + .filter(|version| *version > 0) + .ok_or(Error::InvalidSchemaVersion) +} + +#[cfg(test)] +mod tests { + use alloc::{string::ToString, vec}; + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn schema_id_parsing_accepts_existing_contract_shape() { + let id = SchemaId::parse("radroots.protocol.transport_kind.v1").expect("schema id"); + assert_eq!(id.as_str(), "radroots.protocol.transport_kind.v1"); + assert_eq!(id.version(), 1); + assert_eq!(id.to_string(), id.as_str()); + assert_eq!(id, id.as_str().parse().expect("FromStr schema id")); + } + + #[test] + fn schema_id_parsing_rejects_every_noncanonical_shape() { + let too_long = alloc::format!("{}.v1", "a".repeat(MAX_SCHEMA_ID_BYTES)); + for (value, expected) in [ + ("", Error::EmptySchemaId), + ("v1", Error::MissingSchemaNamespace), + (".v1", Error::MissingSchemaNamespace), + ( + "radroots..event.v1", + Error::InvalidSchemaNamespaceSegment { index: 1 }, + ), + ( + "Radroots.protocol.event.v1", + Error::InvalidSchemaNamespaceSegment { index: 0 }, + ), + ( + "radroots.protocol.event-name.v1", + Error::InvalidSchemaNamespaceSegment { index: 2 }, + ), + ("radroots.protocol.event", Error::InvalidSchemaVersion), + ("radroots.protocol.event.v0", Error::InvalidSchemaVersion), + ("radroots.protocol.event.v01", Error::InvalidSchemaVersion), + ( + "radroots.protocol.event.v65536", + Error::InvalidSchemaVersion, + ), + ] { + assert_eq!(SchemaId::parse(value), Err(expected), "{value}"); + } + assert_eq!( + SchemaId::parse(too_long), + Err(Error::SchemaIdTooLong { + actual: MAX_SCHEMA_ID_BYTES + 3, + max: MAX_SCHEMA_ID_BYTES, + }) + ); + } + + #[test] + fn module_inventory_has_unique_paths_and_explicit_generations() { + let paths = ModuleVersion::ALL + .into_iter() + .map(ModuleVersion::path) + .collect::<BTreeSet<_>>(); + assert_eq!(paths.len(), ModuleVersion::ALL.len()); + assert_eq!(ModuleVersion::CapabilityV1.generation(), 1); + assert_eq!(ModuleVersion::ErrorV1.generation(), 1); + assert_eq!(ModuleVersion::EventV1.generation(), 1); + assert_eq!(ModuleVersion::RuntimeV1.generation(), 1); + assert_eq!(ModuleVersion::RadrootsdTransportPublishV5.generation(), 5); + } + + #[test] + fn registry_is_canonical_unique_and_dispatches_exact_ids() { + let event = Descriptor::try_new( + "radroots.protocol.event_descriptor.v1", + ModuleVersion::EventV1, + ) + .expect("event descriptor"); + let capability = Descriptor::try_new( + "radroots.protocol.transport_kind.v1", + ModuleVersion::CapabilityV1, + ) + .expect("capability descriptor"); + let registry = Registry::try_new(vec![event, capability.clone()]).expect("registry"); + + assert_eq!(registry.len(), 2); + assert!(!registry.is_empty()); + assert_eq!( + registry + .descriptors() + .iter() + .map(|descriptor| descriptor.id().as_str()) + .collect::<Vec<_>>(), + vec![ + "radroots.protocol.event_descriptor.v1", + "radroots.protocol.transport_kind.v1", + ] + ); + assert_eq!( + registry.module_for(capability.id()), + Some(ModuleVersion::CapabilityV1) + ); + let unknown = SchemaId::parse("radroots.protocol.unknown.v1").expect("unknown id"); + assert_eq!(registry.module_for(&unknown), None); + } + + #[test] + fn registry_rejects_duplicate_schema_ids() { + let first = Descriptor::try_new( + "radroots.protocol.event_descriptor.v1", + ModuleVersion::EventV1, + ) + .expect("first descriptor"); + let second = Descriptor::try_new( + "radroots.protocol.event_descriptor.v1", + ModuleVersion::CapabilityV1, + ) + .expect("second descriptor"); + assert_eq!( + Registry::try_new(vec![first, second]), + Err(Error::DuplicateSchemaId { + schema_id: "radroots.protocol.event_descriptor.v1".into(), + }) + ); + } +} diff --git a/crates/protocol/tests/package_boundary.rs b/crates/protocol/tests/package_boundary.rs @@ -2,6 +2,11 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); +const CAPABILITY: &str = include_str!("../src/capability.rs"); +const ERROR: &str = include_str!("../src/error.rs"); +const EVENT: &str = include_str!("../src/event.rs"); +const RADROOTSD: &str = include_str!("../src/radrootsd.rs"); +const RUNTIME: &str = include_str!("../src/runtime.rs"); #[test] fn manifest_has_final_identity_features_and_no_dependencies() { @@ -32,18 +37,11 @@ fn crate_root_exposes_only_the_approved_versioned_skeleton() { "schema" ]) ); - assert!(ROOT.contains( - "pub mod capability {\n /// Capability contracts for generation 1.\n pub mod v1 {}" - )); - assert!(ROOT.contains( - "pub mod error {\n /// Error-report contracts for generation 1.\n pub mod v1 {}" - )); - assert!(ROOT.contains( - "pub mod event {\n /// Event wire contracts for generation 1.\n pub mod v1 {}" - )); - assert!(ROOT.contains("pub mod runtime {\n /// Runtime operation contracts for generation 1.\n pub mod v1 {}")); - assert!(ROOT.contains("pub mod transport_publish {\n /// Transport-publish contracts for generation 5.\n pub mod v5 {}")); - assert!(ROOT.contains("pub mod schema {}")); + for source in [CAPABILITY, ERROR, EVENT, RUNTIME] { + assert!(source.lines().any(|line| line.trim() == "pub mod v1 {}")); + } + assert!(RADROOTSD.contains("pub mod transport_publish {")); + assert!(RADROOTSD.lines().any(|line| line.trim() == "pub mod v5 {}")); assert!( !ROOT .lines() @@ -71,8 +69,8 @@ fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { fn root_declarations(prefix: &str) -> BTreeSet<&str> { ROOT.lines() - .filter(|line| !line.starts_with(char::is_whitespace)) + .map(str::trim) .filter_map(|line| line.strip_prefix(prefix)) - .filter_map(|name| name.strip_suffix(" {").or_else(|| name.strip_suffix(" {}"))) + .filter_map(|name| name.strip_suffix(';')) .collect() }