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:
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()
}