cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 78412ac9835332fe05f08638391aa618ce96a815
parent 4ec2fbfd6b8e6e59bb7c39cbfe95423733ad2310
Author: triesap <tyson@radroots.org>
Date:   Tue, 26 May 2026 10:27:23 +0000

cli: enforce verified primary bin gates

- require protocol-valid readiness for verified primary bins
- make basket readiness check replica primary-bin integrity
- block stale order submit on invalid verified bin state
- cover invalid basket quote and stale submit paths

Diffstat:
Msrc/domain/runtime.rs | 5+++--
Msrc/operation_basket.rs | 308++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/runtime/order.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 412 insertions(+), 51 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1095,7 +1095,8 @@ impl MarketReadinessView { let inventory_available = available_amount.is_some_and(|amount| amount > 0); let primary_bin_available = primary_bin_id.is_some_and(|primary_bin_id| !primary_bin_id.trim().is_empty()); - let primary_bin_verified = primary_bin_available + let primary_bin_verified = protocol_valid + && primary_bin_available && primary_bin_id.is_some_and(|primary_bin_id| { verified_primary_bin_id.is_some_and(|verified_primary_bin_id| { verified_primary_bin_id.trim() == primary_bin_id.trim() @@ -1178,7 +1179,7 @@ mod market_readiness_tests { assert!(!invalid.protocol_valid); assert!(!invalid.marketplace_eligible); assert!(!invalid.checkout_enabled); - assert!(invalid.primary_bin_verified); + assert!(!invalid.primary_bin_verified); assert_eq!(invalid.reason_codes, vec!["listing_protocol_invalid"]); let ineligible = MarketReadinessView::from_market_projection( diff --git a/src/operation_basket.rs b/src/operation_basket.rs @@ -4,6 +4,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use radroots_events::trade::RadrootsTradeOrderEconomics; +use radroots_replica_db::{ReplicaSql, trade_product}; +use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; +use radroots_sql_core::SqliteExecutor; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -89,6 +92,7 @@ struct BasketQuote { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct BasketIssue { + code: String, field: String, message: String, } @@ -99,6 +103,12 @@ struct LoadedBasket { document: BasketDocument, } +#[derive(Debug, Clone)] +struct BasketProductBinState { + primary_bin_id: Option<String>, + verified_primary_bin_id: Option<String>, +} + pub struct BasketOperationService<'a> { config: &'a RuntimeConfig, } @@ -150,7 +160,12 @@ impl OperationService<BasketCreateRequest> for BasketOperationService<'_> { quote: None, }; save_basket(file.as_path(), &document)?; - json_operation_result::<BasketCreateResult>(basket_view(&document, file.as_path(), None)) + json_operation_result::<BasketCreateResult>(basket_view( + self.config, + &document, + file.as_path(), + None, + )?) } } @@ -169,10 +184,11 @@ impl OperationService<BasketGetRequest> for BasketOperationService<'_> { )); }; json_operation_result::<BasketGetResult>(basket_view( + self.config, &loaded.document, loaded.file.as_path(), None, - )) + )?) } } @@ -224,10 +240,11 @@ impl OperationService<BasketItemAddRequest> for BasketOperationService<'_> { loaded.document.quote = None; save_basket(loaded.file.as_path(), &loaded.document)?; json_operation_result::<BasketItemAddResult>(basket_view( + self.config, &loaded.document, loaded.file.as_path(), Some("updated"), - )) + )?) } } @@ -272,10 +289,11 @@ impl OperationService<BasketItemUpdateRequest> for BasketOperationService<'_> { loaded.document.quote = None; save_basket(loaded.file.as_path(), &loaded.document)?; json_operation_result::<BasketItemUpdateResult>(basket_view( + self.config, &loaded.document, loaded.file.as_path(), Some("updated"), - )) + )?) } } @@ -318,10 +336,11 @@ impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> { loaded.document.quote = None; save_basket(loaded.file.as_path(), &loaded.document)?; json_operation_result::<BasketItemRemoveResult>(basket_view( + self.config, &loaded.document, loaded.file.as_path(), Some("updated"), - )) + )?) } } @@ -363,10 +382,11 @@ impl OperationService<BasketAdjustmentAddRequest> for BasketOperationService<'_> loaded.document.quote = None; save_basket(loaded.file.as_path(), &loaded.document)?; json_operation_result::<BasketAdjustmentAddResult>(basket_view( + self.config, &loaded.document, loaded.file.as_path(), Some("updated"), - )) + )?) } } @@ -408,10 +428,11 @@ impl OperationService<BasketAdjustmentRemoveRequest> for BasketOperationService< loaded.document.quote = None; save_basket(loaded.file.as_path(), &loaded.document)?; json_operation_result::<BasketAdjustmentRemoveResult>(basket_view( + self.config, &loaded.document, loaded.file.as_path(), Some("updated"), - )) + )?) } } @@ -430,9 +451,10 @@ impl OperationService<BasketValidateRequest> for BasketOperationService<'_> { )); }; json_operation_result::<BasketValidateResult>(basket_validation_view( + self.config, &loaded.document, loaded.file.as_path(), - )) + )?) } } @@ -446,8 +468,9 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { let basket_id = required_basket_id(&request)?; let mut loaded = load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; - let issues = basket_issues(&loaded.document); + let issues = basket_issues(self.config, &loaded.document)?; if !issues.is_empty() { + let actions = basket_actions(&loaded.document, issues.as_slice()); return json_operation_result::<BasketQuoteCreateResult>(json!({ "state": "unconfigured", "source": BASKET_QUOTE_SOURCE, @@ -455,7 +478,7 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { "file": loaded.file.display().to_string(), "ready_for_quote": false, "issues": issues, - "actions": basket_actions(&loaded.document), + "actions": actions, })); } @@ -685,8 +708,16 @@ where }) } -fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> Value { - json!({ +fn basket_view( + config: &RuntimeConfig, + document: &BasketDocument, + file: &Path, + state: Option<&str>, +) -> Result<Value, OperationAdapterError> { + let issues = basket_issues(config, document)?; + let ready_for_quote = issues.is_empty(); + let actions = basket_actions(document, issues.as_slice()); + Ok(json!({ "state": state.unwrap_or("ready"), "source": BASKET_SOURCE, "basket_id": document.basket.basket_id, @@ -696,25 +727,31 @@ fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> V "adjustment_count": document.basket.adjustments.len(), "adjustments": document.basket.adjustments, "quote": document.quote, - "ready_for_quote": basket_issues(document).is_empty(), - "issues": basket_issues(document), - "actions": basket_actions(document), - }) + "ready_for_quote": ready_for_quote, + "issues": issues, + "actions": actions, + })) } -fn basket_validation_view(document: &BasketDocument, file: &Path) -> Value { - let issues = basket_issues(document); - json!({ - "state": if issues.is_empty() { "ready" } else { "unconfigured" }, +fn basket_validation_view( + config: &RuntimeConfig, + document: &BasketDocument, + file: &Path, +) -> Result<Value, OperationAdapterError> { + let issues = basket_issues(config, document)?; + let ready_for_quote = issues.is_empty(); + let actions = basket_actions(document, issues.as_slice()); + Ok(json!({ + "state": if ready_for_quote { "ready" } else { "unconfigured" }, "source": BASKET_SOURCE, "basket_id": document.basket.basket_id, "file": file.display().to_string(), - "ready_for_quote": issues.is_empty(), + "ready_for_quote": ready_for_quote, "item_count": document.basket.items.len(), "adjustment_count": document.basket.adjustments.len(), "issues": issues, - "actions": basket_actions(document), - }) + "actions": actions, + })) } fn missing_basket_view(config: &RuntimeConfig, lookup: &str) -> Value { @@ -749,13 +786,16 @@ fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, Operation continue; } let loaded = load_basket_path(path.as_path())?; + let issues = basket_issues(config, &loaded.document)?; + let ready_for_quote = issues.is_empty(); baskets.push(json!({ "basket_id": loaded.document.basket.basket_id, - "state": if basket_issues(&loaded.document).is_empty() { "ready" } else { "unconfigured" }, + "state": if ready_for_quote { "ready" } else { "unconfigured" }, "file": loaded.file.display().to_string(), "item_count": loaded.document.basket.items.len(), "adjustment_count": loaded.document.basket.adjustments.len(), - "ready_for_quote": basket_issues(&loaded.document).is_empty(), + "ready_for_quote": ready_for_quote, + "issues": issues, "quote": loaded.document.quote, "updated_at_unix": loaded.document.basket.updated_at_unix, })); @@ -774,49 +814,218 @@ fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, Operation Ok(baskets) } -fn basket_issues(document: &BasketDocument) -> Vec<BasketIssue> { +fn basket_issues( + config: &RuntimeConfig, + document: &BasketDocument, +) -> Result<Vec<BasketIssue>, OperationAdapterError> { let mut issues = Vec::new(); if document.basket.items.is_empty() { - issues.push(BasketIssue { - field: "basket.items".to_owned(), - message: "basket must contain one item before quote creation".to_owned(), - }); + issues.push(basket_issue( + "basket_items_missing", + "basket.items", + "basket must contain one item before quote creation", + )); } if document.basket.items.len() > 1 { - issues.push(BasketIssue { - field: "basket.items".to_owned(), - message: "basket quotes support exactly one item".to_owned(), - }); + issues.push(basket_issue( + "basket_items_unsupported", + "basket.items", + "basket quotes support exactly one item", + )); } for item in &document.basket.items { if item.listing.is_none() && item.listing_addr.is_none() { - issues.push(BasketIssue { - field: format!("basket.items.{}.listing", item.item_id), - message: "item must include listing or listing_addr".to_owned(), - }); + issues.push(basket_issue( + "basket_item_listing_missing", + format!("basket.items.{}.listing", item.item_id), + "item must include listing or listing_addr", + )); } if item.bin_id.trim().is_empty() { - issues.push(BasketIssue { - field: format!("basket.items.{}.bin_id", item.item_id), - message: "item must include bin_id".to_owned(), - }); + issues.push(basket_issue( + "basket_item_bin_missing", + format!("basket.items.{}.bin_id", item.item_id), + "item must include bin_id", + )); } if item.quantity == 0 { - issues.push(BasketIssue { - field: format!("basket.items.{}.quantity", item.item_id), - message: "item quantity must be greater than 0".to_owned(), - }); + issues.push(basket_issue( + "basket_item_quantity_invalid", + format!("basket.items.{}.quantity", item.item_id), + "item quantity must be greater than 0", + )); } } - issues + if issues.is_empty() { + issues.extend(basket_market_issues(config, document)?); + } + Ok(issues) +} + +fn basket_market_issues( + config: &RuntimeConfig, + document: &BasketDocument, +) -> Result<Vec<BasketIssue>, OperationAdapterError> { + if !config.local.replica_db_path.exists() { + return Ok(Vec::new()); + } + let executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| { + OperationAdapterError::Runtime(format!( + "open local replica {}: {error}", + config.local.replica_db_path.display() + )) + })?; + let mut issues = Vec::new(); + for item in &document.basket.items { + let Some(product) = basket_product_bin_state(config, &executor, item)? else { + continue; + }; + let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { + issues.push(basket_issue( + "listing_primary_bin_missing", + format!("basket.items.{}.bin_id", item.item_id), + "current local replica listing primary bin is required before quote creation", + )); + continue; + }; + let Some(verified_primary_bin_id) = product + .verified_primary_bin_id + .as_deref() + .and_then(non_empty_ref) + else { + issues.push(basket_issue( + "listing_primary_bin_invalid", + format!("basket.items.{}.bin_id", item.item_id), + format!("current local replica primary bin `{primary_bin_id}` is not verified"), + )); + continue; + }; + if verified_primary_bin_id != primary_bin_id { + issues.push(basket_issue( + "listing_primary_bin_invalid", + format!("basket.items.{}.bin_id", item.item_id), + format!( + "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`" + ), + )); + continue; + } + if item.bin_id != primary_bin_id { + issues.push(basket_issue( + "order_bin_unknown", + format!("basket.items.{}.bin_id", item.item_id), + format!( + "basket bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`", + item.bin_id + ), + )); + } + } + Ok(issues) +} + +fn basket_product_bin_state( + config: &RuntimeConfig, + executor: &SqliteExecutor, + item: &BasketItem, +) -> Result<Option<BasketProductBinState>, OperationAdapterError> { + if let Some(listing_addr) = item.listing_addr.as_deref().and_then(non_empty_ref) { + let product_rows = trade_product::find_many( + executor, + &ITradeProductFindMany { + filter: Some(trade_product_listing_addr_filter(listing_addr)), + }, + ) + .map_err(|error| { + OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}")) + })? + .results; + let [product] = product_rows.as_slice() else { + return Ok(None); + }; + return Ok(Some(BasketProductBinState { + primary_bin_id: product.primary_bin_id.clone(), + verified_primary_bin_id: product.verified_primary_bin_id.clone(), + })); + } + + let Some(listing_lookup) = item.listing.as_deref().and_then(non_empty_ref) else { + return Ok(None); + }; + let lookup_executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| { + OperationAdapterError::Runtime(format!( + "open local replica {}: {error}", + config.local.replica_db_path.display() + )) + })?; + let rows = ReplicaSql::new(lookup_executor) + .trade_product_lookup(listing_lookup) + .map_err(|error| { + OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}")) + })?; + let [product] = rows.as_slice() else { + return Ok(None); + }; + Ok(Some(BasketProductBinState { + primary_bin_id: product.primary_bin_id.clone(), + verified_primary_bin_id: product.verified_primary_bin_id.clone(), + })) +} + +fn basket_issue( + code: impl Into<String>, + field: impl Into<String>, + message: impl Into<String>, +) -> BasketIssue { + BasketIssue { + code: code.into(), + field: field.into(), + message: message.into(), + } +} + +fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { + ITradeProductFieldsFilter { + id: None, + created_at: None, + updated_at: None, + key: None, + category: None, + title: None, + summary: None, + process: None, + lot: None, + profile: None, + year: None, + qty_amt: None, + qty_amt_exact: None, + qty_unit: None, + qty_label: None, + qty_avail: None, + price_amt: None, + price_amt_exact: None, + price_currency: None, + price_qty_amt: None, + price_qty_amt_exact: None, + price_qty_unit: None, + listing_addr: Some(listing_addr.to_owned()), + primary_bin_id: None, + verified_primary_bin_id: None, + notes: None, + } +} + +fn non_empty_ref(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { None } else { Some(value) } } -fn basket_actions(document: &BasketDocument) -> Vec<String> { +fn basket_actions(document: &BasketDocument, issues: &[BasketIssue]) -> Vec<String> { let basket_id = document.basket.basket_id.as_str(); if document.basket.items.is_empty() { return vec![format!("radroots basket item add {basket_id}")]; } - if basket_issues(document).is_empty() { + if issues.is_empty() { vec![ format!("radroots basket validate {basket_id}"), format!("radroots basket quote create {basket_id}"), @@ -841,6 +1050,7 @@ fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> { .issues .iter() .map(|issue| BasketIssue { + code: issue.code.clone(), field: issue.field.clone(), message: issue.message.clone(), }) diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -234,6 +234,7 @@ struct ResolvedOrderEconomicsProduct { price_qty_amt_exact: Option<String>, price_qty_unit: String, primary_bin_id: Option<String>, + verified_primary_bin_id: Option<String>, notes: Option<String>, } @@ -247,6 +248,7 @@ impl ResolvedOrderEconomicsProduct { price_qty_amt_exact: row.price_qty_amt_exact.clone(), price_qty_unit: row.price_qty_unit.clone(), primary_bin_id: row.primary_bin_id.clone(), + verified_primary_bin_id: row.verified_primary_bin_id.clone(), notes: row.notes.clone(), } } @@ -260,6 +262,7 @@ impl ResolvedOrderEconomicsProduct { price_qty_amt_exact: row.price_qty_amt_exact, price_qty_unit: row.price_qty_unit, primary_bin_id: row.primary_bin_id, + verified_primary_bin_id: row.verified_primary_bin_id, notes: row.notes, } } @@ -9112,6 +9115,22 @@ fn order_economics_from_resolved_listing( let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { return Ok(None); }; + let Some(verified_primary_bin_id) = product + .verified_primary_bin_id + .as_deref() + .and_then(non_empty_ref) + else { + return Err(RuntimeError::Config(format!( + "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` is not verified in the current local replica", + listing.listing_addr + ))); + }; + if verified_primary_bin_id != primary_bin_id { + return Err(RuntimeError::Config(format!( + "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}` in the current local replica", + listing.listing_addr + ))); + } if items.is_empty() || items .iter() @@ -10907,6 +10926,38 @@ fn order_submit_quantity_preflight_view( )], ))); }; + let Some(verified_primary_bin_id) = product + .verified_primary_bin_id + .as_deref() + .and_then(non_empty_ref) + else { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing bin identity is not verified in the local replica", + vec![issue_with_code( + "listing_primary_bin_invalid", + "inventory.primary_bin_id", + format!("current local replica primary bin `{primary_bin_id}` is not verified"), + )], + ))); + }; + if verified_primary_bin_id != primary_bin_id { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing bin identity is invalid in the local replica", + vec![issue_with_code( + "listing_primary_bin_invalid", + "inventory.primary_bin_id", + format!( + "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`" + ), + )], + ))); + } let mut bin_issues = Vec::new(); for (index, item) in loaded.document.order.items.iter().enumerate() { @@ -12670,6 +12721,7 @@ mod tests { price_qty_amt_exact: Some("1".to_owned()), price_qty_unit: "each".to_owned(), primary_bin_id: Some("bin-1".to_owned()), + verified_primary_bin_id: Some("bin-1".to_owned()), notes: Some( serde_json::json!({ "listing_discounts": [{ @@ -12745,6 +12797,7 @@ mod tests { price_qty_amt_exact: Some("1".to_owned()), price_qty_unit: "each".to_owned(), primary_bin_id: Some("bin-1".to_owned()), + verified_primary_bin_id: Some("bin-1".to_owned()), notes: None, }), }; @@ -12786,6 +12839,7 @@ mod tests { price_qty_amt_exact: Some("1".to_owned()), price_qty_unit: "kg".to_owned(), primary_bin_id: Some("bin-a".to_owned()), + verified_primary_bin_id: Some("bin-a".to_owned()), notes: None, }), }; diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -7229,6 +7229,102 @@ fn order_submit_rejects_unknown_local_listing_bin_before_publish() { } #[test] +fn basket_quote_rejects_invalid_verified_primary_bin_before_order_write() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + seed_orderable_listing(&sandbox, LISTING_ADDR); + update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin")); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "create", + "invalid_primary_bin", + ]); + let add = sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "invalid_primary_bin", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + assert_eq!(add["result"]["ready_for_quote"], false); + assert_eq!( + add["result"]["issues"][0]["code"], + "listing_primary_bin_invalid" + ); + + let validate = sandbox.json_success(&[ + "--format", + "json", + "basket", + "validate", + "invalid_primary_bin", + ]); + assert_eq!(validate["result"]["state"], "unconfigured"); + assert_eq!(validate["result"]["ready_for_quote"], false); + assert_eq!( + validate["result"]["issues"][0]["code"], + "listing_primary_bin_invalid" + ); + + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "invalid_primary_bin", + ]); + assert_eq!(quote["result"]["state"], "unconfigured"); + assert_eq!(quote["result"]["ready_for_quote"], false); + assert_eq!( + quote["result"]["issues"][0]["code"], + "listing_primary_bin_invalid" + ); + assert!(!sandbox.root().join("data/apps/cli/orders/drafts").exists()); +} + +#[test] +fn order_submit_rejects_stale_invalid_verified_primary_bin_before_relay_preflight() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "stale_invalid_bin"); + update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin")); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "order", + "submit", + &order_id, + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(10)); + assert_eq!(value["operation_id"], "order.submit"); + assert_eq!(value["dry_run"], true); + assert_eq!(value["errors"][0]["code"], "validation_failed"); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["code"], + "listing_primary_bin_invalid" + ); + assert_eq!( + value["errors"][0]["detail"]["issues"][0]["field"], + "inventory.primary_bin_id" + ); + assert_no_removed_command_reference(&value, &["order", "submit", "--dry-run"]); + assert_no_daemon_runtime_reference(&value, &["order", "submit", "--dry-run"]); +} + +#[test] fn order_submit_dry_run_rejects_over_available_quantity_before_relay_preflight() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);