cli

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

commit f278d9d9eada740291deac9ad2c9ec5692875db0
parent 57dc0b036cbc00ee814bc57b502ae0d4bd18a9d8
Author: triesap <tyson@radroots.org>
Date:   Sat, 16 May 2026 22:20:16 +0000

cli: reject terminal order mutations

Diffstat:
Msrc/domain/runtime.rs | 7+++----
Msrc/runtime/order.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 155 insertions(+), 4 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1577,7 +1577,7 @@ impl OrderDecisionView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "missing" => CommandDisposition::NotFound, - "invalid" | "already_decided" => CommandDisposition::ValidationFailed, + "invalid" | "already_decided" | "terminal" => CommandDisposition::ValidationFailed, "unconfigured" => CommandDisposition::Unconfigured, "unavailable" => CommandDisposition::ExternalUnavailable, "error" => CommandDisposition::InternalError, @@ -1857,9 +1857,8 @@ impl OrderRevisionProposalView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "order_declined" | "fulfilled" | "terminal" | "forked" => { - CommandDisposition::ValidationFailed - } + "invalid" | "requested" | "declined" | "order_declined" | "fulfilled" | "terminal" + | "forked" => CommandDisposition::ValidationFailed, "unconfigured" => CommandDisposition::Unconfigured, "unavailable" => CommandDisposition::ExternalUnavailable, "error" => CommandDisposition::InternalError, diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -5834,6 +5834,7 @@ fn order_decision_preflight_view_from_status( ) -> Option<OrderDecisionView> { let state = match status.state.as_str() { "accepted" | "declined" => "already_decided", + "cancelled" | "completed" | "disputed" => "terminal", "invalid" => "invalid", "unavailable" => "unavailable", "unconfigured" => "unconfigured", @@ -5854,6 +5855,11 @@ fn order_decision_preflight_view_from_status( request.order_id, status.state ), + "cancelled" | "completed" | "disputed" => format!( + "order {} refused because order `{}` is already terminal", + args.decision.command(), + request.order_id + ), "invalid" => status.reason.clone().unwrap_or_else(|| { format!( "order {} refused because active order events for `{}` are invalid", @@ -12152,6 +12158,63 @@ mod tests { } #[test] + fn order_revision_preflight_rejects_declined_order() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Declined { + reason: "out of stock".to_owned(), + }, + ); + let decision_event_id = decision_event.id.to_string(); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone(), decision_event], + }, + ); + let args = revision_args_for_fixture(&fixture, 3); + let candidates = order_revision_proposals_from_events(fixture.order_id.as_str(), &[]); + + let view = order_revision_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + &candidates, + ) + .expect("declined revision proposal preflight"); + + assert_eq!(view.state, "declined"); + assert_eq!( + view.disposition(), + crate::domain::runtime::CommandDisposition::ValidationFailed + ); + assert_eq!( + view.decision_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert!(view.event_id.is_none()); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("was declined") + ); + } + + #[test] fn order_revision_preflight_rejects_pending_revision_candidate() { let dir = tempdir().expect("tempdir"); let mut config = sample_config(dir.path()); @@ -16119,6 +16182,95 @@ mod tests { } #[test] + fn order_decision_preflight_rejects_completed_order_as_terminal() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let resolution = request_resolution_for_fixture(&fixture); + let request = resolution.requests[0].clone(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let decision_event_id = decision_event.id.to_string(); + let fulfillment_event = signed_fulfillment_update_event( + &fixture.seller, + &fixture.request_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::Delivered, + ); + let receipt_event = signed_buyer_receipt_event( + &fixture.buyer, + &fixture.request_event, + &fulfillment_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + true, + None, + ); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![ + fixture.request_event.clone(), + decision_event, + fulfillment_event, + receipt_event, + ], + }, + ); + let args = OrderDecisionArgs { + key: fixture.order_id.clone(), + decision: OrderDecisionArg::Decline, + reason: Some("out of stock".to_owned()), + idempotency_key: None, + }; + + let view = order_decision_preflight_view_from_status( + &config, + &args, + &request, + &resolution, + &status_view, + ) + .expect("terminal decision preflight view"); + + assert_eq!(view.state, "terminal"); + assert_eq!( + view.disposition(), + crate::domain::runtime::CommandDisposition::ValidationFailed + ); + assert_eq!(view.event_id.as_deref(), Some(decision_event_id.as_str())); + assert_eq!(view.event_kind, Some(KIND_TRADE_ORDER_DECISION)); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already terminal") + ); + } + + #[test] fn order_accept_inventory_preflight_rejects_over_reserved_projection() { let dir = tempdir().expect("tempdir"); let mut config = sample_config(dir.path());