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:
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());