commit d5f99e80c27330e0a5dca237cf1e22a7dfa6cb49
parent 2a3cf2042e25621b3d2a6183471170c5327aed2b
Author: triesap <tyson@radroots.org>
Date: Mon, 20 Jul 2026 00:34:00 +0000
trade: enforce canonical listing semantics
- validate complete Operational Listing models before draft construction
- reject duplicate IDs and invalid semantics across every bin
- preserve typed validation causes for downstream callers
- record the breaking authoring behavior in release metadata
Diffstat:
4 files changed, 230 insertions(+), 33 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -59,7 +59,9 @@ publish policy both pass for the same source revision.
semantic reducer and a signature-verified event boundary that delegates to
it after kind, marker-partition, and decoding checks. Event-store projection
reconstructs and verifies the event typestate instead of trusting a plain
- stored envelope.
+ stored envelope. Canonical authoring now invokes that reducer before draft
+ construction and preserves its typed failure cause; the reducer rejects
+ duplicate bin IDs and invalid quantity or price semantics in every bin.
- Generic NIP-01 identifier and signature verification is now independent of
knowledge decoding, and every dynamic Nostr kind conversion rejects values
above `65535` instead of truncating them. Canonical-length author keys that
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -314,3 +314,9 @@ id = "shared-read-only-owner-boundaries"
classification = "feature"
semver_impacts = ["add_exported_function"]
summary = "Expose one Operational Listing model-semantic validator shared by unsigned authoring tools and verified event validation, plus one non-migrating event-store status inspector shared by runtime and offline consumers."
+
+[[changes]]
+id = "operational-listing-authoring-validation"
+classification = "breaking"
+semver_impacts = ["add_enum_variant", "change_exported_algorithm_behavior"]
+summary = "Require every canonical Operational Listing edit to pass the shared model-semantic validator, reject duplicate IDs and invalid quantity or price semantics across every bin, and return the typed validation cause before draft construction, signing, or durable workflow mutation."
diff --git a/crates/trade/src/operational_listing/draft.rs b/crates/trade/src/operational_listing/draft.rs
@@ -19,8 +19,11 @@ use radroots_event::{
},
kinds::KIND_CLASSIFIED_LISTING,
operational_listing::RadrootsOperationalListing,
+ trade_validation::RadrootsOperationalListingValidationError,
};
+use super::validation::validate_operational_listing_model;
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub struct RadrootsOperationalListingEditDocumentV1 {
@@ -56,6 +59,9 @@ impl RadrootsOperationalListingCanonicalEdit {
}
listing.farm.pubkey = farm_pubkey.as_str().to_string();
validate_listing_bins(&listing)?;
+ let listing = validate_operational_listing_model(listing, &seller_pubkey)
+ .map_err(RadrootsOperationalListingEditError::InvalidModel)?
+ .listing;
let public_listing_addr = listing_addr(
KIND_CLASSIFIED_LISTING,
@@ -87,6 +93,7 @@ impl RadrootsOperationalListingCanonicalEdit {
pub enum RadrootsOperationalListingEditError {
InvalidFarmPubkey(RadrootsIdParseError),
InvalidClassifiedListingAddress(RadrootsIdParseError),
+ InvalidModel(RadrootsOperationalListingValidationError),
ActorRoleUnsatisfied {
required_role: RadrootsActorRole,
},
@@ -111,6 +118,9 @@ impl fmt::Display for RadrootsOperationalListingEditError {
Self::InvalidClassifiedListingAddress(error) => {
write!(f, "invalid listing edit address: {error}")
}
+ Self::InvalidModel(error) => {
+ write!(f, "invalid listing edit model: {error}")
+ }
Self::ActorRoleUnsatisfied { required_role } => write!(
f,
"listing edit actor does not satisfy required role {required_role:?}"
@@ -197,9 +207,12 @@ mod tests {
},
kinds::KIND_CLASSIFIED_LISTING,
operational_listing::{
- RadrootsOperationalListing, RadrootsOperationalListingBin,
- RadrootsOperationalListingProduct,
+ RadrootsOperationalListing, RadrootsOperationalListingAvailability,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation,
+ RadrootsOperationalListingStatus,
},
+ trade_validation::RadrootsOperationalListingValidationError,
};
use super::{
@@ -263,10 +276,18 @@ mod tests {
resource_area: None,
plot: None,
discounts: None,
- inventory_available: None,
- availability: None,
- delivery_method: None,
- location: None,
+ inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
+ availability: Some(RadrootsOperationalListingAvailability::Status {
+ status: RadrootsOperationalListingStatus::Active,
+ }),
+ delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup),
+ location: Some(RadrootsOperationalListingPublicLocation {
+ primary: "Victoria".to_string(),
+ city: Some("Victoria".to_string()),
+ region: Some("British Columbia".to_string()),
+ country: Some("CA".to_string()),
+ geohash: "c287g".to_string(),
+ }),
images: None,
}
}
@@ -497,6 +518,66 @@ mod tests {
}
#[test]
+ fn canonical_draft_new_rejects_invalid_model() {
+ let mut listing = listing();
+ listing.inventory_available = None;
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::InvalidModel(
+ RadrootsOperationalListingValidationError::MissingInventory
+ )
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_invalid_secondary_bin() {
+ let mut invalid_quantity_listing = listing();
+ let mut secondary_bin = invalid_quantity_listing.bins[0].clone();
+ secondary_bin.bin_id = bin_id("bin-2");
+ secondary_bin.quantity.amount = "-1".parse().expect("negative decimal");
+ invalid_quantity_listing.bins.push(secondary_bin);
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ invalid_quantity_listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::InvalidModel(
+ RadrootsOperationalListingValidationError::InvalidBin
+ )
+ );
+
+ let mut mismatched_unit_listing = listing();
+ let mut secondary_bin = mismatched_unit_listing.bins[0].clone();
+ secondary_bin.bin_id = bin_id("bin-2");
+ secondary_bin.price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each;
+ mismatched_unit_listing.bins.push(secondary_bin);
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ mismatched_unit_listing,
+ RadrootsPublicKey::parse(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::InvalidModel(
+ RadrootsOperationalListingValidationError::InvalidPrice
+ )
+ );
+ }
+
+ #[test]
fn canonical_draft_new_rejects_duplicate_bin_ids() {
let mut listing = listing();
listing.bins.push(listing.bins[0].clone());
diff --git a/crates/trade/src/operational_listing/validation.rs b/crates/trade/src/operational_listing/validation.rs
@@ -16,7 +16,8 @@ use radroots_event::{
location::{has_textual_locality, is_public_geohash5},
operational_listing::{
RadrootsOperationalListing, RadrootsOperationalListingAvailability,
- RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingPublicLocation,
+ RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod,
+ RadrootsOperationalListingPublicLocation,
},
trade_validation::RadrootsOperationalListingValidationError as OperationalListingValidationError,
};
@@ -129,34 +130,26 @@ pub fn validate_operational_listing_model(
return Err(OperationalListingValidationError::MissingBins);
}
let primary_bin_id = listing.primary_bin_id.trim().to_string();
- let primary_bin = listing
+ let primary_bin_index = listing
.bins
.iter()
- .find(|bin| bin.bin_id == primary_bin_id)
+ .position(|bin| bin.bin_id == primary_bin_id)
.ok_or(OperationalListingValidationError::MissingPrimaryBin)?;
-
- if primary_bin.quantity.amount.is_sign_negative() {
- return Err(OperationalListingValidationError::InvalidBin);
- }
- if !primary_bin.quantity.is_canonical() {
- return Err(OperationalListingValidationError::InvalidBin);
- }
- if !primary_bin
- .price_per_canonical_unit
- .is_price_per_canonical_unit()
- {
- return Err(OperationalListingValidationError::InvalidPrice);
- }
- if primary_bin
- .price_per_canonical_unit
- .amount
- .amount
- .is_sign_negative()
- {
- return Err(OperationalListingValidationError::InvalidPrice);
+ for (index, bin) in listing.bins.iter().enumerate() {
+ if listing.bins[..index]
+ .iter()
+ .any(|seen| seen.bin_id == bin.bin_id)
+ {
+ return Err(OperationalListingValidationError::InvalidBin);
+ }
}
- if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit {
- return Err(OperationalListingValidationError::InvalidPrice);
+ let primary_bin = &listing.bins[primary_bin_index];
+
+ validate_listing_bin(primary_bin)?;
+ for (index, bin) in listing.bins.iter().enumerate() {
+ if index != primary_bin_index {
+ validate_listing_bin(bin)?;
+ }
}
let inventory_available = listing
@@ -207,6 +200,25 @@ pub fn validate_operational_listing_model(
})
}
+fn validate_listing_bin(
+ bin: &RadrootsOperationalListingBin,
+) -> Result<(), OperationalListingValidationError> {
+ if bin.quantity.amount.is_sign_negative() || !bin.quantity.is_canonical() {
+ return Err(OperationalListingValidationError::InvalidBin);
+ }
+ if !bin.price_per_canonical_unit.is_price_per_canonical_unit()
+ || bin
+ .price_per_canonical_unit
+ .amount
+ .amount
+ .is_sign_negative()
+ || bin.price_per_canonical_unit.quantity.unit != bin.quantity.unit
+ {
+ return Err(OperationalListingValidationError::InvalidPrice);
+ }
+ Ok(())
+}
+
#[cfg_attr(coverage_nightly, coverage(off))]
fn validate_listing_location_geohash(
geohash: &str,
@@ -469,6 +481,41 @@ mod tests {
assert_eq!(format!("{err}"), format!("{expected}"));
}
+ fn assert_secondary_bin_model_error(
+ update: impl FnOnce(&mut RadrootsOperationalListingBin),
+ expected: OperationalListingValidationError,
+ ) {
+ let mut listing = listing_with_secondary_bin();
+ update(&mut listing.bins[1]);
+
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("invalid secondary bin"),
+ expected
+ );
+ }
+
+ fn listing_with_secondary_bin() -> RadrootsOperationalListing {
+ let mut listing = base_listing();
+ let mut secondary_bin = listing.bins[0].clone();
+ secondary_bin.bin_id = bin_id("bin-2");
+ listing.bins.push(secondary_bin);
+ listing
+ }
+
+ fn assert_model_and_signed_event_error(
+ listing: RadrootsOperationalListing,
+ expected: OperationalListingValidationError,
+ ) {
+ let event_error = validate_operational_listing_event(&base_event(&listing))
+ .expect_err("signed event semantic error");
+ let model_error = validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("model semantic error");
+
+ assert_eq!(event_error, expected);
+ assert_eq!(model_error, event_error);
+ }
+
#[test]
fn validate_listing_ok() {
let listing = base_listing();
@@ -479,7 +526,7 @@ mod tests {
#[test]
#[cfg(feature = "serde_json")]
fn model_and_verified_event_validation_return_the_same_projection() {
- let listing = base_listing();
+ let listing = listing_with_secondary_bin();
let event_projection =
validate_operational_listing_event(&base_event(&listing)).expect("event projection");
let model_projection = validate_operational_listing_model(listing, &seller_pubkey())
@@ -513,6 +560,18 @@ mod tests {
let model_error = validate_operational_listing_model(listing, &other_seller_pubkey())
.expect_err("model seller error");
assert_eq!(model_error, event_error);
+
+ let mut listing = listing_with_secondary_bin();
+ listing.bins[1].quantity.amount = "-1".parse().expect("negative decimal");
+ assert_model_and_signed_event_error(listing, OperationalListingValidationError::InvalidBin);
+
+ let mut listing = listing_with_secondary_bin();
+ listing.bins[1].price_per_canonical_unit.amount.amount =
+ "-1".parse().expect("negative decimal");
+ assert_model_and_signed_event_error(
+ listing,
+ OperationalListingValidationError::InvalidPrice,
+ );
}
#[test]
@@ -534,6 +593,14 @@ mod tests {
);
let mut listing = base_listing();
+ listing.bins.push(listing.bins[0].clone());
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("duplicate bin ID"),
+ OperationalListingValidationError::InvalidBin
+ );
+
+ let mut listing = base_listing();
listing.location.as_mut().expect("location").geohash = " ".into();
assert_eq!(
validate_operational_listing_model(listing, &seller_pubkey())
@@ -551,6 +618,47 @@ mod tests {
}
#[test]
+ fn model_validation_rejects_invalid_secondary_bin_quantities() {
+ assert_secondary_bin_model_error(
+ |bin| bin.quantity.amount = "-1".parse().expect("negative decimal"),
+ OperationalListingValidationError::InvalidBin,
+ );
+ assert_secondary_bin_model_error(
+ |bin| bin.quantity.unit = RadrootsCoreUnit::MassKg,
+ OperationalListingValidationError::InvalidBin,
+ );
+ }
+
+ #[test]
+ fn model_validation_rejects_invalid_secondary_bin_prices() {
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit.quantity.amount = RadrootsCoreDecimal::from(2u32);
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg;
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit.amount.amount =
+ "-1".parse().expect("negative decimal");
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each;
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ }
+
+ #[test]
fn validate_listing_rejects_retired_kind() {
let listing = base_listing();
let event = event_with_parts(