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:
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");
+}