lib

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

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:
MCHANGELOG.md | 4+++-
Mcontracts/releases/1.0.0-alpha.1.toml | 6++++++
Mcrates/trade/src/operational_listing/draft.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/trade/src/operational_listing/validation.rs | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
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(