commit c56df8b75d8c2728555a463e13778a61533abaae
parent f57001e3f45ed07e1fa0e4ac6456abc997c72dc4
Author: triesap <tyson@radroots.org>
Date: Fri, 19 Jun 2026 01:42:54 -0700
sdk: harden order revision status preflight
Diffstat:
2 files changed, 344 insertions(+), 16 deletions(-)
diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs
@@ -1362,6 +1362,8 @@ pub struct OrderStatusReceipt {
pub event_ids: Vec<RadrootsEventId>,
pub request_event_id: Option<RadrootsEventId>,
pub decision_event_id: Option<RadrootsEventId>,
+ pub agreement_event_id: Option<RadrootsEventId>,
+ pub pending_revision_event_id: Option<RadrootsEventId>,
pub fulfillment_event_id: Option<RadrootsEventId>,
pub cancellation_event_id: Option<RadrootsEventId>,
pub receipt_event_id: Option<RadrootsEventId>,
@@ -2494,6 +2496,8 @@ impl OrderStatusReceipt {
event_ids: query_result.event_ids,
request_event_id: projection.request_event_id,
decision_event_id: projection.decision_event_id,
+ agreement_event_id: projection.agreement_event_id,
+ pending_revision_event_id: projection.pending_revision_event_id,
fulfillment_event_id: projection.fulfillment_event_id,
cancellation_event_id: projection.cancellation_event_id,
receipt_event_id: projection.receipt_event_id,
@@ -3278,14 +3282,23 @@ fn require_pending_revision(
refs: &OrderLifecycleReferences<'_>,
projection: &RadrootsOrderProjection,
) -> Result<(), RadrootsSdkError> {
- if has_pending_revision(projection) {
- Ok(())
- } else {
- Err(lifecycle_invalid(
+ match projection.pending_revision_event_id.as_ref() {
+ Some(pending_revision_event_id) if pending_revision_event_id == refs.previous_event_id => {
+ Ok(())
+ }
+ Some(pending_revision_event_id) => Err(lifecycle_invalid(
+ refs.operation,
+ refs.order_id,
+ format!(
+ "previous event {} does not match pending revision proposal {}",
+ refs.previous_event_id, pending_revision_event_id
+ ),
+ )),
+ None => Err(lifecycle_invalid(
refs.operation,
refs.order_id,
"requires pending revision proposal local state",
- ))
+ )),
}
}
@@ -3294,11 +3307,11 @@ fn require_no_pending_revision(
refs: &OrderLifecycleReferences<'_>,
projection: &RadrootsOrderProjection,
) -> Result<(), RadrootsSdkError> {
- if has_pending_revision(projection) {
+ if let Some(pending_revision_event_id) = projection.pending_revision_event_id.as_ref() {
Err(lifecycle_invalid(
refs.operation,
refs.order_id,
- "cannot follow pending revision proposal local state",
+ format!("cannot follow pending revision proposal {pending_revision_event_id}"),
))
} else {
Ok(())
@@ -3306,15 +3319,6 @@ fn require_no_pending_revision(
}
#[cfg(feature = "runtime")]
-fn has_pending_revision(projection: &RadrootsOrderProjection) -> bool {
- matches!(projection.status, RadrootsOrderStatus::Accepted)
- && projection.fulfillment_event_id.is_none()
- && projection.agreement_event_id.is_some()
- && projection.last_event_id.is_some()
- && projection.agreement_event_id != projection.last_event_id
-}
-
-#[cfg(feature = "runtime")]
fn require_lifecycle_previous_is_current(
refs: &OrderLifecycleReferences<'_>,
projection: &RadrootsOrderProjection,
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -1482,6 +1482,14 @@ async fn order_decision_enqueue_accept_stores_event_queues_outbox_and_updates_st
.map(RadrootsEventId::as_str),
Some(receipt.signed_event_id.as_str())
);
+ assert_eq!(
+ status
+ .agreement_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(receipt.signed_event_id.as_str())
+ );
+ assert_eq!(status.pending_revision_event_id, None);
assert!(status.issues.is_empty());
}
@@ -1863,11 +1871,327 @@ async fn order_revision_order_fulfillment_order_receipt_lifecycle_enqueue_update
status.fulfillment_status,
Some(OrderFulfillmentStatusKind::ReadyForPickup)
);
+ assert_eq!(
+ status
+ .agreement_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(revision_decision_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(status.pending_revision_event_id, None);
assert!(status.lifecycle_terminal);
assert!(status.issues.is_empty());
}
#[tokio::test]
+async fn order_revision_proposal_status_exposes_pending_and_blocks_follow_on_lifecycle() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request_event = signed_order_request_event("order-lifecycle-pending-revision", 55);
+ let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id");
+ store
+ .ingest_event(RadrootsEventIngest::new(request_event.clone(), 5_500))
+ .await
+ .expect("ingest request");
+ let decision_receipt = sdk
+ .orders()
+ .enqueue_decision(
+ OrderDecisionEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_decision("order-lifecycle-pending-revision"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("decision target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue decision");
+ let proposal = order_revision_proposal(
+ "order-lifecycle-pending-revision",
+ &request_event_id,
+ &decision_receipt.signed_event_id,
+ );
+ let proposal_receipt = sdk
+ .orders()
+ .enqueue_revision_proposal(
+ OrderRevisionProposalEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&decision_receipt.signed_event_id),
+ proposal,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("proposal target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue revision proposal");
+
+ let status = sdk
+ .orders()
+ .status(status_request("order-lifecycle-pending-revision"))
+ .await
+ .expect("status");
+ assert_eq!(status.status, OrderStatusKind::Accepted);
+ assert_eq!(status.event_count, 3);
+ assert_eq!(
+ status
+ .agreement_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(decision_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(
+ status
+ .pending_revision_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(proposal_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(
+ status.last_event_id.as_ref().map(RadrootsEventId::as_str),
+ Some(proposal_receipt.signed_event_id.as_str())
+ );
+ assert!(status.issues.is_empty());
+
+ let fulfillment_error = sdk
+ .orders()
+ .enqueue_fulfillment_update(
+ OrderFulfillmentUpdateEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&proposal_receipt.signed_event_id),
+ order_fulfillment_update(
+ "order-lifecycle-pending-revision",
+ RadrootsOrderFulfillmentState::ReadyForPickup,
+ ),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("fulfillment target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect_err("pending revision blocks fulfillment");
+ assert!(matches!(
+ fulfillment_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let blocked_proposal = order_revision_proposal(
+ "order-lifecycle-pending-revision",
+ &request_event_id,
+ &proposal_receipt.signed_event_id,
+ );
+ let proposal_error = sdk
+ .orders()
+ .enqueue_revision_proposal(
+ OrderRevisionProposalEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&proposal_receipt.signed_event_id),
+ blocked_proposal,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("blocked proposal target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect_err("pending revision blocks new proposal");
+ assert!(matches!(
+ proposal_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 3
+ );
+}
+
+#[tokio::test]
+async fn order_declined_revision_clears_pending_and_allows_follow_on_lifecycle() {
+ let (_tempdir, sdk, store) = directory_sdk_and_store().await;
+ let request_event = signed_order_request_event("order-lifecycle-declined-revision", 56);
+ let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id");
+ store
+ .ingest_event(RadrootsEventIngest::new(request_event.clone(), 5_600))
+ .await
+ .expect("ingest request");
+ let decision_receipt = sdk
+ .orders()
+ .enqueue_decision(
+ OrderDecisionEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_decision("order-lifecycle-declined-revision"),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("decision target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue decision");
+ let proposal = order_revision_proposal(
+ "order-lifecycle-declined-revision",
+ &request_event_id,
+ &decision_receipt.signed_event_id,
+ );
+ let proposal_receipt = sdk
+ .orders()
+ .enqueue_revision_proposal(
+ OrderRevisionProposalEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&decision_receipt.signed_event_id),
+ proposal.clone(),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("proposal target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue revision proposal");
+ let declined_revision = order_revision_decision(
+ &proposal,
+ &proposal_receipt.signed_event_id,
+ RadrootsOrderRevisionOutcome::Declined {
+ reason: "keep original order".to_owned(),
+ },
+ );
+ let declined_revision_receipt = sdk
+ .orders()
+ .enqueue_revision_decision(
+ OrderRevisionDecisionEnqueueRequest::new(
+ buyer_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&proposal_receipt.signed_event_id),
+ declined_revision,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("declined revision target relays"),
+ &FixtureSigner::new(BUYER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue declined revision");
+
+ let status = sdk
+ .orders()
+ .status(status_request("order-lifecycle-declined-revision"))
+ .await
+ .expect("status");
+ assert_eq!(status.status, OrderStatusKind::Accepted);
+ assert_eq!(status.event_count, 4);
+ assert_eq!(
+ status
+ .agreement_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(decision_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(status.pending_revision_event_id, None);
+ assert_eq!(
+ status.last_event_id.as_ref().map(RadrootsEventId::as_str),
+ Some(declined_revision_receipt.signed_event_id.as_str())
+ );
+
+ let second_decision = order_revision_decision(
+ &proposal,
+ &proposal_receipt.signed_event_id,
+ RadrootsOrderRevisionOutcome::Accepted,
+ );
+ let second_decision_error = sdk
+ .orders()
+ .enqueue_revision_decision(
+ OrderRevisionDecisionEnqueueRequest::new(
+ buyer_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&proposal_receipt.signed_event_id),
+ second_decision,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("second decision target relays"),
+ &FixtureSigner::new(BUYER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect_err("second revision decision after decline");
+ assert!(matches!(
+ second_decision_error,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+ assert_eq!(
+ store
+ .status_summary()
+ .await
+ .expect("event store status")
+ .total_events,
+ 4
+ );
+
+ let fulfillment_receipt = sdk
+ .orders()
+ .enqueue_fulfillment_update(
+ OrderFulfillmentUpdateEnqueueRequest::new(
+ seller_actor(),
+ request_event_ptr(&request_event),
+ order_event_ptr(&declined_revision_receipt.signed_event_id),
+ order_fulfillment_update(
+ "order-lifecycle-declined-revision",
+ RadrootsOrderFulfillmentState::ReadyForPickup,
+ ),
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ )
+ .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public)
+ .expect("fulfillment target relays"),
+ &FixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue fulfillment");
+
+ let status = sdk
+ .orders()
+ .status(status_request("order-lifecycle-declined-revision"))
+ .await
+ .expect("status");
+ assert_eq!(status.status, OrderStatusKind::Accepted);
+ assert_eq!(status.event_count, 5);
+ assert_eq!(
+ status
+ .agreement_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(decision_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(status.pending_revision_event_id, None);
+ assert_eq!(
+ status
+ .fulfillment_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str),
+ Some(fulfillment_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(
+ status.last_event_id.as_ref().map(RadrootsEventId::as_str),
+ Some(fulfillment_receipt.signed_event_id.as_str())
+ );
+ assert_eq!(
+ status.fulfillment_status,
+ Some(OrderFulfillmentStatusKind::ReadyForPickup)
+ );
+ assert!(status.issues.is_empty());
+}
+
+#[tokio::test]
async fn order_cancel_lifecycle_enqueue_updates_status() {
let (_tempdir, sdk, store) = directory_sdk_and_store().await;
let request_event = signed_order_request_event("order-lifecycle-cancel", 60);