cli

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

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

order: reject terminal fulfillment updates

- classify terminal fulfillment preflight as validation failure
- reject fulfillment updates after completed or disputed receipt state
- add a unit regression for completed-order terminal handling
- keep order mutation behavior aligned with lifecycle reducer output

Diffstat:
Msrc/domain/runtime.rs | 4+++-
Msrc/runtime/order.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 105 insertions(+), 1 deletion(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1642,7 +1642,9 @@ impl OrderFulfillmentView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "declined" | "forked" => CommandDisposition::ValidationFailed, + "invalid" | "requested" | "declined" | "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 @@ -5608,6 +5608,7 @@ fn order_fulfillment_preflight_view_from_status( "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { status.state.as_str() } + "cancelled" | "completed" | "disputed" => "terminal", _ => return None, }; let mut view = order_fulfillment_base_view(config, args, state, config.output.dry_run); @@ -5657,6 +5658,12 @@ fn order_fulfillment_preflight_view_from_status( args.key ) }), + "terminal" => { + format!( + "order fulfillment update refused because order `{}` is already terminal", + args.key + ) + } _ => status.reason.clone().unwrap_or_else(|| { format!( "order fulfillment update status preflight failed with state `{}`", @@ -15711,6 +15718,101 @@ mod tests { } #[test] + fn order_fulfillment_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 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 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 receipt_event_id = receipt_event.id.to_string(); + let reduction = order_status_reduction_from_receipt_with_context( + OrderStatusContext { + order_id: fixture.order_id.as_str(), + buyer_pubkey: None, + seller_pubkey: None, + selected_account_pubkey: None, + actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, + }, + 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 = OrderFulfillmentArgs { + key: fixture.order_id.clone(), + state: "ready_for_pickup".to_owned(), + idempotency_key: None, + }; + + let view = order_fulfillment_preflight_view_from_status( + &config, + &args, + &reduction.view, + reduction.fulfillment_status, + reduction.fulfillment_event_id.as_deref(), + ) + .expect("completed fulfillment preflight"); + + assert_eq!(view.state, "terminal"); + assert_eq!( + view.disposition(), + crate::domain::runtime::CommandDisposition::ValidationFailed + ); + assert_eq!( + view.prev_event_id.as_deref(), + Some(receipt_event_id.as_str()) + ); + assert!(view.event_id.is_none()); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already terminal") + ); + } + + #[test] fn order_fulfillment_preflight_rejects_missing_order() { let dir = tempdir().expect("tempdir"); let mut config = sample_config(dir.path());