lib

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

commit f93da764fe9ace20c63285819057665193100e6a
parent 06151e82346a8fb359fc79ba062470aeb49e039e
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Jul 2026 07:12:16 +0000

trade: remove the conflicting TradeId wrapper

- consume the singular canonical protocol TradeId from radroots_event
- introduce a separately validated business OrderId under the trade model
- require locators to carry protocol and business identities explicitly
- guard against duplicate definitions and implicit cross-identity conversions

Diffstat:
Mcrates/trade/src/identity.rs | 109++++++++++++++++++++++---------------------------------------------------------
Mcrates/trade/src/model.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/tests/package_boundary.rs | 21+++++++++++++++++++++
Mdocs/migration/trade-ids.md | 9+++++----
4 files changed, 254 insertions(+), 83 deletions(-)

diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs @@ -1,67 +1,21 @@ #![forbid(unsafe_code)] -use core::str::FromStr; - -use radroots_event::id::{ClassifiedListingAddress, EventId, OrderId, ParseError}; +use radroots_event::{ + id::{ClassifiedListingAddress, EventId}, + trade::TradeId, +}; use radroots_identity::PublicKey; -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct TradeId(OrderId); - -impl TradeId { - pub fn parse(value: impl AsRef<str>) -> Result<Self, ParseError> { - OrderId::parse(value).map(Self) - } - - pub fn as_order_id(&self) -> &OrderId { - &self.0 - } - - pub fn into_order_id(self) -> OrderId { - self.0 - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl From<OrderId> for TradeId { - fn from(order_id: OrderId) -> Self { - Self(order_id) - } -} - -impl From<TradeId> for OrderId { - fn from(trade_id: TradeId) -> Self { - trade_id.into_order_id() - } -} - -impl AsRef<str> for TradeId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl FromStr for TradeId { - type Err = ParseError; - - fn from_str(value: &str) -> Result<Self, Self::Err> { - Self::parse(value) - } -} +use crate::model::OrderId; #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "dto-bindgen", dto(export))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeLocator { + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] pub trade_id: TradeId, + pub order_id: Option<OrderId>, #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] pub root_event_id: Option<EventId>, #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] @@ -73,9 +27,10 @@ pub struct RadrootsTradeLocator { } impl RadrootsTradeLocator { - pub fn new(trade_id: impl Into<TradeId>) -> Self { + pub fn new(trade_id: TradeId) -> Self { Self { - trade_id: trade_id.into(), + trade_id, + order_id: None, root_event_id: None, listing_addr: None, buyer_pubkey: None, @@ -83,12 +38,9 @@ impl RadrootsTradeLocator { } } - pub fn from_order_id(order_id: OrderId) -> Self { - Self::new(order_id) - } - - pub fn order_id(&self) -> &OrderId { - self.trade_id.as_order_id() + pub fn with_order_id(mut self, order_id: OrderId) -> Self { + self.order_id = Some(order_id); + self } pub fn with_root_event_id(mut self, root_event_id: EventId) -> Self { @@ -117,7 +69,9 @@ impl RadrootsTradeLocator { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTradeLocatorCandidate { + #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] pub trade_id: TradeId, + pub order_id: OrderId, #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] pub root_event_id: EventId, #[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] @@ -131,7 +85,8 @@ pub struct RadrootsTradeLocatorCandidate { impl RadrootsTradeLocatorCandidate { pub fn locator(&self) -> RadrootsTradeLocator { RadrootsTradeLocator { - trade_id: self.trade_id.clone(), + trade_id: self.trade_id, + order_id: Some(self.order_id.clone()), root_event_id: Some(self.root_event_id), listing_addr: Some(self.listing_addr.clone()), buyer_pubkey: Some(self.buyer_pubkey), @@ -157,6 +112,10 @@ mod tests { OrderId::parse("order-1").expect("order id") } + fn trade_id() -> TradeId { + TradeId::parse("11".repeat(16)).expect("trade id") + } + fn public_key(raw: &str) -> PublicKey { PublicKey::from_hex(raw).expect("public key") } @@ -169,28 +128,18 @@ mod tests { } #[test] - fn trade_id_and_locator_accessors_cover_public_surface() { + fn protocol_trade_and_business_order_ids_remain_distinct() { let order_id = order_id(); - let trade_id = TradeId::parse(order_id.as_str()).expect("trade id"); - - assert_eq!(trade_id.as_order_id(), &order_id); - assert_eq!(trade_id.as_str(), "order-1"); - assert_eq!(trade_id.as_ref(), "order-1"); - assert_eq!(TradeId::from_str("order-1").unwrap(), trade_id); - assert!(TradeId::parse(" ").is_err()); - assert_eq!( - OrderId::from(trade_id.clone()), - trade_id.clone().into_order_id() - ); - - let locator = RadrootsTradeLocator::from_order_id(order_id.clone()) + let trade_id = trade_id(); + let locator = RadrootsTradeLocator::new(trade_id) + .with_order_id(order_id.clone()) .with_root_event_id(event_id(1)) .with_listing_addr(listing_addr()) .with_buyer_pubkey(public_key(BUYER)) .with_seller_pubkey(public_key(SELLER)); - assert_eq!(locator.order_id(), &order_id); - assert_eq!(locator.trade_id.as_order_id(), &order_id); + assert_eq!(locator.trade_id, trade_id); + assert_eq!(locator.order_id.as_ref(), Some(&order_id)); assert_eq!(locator.root_event_id, Some(event_id(1))); assert_eq!(locator.listing_addr, Some(listing_addr())); assert_eq!(locator.buyer_pubkey, Some(public_key(BUYER))); @@ -200,7 +149,8 @@ mod tests { #[test] fn locator_candidate_converts_to_specific_locator() { let candidate = RadrootsTradeLocatorCandidate { - trade_id: order_id().into(), + trade_id: trade_id(), + order_id: order_id(), root_event_id: event_id(1), listing_addr: listing_addr(), buyer_pubkey: public_key(BUYER), @@ -210,6 +160,7 @@ mod tests { let locator = candidate.locator(); assert_eq!(locator.trade_id, candidate.trade_id); + assert_eq!(locator.order_id, Some(candidate.order_id)); assert_eq!(locator.root_event_id, Some(candidate.root_event_id)); assert_eq!(locator.listing_addr, Some(candidate.listing_addr)); assert_eq!(locator.buyer_pubkey, Some(candidate.buyer_pubkey)); diff --git a/crates/trade/src/model.rs b/crates/trade/src/model.rs @@ -1 +1,199 @@ //! Native trade-domain models and validated value types. + +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString}; +#[cfg(feature = "std")] +use std::string::String; + +use core::{fmt, str::FromStr}; + +/// Maximum encoded length of a human or business order identifier. +pub const ORDER_ID_MAX_LEN: usize = 128; + +/// A human or business-workflow order identifier. +/// +/// This identifier is deliberately distinct from the canonical protocol +/// [`radroots_event::trade::TradeId`]. No conversion exists between them. +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "dto-bindgen", dto(as = "string"))] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OrderId(String); + +impl OrderId { + /// Parses a non-empty, whitespace-free business identifier. + pub fn parse(value: impl AsRef<str>) -> Result<Self, OrderIdError> { + let value = value.as_ref(); + if value.is_empty() { + return Err(OrderIdError::Empty); + } + if value.len() > ORDER_ID_MAX_LEN { + return Err(OrderIdError::TooLong { + max: ORDER_ID_MAX_LEN, + actual: value.len(), + }); + } + if value.trim() != value + || value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(OrderIdError::InvalidCharacter); + } + Ok(Self(value.to_string())) + } + + /// Returns the validated business identifier. + #[inline] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Consumes the identifier and returns its owned representation. + #[inline] + pub fn into_string(self) -> String { + self.0 + } +} + +impl AsRef<str> for OrderId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl From<OrderId> for String { + fn from(value: OrderId) -> Self { + value.into_string() + } +} + +impl fmt::Display for OrderId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for OrderId { + type Err = OrderIdError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl TryFrom<&str> for OrderId { + type Error = OrderIdError; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl TryFrom<String> for OrderId { + type Error = OrderIdError; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for OrderId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for OrderId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = <String as serde::Deserialize>::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// Business order identifier validation failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrderIdError { + /// The identifier was empty. + Empty, + /// The identifier exceeded its bounded encoded length. + TooLong { max: usize, actual: usize }, + /// The identifier contained whitespace or a control character. + InvalidCharacter, +} + +impl fmt::Display for OrderIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => formatter.write_str("order identifier is empty"), + Self::TooLong { max, actual } => write!( + formatter, + "order identifier length {actual} exceeds maximum length {max}" + ), + Self::InvalidCharacter => { + formatter.write_str("order identifier contains an invalid character") + } + } + } +} + +impl core::error::Error for OrderIdError {} + +#[cfg(test)] +mod tests { + use core::str::FromStr; + + use super::{ORDER_ID_MAX_LEN, OrderId, OrderIdError}; + + #[test] + fn order_id_parses_business_values_without_becoming_a_protocol_trade_id() { + let order_id = OrderId::parse("order-1").expect("business order id"); + + assert_eq!(order_id.as_str(), "order-1"); + assert_eq!(order_id.as_ref(), "order-1"); + assert_eq!(order_id.to_string(), "order-1"); + assert_eq!(OrderId::try_from("order-1").unwrap(), order_id); + assert_eq!(OrderId::from_str("order-1").unwrap(), order_id); + assert_eq!(String::from(order_id.clone()), order_id.into_string()); + assert!(radroots_event::trade::TradeId::parse("order-1").is_err()); + } + + #[test] + fn order_id_rejects_invalid_business_values() { + assert_eq!(OrderId::parse("").unwrap_err(), OrderIdError::Empty); + assert_eq!( + OrderId::parse("x".repeat(ORDER_ID_MAX_LEN + 1)).unwrap_err(), + OrderIdError::TooLong { + max: ORDER_ID_MAX_LEN, + actual: ORDER_ID_MAX_LEN + 1, + } + ); + for value in [" order-1", "order-1 ", "order 1", "order\n1"] { + assert_eq!( + OrderId::parse(value).unwrap_err(), + OrderIdError::InvalidCharacter + ); + } + } + + #[cfg(feature = "serde_json")] + #[test] + fn order_id_serde_round_trip_preserves_validation() { + let order_id = OrderId::parse("order-1").expect("business order id"); + let encoded = serde_json::to_string(&order_id).expect("serialize order id"); + + assert_eq!(encoded, "\"order-1\""); + assert_eq!( + serde_json::from_str::<OrderId>(&encoded).expect("deserialize order id"), + order_id + ); + assert!(serde_json::from_str::<OrderId>("\"bad order\"").is_err()); + } +} diff --git a/crates/trade/tests/package_boundary.rs b/crates/trade/tests/package_boundary.rs @@ -4,6 +4,8 @@ use std::collections::BTreeSet; use radroots_trade::{evidence as _, model as _, reducer as _, validation as _, workflow as _}; const MANIFEST: &str = include_str!("../Cargo.toml"); +const IDENTITY: &str = include_str!("../src/identity.rs"); +const MODEL: &str = include_str!("../src/model.rs"); const ROOT: &str = include_str!("../src/lib.rs"); const PACKAGE_TIERS: &str = include_str!("../../../contracts/releases/package_tiers.toml"); @@ -51,6 +53,25 @@ fn expired_upward_development_dependencies_are_absent() { } } +#[test] +fn protocol_trade_id_is_singular_and_business_order_id_is_distinct() { + let trade_id = radroots_event::trade::TradeId::parse("11".repeat(16)) + .expect("canonical protocol trade id"); + let order_id = radroots_trade::model::OrderId::parse("order-1").expect("business order id"); + let locator = radroots_trade::identity::RadrootsTradeLocator::new(trade_id) + .with_order_id(order_id.clone()); + + assert_eq!(locator.trade_id, trade_id); + assert_eq!(locator.order_id, Some(order_id)); + assert!(!IDENTITY.contains("pub struct TradeId")); + assert!(!IDENTITY.contains("pub type TradeId")); + assert!(!IDENTITY.contains("From<OrderId> for TradeId")); + assert!(!IDENTITY.contains("From<TradeId> for OrderId")); + assert!(IDENTITY.contains("trade::TradeId")); + assert!(MODEL.contains("pub struct OrderId(String);")); + assert!(MODEL.contains("No conversion exists between them.")); +} + fn table_keys<'a>(manifest: &'a str, heading: &str) -> BTreeSet<&'a str> { let Some((_, table)) = manifest.split_once(heading) else { return BTreeSet::new(); diff --git a/docs/migration/trade-ids.md b/docs/migration/trade-ids.md @@ -12,10 +12,11 @@ Import these types from `radroots_event::trade`. The deliberate canonical event-bound identifiers. Both paths resolve to the same definitions; neither path introduces a wrapper or conversion. -`OrderId` is a separate human or business-workflow identifier. It is not an -alias for `TradeId`, and protocol code must not infer or construct a `TradeId` -from an `OrderId`. Persisted and wire boundaries encode protocol IDs as -lowercase hexadecimal only through their explicit `to_hex` and parsing APIs. +`radroots_trade::model::OrderId` is the separate human or business-workflow +identifier. It is not an alias for `TradeId`, and no conversion exists between +them. Protocol code must not infer or construct a `TradeId` from an `OrderId`. +Persisted and wire boundaries encode protocol IDs as lowercase hexadecimal +only through their explicit `to_hex` and parsing APIs. The algorithm package's former `TradeId(OrderId)` wrapper is removed at its ordered package-refactor checkpoint. New event-domain code must use the