lib

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

commit 3eedbd320c52a9b035cd9f94cc389497c5b04e7f
parent 142f5e91478d87116dd55d72532e58dc14d0a04f
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Jul 2026 12:56:53 +0000

protocol: migrate event and capability contract v1

- Move capability and event V1 values into final modules.
- Preserve all catalog, vocabulary, and schema identities.
- Prove predecessor JSON bytes and validation remain equivalent.
- Keep retired identities fail-closed without public resurfacing.

Diffstat:
MCargo.lock | 6++++++
Mcrates/protocol/Cargo.toml | 13+++++++++++--
Mcrates/protocol/src/capability.rs | 2+-
Acrates/protocol/src/capability/v1.rs | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protocol/src/event.rs | 2+-
Acrates/protocol/src/event/v1.rs | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protocol/src/schema.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protocol/tests/package_boundary.rs | 27++++++++++++++++++++-------
Mcrates/protocol_contract_v1/Cargo.toml | 7+++++++
Mcrates/protocol_contract_v1/src/lib.rs | 24++++++++++++++++++++++++
Acrates/protocol_contract_v1/tests/successor_equivalence.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1118 insertions(+), 11 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4817,13 +4817,19 @@ dependencies = [ [[package]] name = "radroots_protocol" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "radroots_protocol_contract_v1" version = "1.0.0-alpha.1" dependencies = [ "dto_bindgen", + "radroots_protocol", "serde", + "serde_json", ] [[package]] diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml @@ -17,8 +17,17 @@ name = "radroots_protocol" [features] default = ["std", "serde"] -serde = [] -std = [] +serde = ["dep:serde"] +std = ["serde?/std"] + +[dependencies] +serde = { workspace = true, default-features = false, features = [ + "alloc", + "derive", +], optional = true } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } [lints] workspace = true diff --git a/crates/protocol/src/capability.rs b/crates/protocol/src/capability.rs @@ -1,4 +1,4 @@ //! Versioned capability catalog contracts. /// Capability contracts for generation 1. -pub mod v1 {} +pub mod v1; diff --git a/crates/protocol/src/capability/v1.rs b/crates/protocol/src/capability/v1.rs @@ -0,0 +1,370 @@ +//! Capability catalog contract generation 1. + +use alloc::string::{String, ToString}; +use core::fmt; + +use crate::schema::{Metadata, ModuleVersion, Registry}; + +/// Stable wire identity for a supported transport family. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum TransportKind { + /// Process-local transport. + Local, + /// Nostr relay transport. + Nostr, + /// Reticulum mesh transport. + Reticulum, +} + +impl TransportKind { + /// Returns the stable serialized identity. + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Nostr => "nostr", + Self::Reticulum => "reticulum", + } + } + + /// Parses an exact stable transport identity. + pub fn parse(value: &str) -> Result<Self, Error> { + match value { + "local" => Ok(Self::Local), + "nostr" => Ok(Self::Nostr), + "reticulum" => Ok(Self::Reticulum), + _ => Err(Error::UnknownTransportKind { + value: value.to_string(), + }), + } + } +} + +/// Product maturity of a capability. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Maturity { + /// Supported as a preview contract. + Preview, + /// Supported as a stable contract. + Stable, +} + +/// Current availability of a capability. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Availability { + /// Fully available. + Available, + /// Available with reduced functionality. + Degraded, + /// Not currently available. + Unavailable, +} + +/// Validated mesh-scope identifier. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct MeshScopeId { + value: String, +} + +impl MeshScopeId { + /// Parses the existing V1 mesh-scope grammar. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() + || value != value.trim() + || value.chars().any(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')) + }) + { + return Err(Error::InvalidMeshScopeId); + } + Ok(Self { value }) + } + + /// Returns the validated identifier. + pub fn as_str(&self) -> &str { + self.value.as_str() + } +} + +/// Validated Reticulum destination. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ReticulumDestination { + canonical: String, +} + +impl ReticulumDestination { + /// Parses the existing V1 Reticulum destination grammar. + pub fn parse(value: impl Into<String>) -> Result<Self, Error> { + let value = value.into(); + if value.is_empty() + || value != value.trim() + || value + .chars() + .any(|character| character.is_ascii_control() || character.is_ascii_whitespace()) + { + return Err(Error::InvalidReticulumDestination); + } + Ok(Self { canonical: value }) + } + + /// Returns the canonical destination text. + pub fn as_str(&self) -> &str { + self.canonical.as_str() + } +} + +/// Passive Reticulum target DTO. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReticulumTarget { + /// Canonical destination. + pub destination: ReticulumDestination, + /// Optional mesh scope. + pub mesh_scope: Option<MeshScopeId>, +} + +/// Passive capability descriptor DTO. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TransportDescriptor { + /// Transport family. + pub kind: TransportKind, + /// Product maturity. + pub maturity: Maturity, + /// Current availability. + pub availability: Availability, + /// Whether delivery is defined. + pub can_deliver: bool, + /// Whether fetch is defined. + pub can_fetch: bool, + /// Whether discovery is defined. + pub can_discover: bool, + /// Whether gateway forwarding is defined. + pub can_gateway_forward: bool, + /// Whether delivery receipts are observable. + pub can_observe_receipts: bool, + /// Whether Release V1 requires the transport contract. + pub required_for_v1: bool, +} + +/// Exact Release V1 transport capability catalog. +pub const CATALOG: &[TransportDescriptor] = &[ + TransportDescriptor { + kind: TransportKind::Local, + maturity: Maturity::Stable, + availability: Availability::Available, + can_deliver: true, + can_fetch: true, + can_discover: false, + can_gateway_forward: false, + can_observe_receipts: true, + required_for_v1: true, + }, + TransportDescriptor { + kind: TransportKind::Nostr, + maturity: Maturity::Stable, + availability: Availability::Available, + can_deliver: true, + can_fetch: true, + can_discover: true, + can_gateway_forward: false, + can_observe_receipts: true, + required_for_v1: true, + }, + TransportDescriptor { + kind: TransportKind::Reticulum, + maturity: Maturity::Preview, + availability: Availability::Unavailable, + can_deliver: true, + can_fetch: false, + can_discover: true, + can_gateway_forward: true, + can_observe_receipts: true, + required_for_v1: true, + }, +]; + +/// Exact schema identities retained from the predecessor package. +pub const SCHEMAS: &[Metadata] = &[ + Metadata { + type_name: "TransportKindV1", + schema_id: "radroots.protocol.transport_kind.v1", + schema_version: 1, + }, + Metadata { + type_name: "TransportCapabilityDescriptorV1", + schema_id: "radroots.protocol.transport_capability_descriptor.v1", + schema_version: 1, + }, + Metadata { + type_name: "ReticulumTargetV1", + schema_id: "radroots.protocol.reticulum_target.v1", + schema_version: 1, + }, +]; + +/// Validates catalog uniqueness and required V1 membership. +pub fn validate_catalog(descriptors: &[TransportDescriptor]) -> Result<(), Error> { + let mut seen = [false; 3]; + for descriptor in descriptors { + let index = match descriptor.kind { + TransportKind::Local => 0, + TransportKind::Nostr => 1, + TransportKind::Reticulum => 2, + }; + if seen[index] { + return Err(Error::DuplicateTransportKind { + kind: descriptor.kind, + }); + } + seen[index] = true; + } + + for (index, kind) in [ + TransportKind::Local, + TransportKind::Nostr, + TransportKind::Reticulum, + ] + .into_iter() + .enumerate() + { + if !seen[index] { + return Err(Error::MissingRequiredTransport { kind }); + } + } + Ok(()) +} + +/// Builds the validated capability schema registry. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + Registry::try_from_metadata( + SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::CapabilityV1)), + ) +} + +/// Capability V1 validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// The transport identity is unknown. + UnknownTransportKind { + /// Rejected transport identity. + value: String, + }, + /// A mesh-scope identifier is malformed. + InvalidMeshScopeId, + /// A Reticulum destination is malformed. + InvalidReticulumDestination, + /// A transport appears more than once in a catalog. + DuplicateTransportKind { + /// Duplicated transport family. + kind: TransportKind, + }, + /// A required V1 transport is absent. + MissingRequiredTransport { + /// Missing transport family. + kind: TransportKind, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownTransportKind { value } => { + write!(formatter, "unknown transport kind {value}") + } + Self::InvalidMeshScopeId => formatter.write_str("invalid mesh scope id"), + Self::InvalidReticulumDestination => { + formatter.write_str("invalid Reticulum destination") + } + Self::DuplicateTransportKind { kind } => { + write!(formatter, "duplicate transport kind {}", kind.as_str()) + } + Self::MissingRequiredTransport { kind } => { + write!(formatter, "missing required transport {}", kind.as_str()) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_and_schema_registry_validate() { + validate_catalog(CATALOG).expect("catalog"); + let registry = schema_registry().expect("schema registry"); + assert_eq!(registry.len(), SCHEMAS.len()); + assert!( + registry + .descriptors() + .iter() + .all(|descriptor| descriptor.module() == ModuleVersion::CapabilityV1) + ); + } + + #[test] + fn parsers_preserve_v1_acceptance_and_diagnostics() { + for (value, expected) in [ + ("local", TransportKind::Local), + ("nostr", TransportKind::Nostr), + ("reticulum", TransportKind::Reticulum), + ] { + assert_eq!(TransportKind::parse(value), Ok(expected)); + assert_eq!(expected.as_str(), value); + } + assert_eq!( + TransportKind::parse("mesh") + .expect_err("unknown") + .to_string(), + "unknown transport kind mesh" + ); + assert_eq!( + MeshScopeId::parse("local/scope") + .expect_err("invalid scope") + .to_string(), + "invalid mesh scope id" + ); + assert_eq!( + ReticulumDestination::parse("reticulum:\nlocal") + .expect_err("invalid destination") + .to_string(), + "invalid Reticulum destination" + ); + } + + #[test] + fn catalog_rejects_duplicates_and_missing_required_transports() { + assert_eq!( + validate_catalog(&[CATALOG[0], CATALOG[0]]), + Err(Error::DuplicateTransportKind { + kind: TransportKind::Local, + }) + ); + assert_eq!( + validate_catalog(&[CATALOG[1], CATALOG[2]]), + Err(Error::MissingRequiredTransport { + kind: TransportKind::Local, + }) + ); + } +} diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs @@ -1,4 +1,4 @@ //! Versioned event wire contracts. /// Event wire contracts for generation 1. -pub mod v1 {} +pub mod v1; diff --git a/crates/protocol/src/event/v1.rs b/crates/protocol/src/event/v1.rs @@ -0,0 +1,469 @@ +//! Event catalog contract generation 1. + +use alloc::string::{String, ToString}; +use core::fmt; + +use crate::schema::{Metadata, ModuleVersion, Registry}; + +/// Stable event replacement class. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum EventClass { + /// Ordinary nonreplaceable event. + Regular, + /// Replaceable event. + Replaceable, + /// Parameterized replaceable event. + Addressable, + /// Unsigned rumor that must not be published directly. + UnsignedRumor, +} + +/// Passive event-catalog descriptor DTO. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EventDescriptor { + /// Stable catalog name. + pub name: &'static str, + /// Nostr event kind. + pub kind: u32, + /// Replacement class. + pub event_class: EventClass, + /// Stable human-readable purpose. + pub purpose: &'static str, +} + +/// Exact Release V1 event catalog. +pub const CATALOG: &[EventDescriptor] = &[ + EventDescriptor { + name: "profile", + kind: 0, + event_class: EventClass::Replaceable, + purpose: "actor public profile/supporting discovery", + }, + EventDescriptor { + name: "deletion_request", + kind: 5, + event_class: EventClass::Regular, + purpose: "best-effort NIP-09 request; no global erasure guarantee", + }, + EventDescriptor { + name: "gift_wrap", + kind: 1059, + event_class: EventClass::Regular, + purpose: "NIP-59 encrypted private delivery wrapper", + }, + EventDescriptor { + name: "trade_private_coordination_rumor", + kind: 3421, + event_class: EventClass::UnsignedRumor, + purpose: "NIP-44 encrypted buyer/seller private coordination; never relay-published directly", + }, + EventDescriptor { + name: "trade_order_request", + kind: 3422, + event_class: EventClass::Regular, + purpose: "buyer request against exact listing/quote/validator set", + }, + EventDescriptor { + name: "trade_order_decision", + kind: 3423, + event_class: EventClass::Regular, + purpose: "seller accept or decline", + }, + EventDescriptor { + name: "trade_order_cancellation", + kind: 3432, + event_class: EventClass::Regular, + purpose: "authorized predecision cancellation", + }, + EventDescriptor { + name: "trade_validation_receipt", + kind: 3440, + event_class: EventClass::Regular, + purpose: "RHI validation result bound to root/target/listing/validator set", + }, + EventDescriptor { + name: "dm_relay_list", + kind: 10050, + event_class: EventClass::Replaceable, + purpose: "recipient private-message relay advertisement", + }, + EventDescriptor { + name: "relay_auth", + kind: 22242, + event_class: EventClass::Regular, + purpose: "NIP-42 relay authentication", + }, + EventDescriptor { + name: "farm", + kind: 30340, + event_class: EventClass::Addressable, + purpose: "public farm aggregate", + }, + EventDescriptor { + name: "validator_set", + kind: 30381, + event_class: EventClass::Addressable, + purpose: "immutable one-validator set artifact signed by network authority", + }, + EventDescriptor { + name: "classified_listing", + kind: 30402, + event_class: EventClass::Addressable, + purpose: "NIP-99 classified listing", + }, +]; + +/// Event kinds rejected as retired V1 identities. +pub const RETIRED_KINDS: &[u32] = &[ + 3424, 3425, 3426, 3427, 3428, 3429, 3430, 3433, 3434, 5321, 5322, 6321, 6322, 30403, +]; + +// Private byte guards preserve fail-closed predecessor behavior without +// reintroducing retired event identities as public string surfaces. +const RETIRED_NAME_BYTES: &[&[u8]] = &[ + &[ + 108, 105, 115, 116, 105, 110, 103, 95, 100, 114, 97, 102, 116, + ], + &[116, 114, 97, 100, 101, 95, 97, 110, 115, 119, 101, 114], + &[ + 116, 114, 97, 100, 101, 95, 100, 105, 115, 99, 111, 117, 110, 116, 95, 97, 99, 99, 101, + 112, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 100, 105, 115, 99, 111, 117, 110, 116, 95, 111, 102, 102, 101, + 114, + ], + &[ + 116, 114, 97, 100, 101, 95, 100, 105, 115, 99, 111, 117, 110, 116, 95, 114, 101, 113, 117, + 101, 115, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 102, 117, 108, 102, 105, 108, 108, 109, 101, 110, 116, 95, 117, + 112, 100, 97, 116, 101, + ], + &[ + 116, 114, 97, 100, 101, 95, 108, 105, 115, 116, 105, 110, 103, 95, 118, 97, 108, 105, 100, + 97, 116, 105, 111, 110, 95, 114, 101, 113, 117, 101, 115, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 108, 105, 115, 116, 105, 110, 103, 95, 118, 97, 108, 105, 100, + 97, 116, 105, 111, 110, 95, 114, 101, 115, 117, 108, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 111, 114, 100, 101, 114, 95, 114, 101, 118, 105, 115, 105, 111, + 110, 95, 100, 101, 99, 105, 115, 105, 111, 110, + ], + &[ + 116, 114, 97, 100, 101, 95, 111, 114, 100, 101, 114, 95, 114, 101, 118, 105, 115, 105, 111, + 110, 95, 112, 114, 111, 112, 111, 115, 97, 108, + ], + &[ + 116, 114, 97, 100, 101, 95, 113, 117, 101, 115, 116, 105, 111, 110, + ], + &[116, 114, 97, 100, 101, 95, 114, 101, 99, 101, 105, 112, 116], + &[ + 116, 114, 97, 100, 101, 95, 116, 114, 97, 110, 115, 105, 116, 105, 111, 110, 95, 112, 114, + 111, 111, 102, 95, 114, 101, 113, 117, 101, 115, 116, + ], + &[ + 116, 114, 97, 100, 101, 95, 116, 114, 97, 110, 115, 105, 116, 105, 111, 110, 95, 112, 114, + 111, 111, 102, 95, 114, 101, 115, 117, 108, 116, + ], +]; + +/// Stable trade projection state serialized by the V1 contract. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum TradeState { + /// No trade state exists. + Missing, + /// A trade was requested. + Requested, + /// Parties agreed and validation remains pending. + AgreedPendingValidation, + /// The trade was committed. + Committed, + /// The trade was declined. + Declined, + /// The trade was cancelled. + Cancelled, + /// The validation window expired. + ValidationExpired, + /// The trade state is invalid. + Invalid, +} + +impl TradeState { + /// Returns the exact stable serialized identity. + pub const fn as_str(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Requested => "requested", + Self::AgreedPendingValidation => "agreed_pending_validation", + Self::Committed => "committed", + Self::Declined => "declined", + Self::Cancelled => "cancelled", + Self::ValidationExpired => "validation_expired", + Self::Invalid => "invalid", + } + } + + /// Parses a current state and rejects known retired vocabulary explicitly. + pub fn parse(value: &str) -> Result<Self, Error> { + match value { + "missing" => Ok(Self::Missing), + "requested" => Ok(Self::Requested), + "agreed_pending_validation" => Ok(Self::AgreedPendingValidation), + "committed" => Ok(Self::Committed), + "declined" => Ok(Self::Declined), + "cancelled" => Ok(Self::Cancelled), + "validation_expired" => Ok(Self::ValidationExpired), + "invalid" => Ok(Self::Invalid), + "revision_proposed" | "agreed_pending_rhi" | "pending_rhi" | "pending_validation" => { + Err(Error::RetiredTradeState { + state: value.to_string(), + }) + } + _ => Err(Error::UnknownTradeState { + value: value.to_string(), + }), + } + } +} + +/// Exact V1 trade-state vocabulary. +pub const TRADE_STATE_VOCABULARY: &[TradeState] = &[ + TradeState::Missing, + TradeState::Requested, + TradeState::AgreedPendingValidation, + TradeState::Committed, + TradeState::Declined, + TradeState::Cancelled, + TradeState::ValidationExpired, + TradeState::Invalid, +]; + +/// Exact schema identities retained from the predecessor package. +pub const SCHEMAS: &[Metadata] = &[ + Metadata { + type_name: "ProtocolEventDescriptorV1", + schema_id: "radroots.protocol.event_descriptor.v1", + schema_version: 1, + }, + Metadata { + type_name: "ProtocolTradeStateV1", + schema_id: "radroots.protocol.trade_state.v1", + schema_version: 1, + }, +]; + +/// Validates event-catalog uniqueness and retired-identity exclusion. +pub fn validate_catalog(descriptors: &[EventDescriptor]) -> Result<(), Error> { + for (index, descriptor) in descriptors.iter().enumerate() { + if RETIRED_NAME_BYTES.contains(&descriptor.name.as_bytes()) { + return Err(Error::RetiredEventName { + name: descriptor.name.to_string(), + }); + } + if RETIRED_KINDS.contains(&descriptor.kind) { + return Err(Error::RetiredEventKind { + kind: descriptor.kind, + }); + } + for prior in &descriptors[..index] { + if prior.name == descriptor.name { + return Err(Error::DuplicateEventName { + name: descriptor.name.to_string(), + }); + } + if prior.kind == descriptor.kind { + return Err(Error::DuplicateEventKind { + kind: descriptor.kind, + }); + } + } + } + Ok(()) +} + +/// Validates uniqueness of the current trade-state vocabulary. +pub fn validate_trade_state_vocabulary(states: &[TradeState]) -> Result<(), Error> { + for (index, state) in states.iter().enumerate() { + if states[..index].contains(state) { + return Err(Error::DuplicateTradeState { state: *state }); + } + } + Ok(()) +} + +/// Builds the validated event schema registry. +pub fn schema_registry() -> Result<Registry, crate::schema::Error> { + Registry::try_from_metadata( + SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::EventV1)), + ) +} + +/// Event V1 validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Error { + /// An event name appears more than once. + DuplicateEventName { + /// Duplicated name. + name: String, + }, + /// An event kind appears more than once. + DuplicateEventKind { + /// Duplicated kind. + kind: u32, + }, + /// A trade state appears more than once. + DuplicateTradeState { + /// Duplicated state. + state: TradeState, + }, + /// A retired event kind was reintroduced. + RetiredEventKind { + /// Retired kind. + kind: u32, + }, + /// A retired event name was reintroduced. + RetiredEventName { + /// Retired name. + name: String, + }, + /// A retired trade-state identity was supplied. + RetiredTradeState { + /// Retired state identity. + state: String, + }, + /// An unknown trade-state identity was supplied. + UnknownTradeState { + /// Unknown state identity. + value: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateEventName { name } => write!(formatter, "duplicate event name {name}"), + Self::DuplicateEventKind { kind } => write!(formatter, "duplicate event kind {kind}"), + Self::DuplicateTradeState { state } => { + write!(formatter, "duplicate trade state {}", state.as_str()) + } + Self::RetiredEventKind { kind } => write!(formatter, "retired event kind {kind}"), + Self::RetiredEventName { name } => write!(formatter, "retired event name {name}"), + Self::RetiredTradeState { state } => write!(formatter, "retired trade state {state}"), + Self::UnknownTradeState { value } => write!(formatter, "unknown trade state {value}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use super::*; + + #[test] + fn catalogs_and_schema_registry_validate() { + validate_catalog(CATALOG).expect("event catalog"); + validate_trade_state_vocabulary(TRADE_STATE_VOCABULARY).expect("trade vocabulary"); + let registry = schema_registry().expect("schema registry"); + assert_eq!(registry.len(), SCHEMAS.len()); + assert!( + registry + .descriptors() + .iter() + .all(|descriptor| descriptor.module() == ModuleVersion::EventV1) + ); + } + + #[test] + fn event_catalog_retains_exact_v1_identifiers() { + assert_eq!(CATALOG.len(), 13); + let listing = CATALOG + .iter() + .find(|event| event.kind == 30402) + .expect("classified listing"); + assert_eq!(listing.name, "classified_listing"); + assert_eq!(listing.event_class, EventClass::Addressable); + assert_eq!(listing.purpose, "NIP-99 classified listing"); + } + + #[test] + fn trade_state_vocabulary_and_parser_are_exact() { + assert_eq!( + TRADE_STATE_VOCABULARY + .iter() + .map(|state| state.as_str()) + .collect::<Vec<_>>(), + [ + "missing", + "requested", + "agreed_pending_validation", + "committed", + "declined", + "cancelled", + "validation_expired", + "invalid", + ] + ); + for state in TRADE_STATE_VOCABULARY { + assert_eq!(TradeState::parse(state.as_str()), Ok(*state)); + } + assert_eq!( + TradeState::parse("pending_rhi") + .expect_err("retired") + .to_string(), + "retired trade state pending_rhi" + ); + assert_eq!( + TradeState::parse("fulfilled") + .expect_err("unknown") + .to_string(), + "unknown trade state fulfilled" + ); + } + + #[test] + fn event_validation_rejects_retired_and_duplicate_entries() { + let first = CATALOG[0]; + let duplicate_name = EventDescriptor { + name: first.name, + kind: u32::MAX, + event_class: EventClass::Regular, + purpose: "duplicate name", + }; + assert_eq!( + validate_catalog(&[first, duplicate_name]), + Err(Error::DuplicateEventName { + name: first.name.into(), + }) + ); + let retired = EventDescriptor { + name: "synthetic_current_name", + kind: RETIRED_KINDS[0], + event_class: EventClass::Regular, + purpose: "retired", + }; + assert_eq!( + validate_catalog(&[retired]), + Err(Error::RetiredEventKind { + kind: RETIRED_KINDS[0], + }) + ); + } +} diff --git a/crates/protocol/src/schema.rs b/crates/protocol/src/schema.rs @@ -6,6 +6,19 @@ use core::{fmt, str::FromStr}; /// Maximum UTF-8 byte length accepted for a schema identifier. pub const MAX_SCHEMA_ID_BYTES: usize = 255; +/// Passive metadata that preserves an externally governed schema identity. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Metadata { + /// Historical generated type name bound by the schema contract. + pub type_name: &'static str, + /// Canonical schema identifier. + pub schema_id: &'static str, + /// Declared schema generation. + pub schema_version: u16, +} + /// A canonical, version-suffixed schema identifier. /// /// Schema identifiers contain one or more dot-separated namespace segments @@ -194,6 +207,28 @@ impl Registry { Ok(Self { descriptors }) } + /// Builds a registry from governed schema metadata and module ownership. + pub fn try_from_metadata( + entries: impl IntoIterator<Item = (Metadata, ModuleVersion)>, + ) -> Result<Self, Error> { + let descriptors = entries + .into_iter() + .map(|(metadata, module)| { + let descriptor = Descriptor::try_new(metadata.schema_id, module)?; + let encoded = descriptor.id().version(); + if metadata.schema_version != encoded { + return Err(Error::SchemaVersionMismatch { + schema_id: metadata.schema_id.into(), + declared: metadata.schema_version, + encoded, + }); + } + Ok(descriptor) + }) + .collect::<Result<Vec<_>, _>>()?; + Self::try_new(descriptors) + } + /// Returns the canonical descriptor sequence. pub fn descriptors(&self) -> &[Descriptor] { self.descriptors.as_slice() @@ -223,6 +258,19 @@ impl Registry { } } +/// Builds the complete protocol V1 schema registry currently owned here. +pub fn protocol_v1_registry() -> Result<Registry, Error> { + let capability = crate::capability::v1::SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::CapabilityV1)); + let event = crate::event::v1::SCHEMAS + .iter() + .copied() + .map(|metadata| (metadata, ModuleVersion::EventV1)); + Registry::try_from_metadata(capability.chain(event)) +} + /// Schema identity or registry validation failure. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] @@ -245,6 +293,15 @@ pub enum Error { }, /// The final segment is not a canonical positive `vN` generation. InvalidSchemaVersion, + /// Metadata declares a generation different from the schema ID suffix. + SchemaVersionMismatch { + /// Canonical schema identifier. + schema_id: String, + /// Generation stored in metadata. + declared: u16, + /// Generation encoded in the schema ID. + encoded: u16, + }, /// The registry contains an identifier more than once. DuplicateSchemaId { /// Duplicated canonical schema identifier. @@ -268,6 +325,14 @@ impl fmt::Display for Error { Self::InvalidSchemaVersion => { formatter.write_str("schema id version must be canonical positive vN") } + Self::SchemaVersionMismatch { + schema_id, + declared, + encoded, + } => write!( + formatter, + "schema id {schema_id} encodes v{encoded} but metadata declares v{declared}" + ), Self::DuplicateSchemaId { schema_id } => { write!(formatter, "duplicate schema id {schema_id}") } @@ -423,4 +488,37 @@ mod tests { }) ); } + + #[test] + fn metadata_registry_rejects_version_mismatch() { + let metadata = Metadata { + type_name: "EventDescriptor", + schema_id: "radroots.protocol.event_descriptor.v1", + schema_version: 2, + }; + assert_eq!( + Registry::try_from_metadata([(metadata, ModuleVersion::EventV1)]), + Err(Error::SchemaVersionMismatch { + schema_id: metadata.schema_id.into(), + declared: 2, + encoded: 1, + }) + ); + } + + #[test] + fn protocol_v1_registry_dispatches_all_migrated_schemas() { + let registry = protocol_v1_registry().expect("protocol V1 registry"); + assert_eq!(registry.len(), 5); + for descriptor in registry.descriptors() { + let expected = if descriptor.id().as_str().contains("event_descriptor") + || descriptor.id().as_str().contains("trade_state") + { + ModuleVersion::EventV1 + } else { + ModuleVersion::CapabilityV1 + }; + assert_eq!(registry.module_for(descriptor.id()), Some(expected)); + } + } } diff --git a/crates/protocol/tests/package_boundary.rs b/crates/protocol/tests/package_boundary.rs @@ -9,7 +9,7 @@ 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() { +fn manifest_has_final_identity_features_and_no_radroots_dependencies() { assert!(MANIFEST.contains("name = \"radroots_protocol\"")); assert!(MANIFEST.contains("version = \"0.1.0\"")); assert!(MANIFEST.contains("publish = false")); @@ -19,8 +19,14 @@ fn manifest_has_final_identity_features_and_no_dependencies() { table_keys(MANIFEST, "[features]"), BTreeSet::from(["default", "serde", "std"]) ); - assert_eq!(table_keys(MANIFEST, "[dependencies]"), BTreeSet::new()); - assert_eq!(table_keys(MANIFEST, "[dev-dependencies]"), BTreeSet::new()); + assert_eq!( + table_keys(MANIFEST, "[dependencies]"), + BTreeSet::from(["serde"]) + ); + assert_eq!( + table_keys(MANIFEST, "[dev-dependencies]"), + BTreeSet::from(["serde_json"]) + ); } #[test] @@ -37,7 +43,10 @@ fn crate_root_exposes_only_the_approved_versioned_skeleton() { "schema" ]) ); - for source in [CAPABILITY, ERROR, EVENT, RUNTIME] { + for source in [CAPABILITY, EVENT] { + assert!(source.lines().any(|line| line.trim() == "pub mod v1;")); + } + for source in [ERROR, RUNTIME] { assert!(source.lines().any(|line| line.trim() == "pub mod v1 {}")); } assert!(RADROOTSD.contains("pub mod transport_publish {")); @@ -60,9 +69,13 @@ fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { .take_while(|line| !line.trim_start().starts_with('[')) .filter_map(|line| { let line = line.trim(); - (!line.is_empty() && !line.starts_with('#')) - .then(|| line.split_once('=').map(|(key, _)| key.trim())) - .flatten() + (line + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_') + && !line.starts_with('#')) + .then(|| line.split_once('=').map(|(key, _)| key.trim())) + .flatten() }) .collect() } diff --git a/crates/protocol_contract_v1/Cargo.toml b/crates/protocol_contract_v1/Cargo.toml @@ -24,3 +24,10 @@ serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ], optional = true } + +[dev-dependencies] +radroots_protocol = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } +serde_json = { workspace = true, features = ["std"] } diff --git a/crates/protocol_contract_v1/src/lib.rs b/crates/protocol_contract_v1/src/lib.rs @@ -750,6 +750,18 @@ mod tests { .to_string(), alloc::format!("retired event name {name}") ); + let successor = radroots_protocol::event::v1::EventDescriptor { + name, + kind: u32::MAX, + event_class: radroots_protocol::event::v1::EventClass::Regular, + purpose: "retired", + }; + assert_eq!( + radroots_protocol::event::v1::validate_catalog(&[successor]) + .expect_err("successor retired event name") + .to_string(), + alloc::format!("retired event name {name}") + ); } for kind in RETIRED_PROTOCOL_EVENT_KINDS_V1 { @@ -765,6 +777,18 @@ mod tests { .to_string(), alloc::format!("retired event kind {kind}") ); + let successor = radroots_protocol::event::v1::EventDescriptor { + name: "synthetic_current_name", + kind: *kind, + event_class: radroots_protocol::event::v1::EventClass::Regular, + purpose: "retired", + }; + assert_eq!( + radroots_protocol::event::v1::validate_catalog(&[successor]) + .expect_err("successor retired event kind") + .to_string(), + alloc::format!("retired event kind {kind}") + ); } } diff --git a/crates/protocol_contract_v1/tests/successor_equivalence.rs b/crates/protocol_contract_v1/tests/successor_equivalence.rs @@ -0,0 +1,111 @@ +use std::collections::BTreeMap; + +use radroots_protocol::{ + capability::v1 as capability, + event::v1 as event, + schema::{ModuleVersion, protocol_v1_registry}, +}; +use radroots_protocol_contract_v1 as predecessor; + +#[test] +fn capability_and_event_catalog_json_is_byte_identical() { + assert_eq!( + serde_json::to_vec(predecessor::TRANSPORT_CAPABILITY_CATALOG_V1) + .expect("predecessor capability JSON"), + serde_json::to_vec(capability::CATALOG).expect("successor capability JSON") + ); + assert_eq!( + serde_json::to_vec(predecessor::PROTOCOL_EVENT_CATALOG_V1).expect("predecessor event JSON"), + serde_json::to_vec(event::CATALOG).expect("successor event JSON") + ); + assert_eq!( + serde_json::to_vec(predecessor::PROTOCOL_TRADE_STATE_VOCABULARY_V1) + .expect("predecessor trade state JSON"), + serde_json::to_vec(event::TRADE_STATE_VOCABULARY).expect("successor trade state JSON") + ); +} + +#[test] +fn capability_value_json_is_byte_identical() { + for (predecessor, successor) in [ + ( + predecessor::TransportKindV1::Local, + capability::TransportKind::Local, + ), + ( + predecessor::TransportKindV1::Nostr, + capability::TransportKind::Nostr, + ), + ( + predecessor::TransportKindV1::Reticulum, + capability::TransportKind::Reticulum, + ), + ] { + assert_eq!( + serde_json::to_vec(&predecessor).expect("predecessor transport JSON"), + serde_json::to_vec(&successor).expect("successor transport JSON") + ); + } + + let predecessor = predecessor::ReticulumTargetV1 { + destination: predecessor::ReticulumDestinationV1::parse("reticulum:local") + .expect("predecessor destination"), + mesh_scope: Some( + predecessor::MeshScopeIdV1::parse("local_preview").expect("predecessor scope"), + ), + }; + let successor = capability::ReticulumTarget { + destination: capability::ReticulumDestination::parse("reticulum:local") + .expect("successor destination"), + mesh_scope: Some(capability::MeshScopeId::parse("local_preview").expect("successor scope")), + }; + assert_eq!( + serde_json::to_vec(&predecessor).expect("predecessor target JSON"), + serde_json::to_vec(&successor).expect("successor target JSON") + ); +} + +#[test] +fn schema_metadata_bytes_and_dispatch_are_preserved() { + let predecessor = predecessor::PROTOCOL_SCHEMA_METADATA_V1 + .iter() + .map(|metadata| { + ( + metadata.schema_id, + serde_json::to_vec(metadata).expect("predecessor metadata JSON"), + ) + }) + .collect::<BTreeMap<_, _>>(); + let successor = capability::SCHEMAS + .iter() + .chain(event::SCHEMAS) + .map(|metadata| { + ( + metadata.schema_id, + serde_json::to_vec(metadata).expect("successor metadata JSON"), + ) + }) + .collect::<BTreeMap<_, _>>(); + assert_eq!(successor, predecessor); + + let registry = protocol_v1_registry().expect("successor schema registry"); + for descriptor in registry.descriptors() { + let module = registry + .module_for(descriptor.id()) + .expect("registered module"); + assert!(matches!( + module, + ModuleVersion::CapabilityV1 | ModuleVersion::EventV1 + )); + } +} + +#[test] +fn predecessor_and_successor_validation_are_green() { + predecessor::validate_protocol_contract_v1().expect("predecessor contract"); + capability::validate_catalog(capability::CATALOG).expect("successor capability catalog"); + event::validate_catalog(event::CATALOG).expect("successor event catalog"); + event::validate_trade_state_vocabulary(event::TRADE_STATE_VOCABULARY) + .expect("successor trade vocabulary"); + protocol_v1_registry().expect("successor schema registry"); +}