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:
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