commit 122e61eced234feccd4cb7fee0686189e6adef07
parent 06fecf0f298c71d9ddad5945bf8558c2b22241f3
Author: triesap <tyson@radroots.org>
Date: Wed, 29 Apr 2026 21:57:39 +0000
order: add fulfillment update cli surface
Diffstat:
8 files changed, 370 insertions(+), 8 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1274,6 +1274,71 @@ impl OrderDecisionView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderFulfillmentView {
+ pub state: String,
+ pub source: String,
+ pub order_id: String,
+ pub fulfillment_state: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub request_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub decision_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub root_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub prev_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_kind: Option<u32>,
+ #[serde(default)]
+ pub dry_run: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub target_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub connected_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub acknowledged_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub failed_relays: Vec<RelayFailureView>,
+ #[serde(default)]
+ pub fetched_count: usize,
+ #[serde(default)]
+ pub decoded_count: usize,
+ #[serde(default)]
+ pub skipped_count: usize,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signer_mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub issues: Vec<OrderIssueView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderFulfillmentView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "missing" => CommandDisposition::NotFound,
+ "invalid" | "requested" | "declined" | "forked" => CommandDisposition::ValidationFailed,
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "unavailable" => CommandDisposition::ExternalUnavailable,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct OrderStatusView {
pub state: String,
pub source: String,
diff --git a/src/main.rs b/src/main.rs
@@ -266,6 +266,9 @@ fn execute_request(
TargetOperationRequest::OrderDecline(request) => {
execute_with(OrderOperationService::new(config), request)
}
+ TargetOperationRequest::OrderFulfillmentUpdate(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
TargetOperationRequest::OrderStatusGet(request) => {
execute_with(OrderOperationService::new(config), request)
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1054,7 +1054,8 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand,
BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand,
FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand,
- MarketProductCommand, OrderCommand, OrderEventCommand, OrderStatusCommand, TargetCommand,
+ MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand,
+ OrderStatusCommand, TargetCommand,
};
let mut input = OperationData::new();
@@ -1187,6 +1188,17 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "order_id", &args.order_id);
insert_string(&mut input, "reason", &args.reason);
}
+ OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command {
+ OrderFulfillmentCommand::Update(args) => {
+ insert_string(&mut input, "order_id", &args.order_id);
+ if let Some(state) = args.state {
+ input.insert(
+ "state".to_owned(),
+ Value::String(state.as_protocol_state().to_owned()),
+ );
+ }
+ }
+ },
OrderCommand::Status(status) => match &status.command {
OrderStatusCommand::Get(args) => {
insert_string(&mut input, "order_id", &args.order_id)
@@ -1290,6 +1302,7 @@ target_operation_contracts! {
OrderList => (OrderListRequest, OrderListResult, "order.list"),
OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"),
OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"),
+ OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"),
OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"),
OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"),
OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"),
@@ -1309,7 +1322,7 @@ mod tests {
use std::io;
use clap::Parser;
- use serde_json::json;
+ use serde_json::{Value, json};
use super::{
OperationAdapter, OperationAdapterError, OperationContext, OperationInputMode,
@@ -1396,6 +1409,40 @@ mod tests {
}
#[test]
+ fn adapter_maps_order_fulfillment_update_input() {
+ let parsed = TargetCliArgs::try_parse_from([
+ "radroots",
+ "order",
+ "fulfillment",
+ "update",
+ "ord_test",
+ "--state",
+ "seller_cancelled",
+ ])
+ .expect("target args parse");
+
+ let request = TargetOperationRequest::from_target_args(&parsed)
+ .expect("operation request from target args");
+ let TargetOperationRequest::OrderFulfillmentUpdate(request) = request else {
+ panic!("expected order fulfillment update request")
+ };
+
+ assert_eq!(request.operation_id(), "order.fulfillment.update");
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("order_id")
+ .and_then(Value::as_str),
+ Some("ord_test")
+ );
+ assert_eq!(
+ request.payload.input.get("state").and_then(Value::as_str),
+ Some("seller_cancelled")
+ );
+ }
+
+ #[test]
fn typed_service_boundary_returns_enveloped_result() {
struct WorkspaceService;
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -8,9 +8,10 @@ use crate::operation_adapter::{
OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult,
OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult,
- OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest, OrderGetResult,
- OrderListRequest, OrderListResult, OrderStatusGetRequest, OrderStatusGetResult,
- OrderSubmitRequest, OrderSubmitResult,
+ OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest,
+ OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, OrderListRequest,
+ OrderListResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest,
+ OrderSubmitResult,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
@@ -166,6 +167,36 @@ impl OperationService<OrderDeclineRequest> for OrderOperationService<'_> {
}
}
+impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<'_> {
+ type Result = OrderFulfillmentUpdateResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderFulfillmentUpdateRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ required_order_key(&request)?;
+ string_input(&request, "state")
+ .map(|state| state.trim().to_owned())
+ .filter(|state| !state.is_empty())
+ .ok_or_else(|| {
+ invalid_input(
+ request.operation_id(),
+ "missing required `state` input".to_owned(),
+ )
+ })?;
+ if request.context.requires_approval_token() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
+ Err(OperationAdapterError::from_command_disposition(
+ request.operation_id(),
+ CommandDisposition::Unsupported,
+ "order fulfillment update runtime is not wired yet".to_owned(),
+ ))
+ }
+}
+
impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> {
type Result = OrderStatusGetResult;
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -872,6 +872,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
true
),
operation!(
+ "order.fulfillment.update",
+ "radroots order fulfillment update",
+ "order",
+ "order_fulfillment_update",
+ "OrderFulfillmentUpdateRequest",
+ "OrderFulfillmentUpdateResult",
+ "Update seller-authored order fulfillment state.",
+ Seller,
+ true,
+ Required,
+ High,
+ false,
+ true
+ ),
+ operation!(
"order.status.get",
"radroots order status get",
"order",
@@ -993,6 +1008,7 @@ mod tests {
"order.list",
"order.accept",
"order.decline",
+ "order.fulfillment.update",
"order.status.get",
"order.event.list",
"order.event.watch",
@@ -1027,6 +1043,7 @@ mod tests {
"order.submit",
"order.accept",
"order.decline",
+ "order.fulfillment.update",
];
const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[];
@@ -1039,7 +1056,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 56);
+ assert_eq!(OPERATION_REGISTRY.len(), 57);
}
#[test]
@@ -1088,6 +1105,7 @@ mod tests {
"order.submit",
"order.accept",
"order.decline",
+ "order.fulfillment.update",
]
.into_iter()
.collect::<BTreeSet<_>>();
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -986,7 +986,7 @@ fn order_status_from_receipt_with_context(
});
let order_id = context.order_id;
- let projection = reduce_active_order_events(order_id, requests, decisions.clone());
+ let projection = reduce_active_order_events(order_id, requests, decisions.clone(), []);
let listing_event_id = projection
.request_event_id
.as_ref()
@@ -1103,6 +1103,7 @@ fn enrich_order_status_inventory(
listing.bins,
requests,
decisions,
+ [],
);
let relevant_issues = projection
.issues
@@ -1587,6 +1588,114 @@ fn active_order_reducer_issue_view(issue_value: RadrootsActiveOrderReducerIssue)
"active order reducer reported conflicting decisions",
event_ids,
),
+ RadrootsActiveOrderReducerIssue::FulfillmentWithoutAcceptedDecision { event_id } => {
+ issue_with_events(
+ "fulfillment_without_accepted_decision",
+ "fulfillment_event_id",
+ "active order reducer reported fulfillment without accepted decision",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentPayloadInvalid { event_id } => {
+ issue_with_events(
+ "invalid_fulfillment_payload",
+ "fulfillment_payload",
+ "active order reducer reported invalid fulfillment payload",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentOrderIdMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_order_id_mismatch",
+ "order_id",
+ "active order reducer reported fulfillment order id mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentAuthorMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_author_mismatch",
+ "seller_pubkey",
+ "active order reducer reported fulfillment author mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentCounterpartyMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_counterparty_mismatch",
+ "buyer_pubkey",
+ "active order reducer reported fulfillment counterparty mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentBuyerMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_buyer_mismatch",
+ "buyer_pubkey",
+ "active order reducer reported fulfillment buyer mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentSellerMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_seller_mismatch",
+ "seller_pubkey",
+ "active order reducer reported fulfillment seller mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentListingAddressInvalid { event_id } => {
+ issue_with_events(
+ "invalid_fulfillment_listing_address",
+ "listing_addr",
+ "active order reducer reported invalid fulfillment listing address",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentListingMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_listing_mismatch",
+ "listing_addr",
+ "active order reducer reported fulfillment listing mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentRootMismatch { event_id } => issue_with_events(
+ "fulfillment_root_mismatch",
+ "root_event_id",
+ "active order reducer reported fulfillment root mismatch",
+ vec![event_id],
+ ),
+ RadrootsActiveOrderReducerIssue::FulfillmentPreviousMismatch { event_id } => {
+ issue_with_events(
+ "fulfillment_previous_mismatch",
+ "prev_event_id",
+ "active order reducer reported fulfillment previous mismatch",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentStatusNotPublishable { event_id } => {
+ issue_with_events(
+ "fulfillment_status_not_publishable",
+ "fulfillment_state",
+ "active order reducer reported non-publishable fulfillment status",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::FulfillmentUnsupportedTransition { event_id } => {
+ issue_with_events(
+ "fulfillment_unsupported_transition",
+ "fulfillment_state",
+ "active order reducer reported unsupported fulfillment transition",
+ vec![event_id],
+ )
+ }
+ RadrootsActiveOrderReducerIssue::ForkedFulfillments { event_ids } => issue_with_events(
+ "forked_fulfillments",
+ "fulfillment_event_id",
+ "active order reducer reported forked fulfillment updates",
+ event_ids,
+ ),
}
}
@@ -1980,6 +2089,7 @@ fn order_accept_inventory_preflight_view(
listing.bins,
requests,
decisions,
+ [],
);
Ok(order_accept_inventory_preflight_view_from_projection(
config, args, request, resolution, status, projection,
@@ -5386,6 +5496,7 @@ mod tests {
},
proposed_accept_decision_record(&request).expect("proposed accept decision"),
],
+ [],
);
let args = OrderDecisionArgs {
key: fixture.order_id.clone(),
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -174,6 +174,9 @@ impl TargetCommand {
OrderCommand::List => "order.list",
OrderCommand::Accept(_) => "order.accept",
OrderCommand::Decline(_) => "order.decline",
+ OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command {
+ OrderFulfillmentCommand::Update(_) => "order.fulfillment.update",
+ },
OrderCommand::Status(status) => match &status.command {
OrderStatusCommand::Get(_) => "order.status.get",
},
@@ -685,6 +688,7 @@ pub enum OrderCommand {
List,
Accept(OrderKeyArgs),
Decline(OrderDeclineArgs),
+ Fulfillment(OrderFulfillmentArgs),
Status(OrderStatusArgs),
Event(OrderEventArgs),
}
@@ -707,6 +711,46 @@ pub struct OrderDeclineArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct OrderFulfillmentArgs {
+ #[command(subcommand)]
+ pub command: OrderFulfillmentCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum OrderFulfillmentCommand {
+ Update(OrderFulfillmentUpdateArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct OrderFulfillmentUpdateArgs {
+ pub order_id: Option<String>,
+ #[arg(long, value_enum)]
+ pub state: Option<OrderFulfillmentStateArg>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+#[value(rename_all = "snake_case")]
+pub enum OrderFulfillmentStateArg {
+ Preparing,
+ ReadyForPickup,
+ OutForDelivery,
+ Delivered,
+ SellerCancelled,
+}
+
+impl OrderFulfillmentStateArg {
+ pub const fn as_protocol_state(self) -> &'static str {
+ match self {
+ Self::Preparing => "preparing",
+ Self::ReadyForPickup => "ready_for_pickup",
+ Self::OutForDelivery => "out_for_delivery",
+ Self::Delivered => "delivered",
+ Self::SellerCancelled => "seller_cancelled",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Args)]
pub struct OrderStatusArgs {
#[command(subcommand)]
pub command: OrderStatusCommand,
@@ -741,7 +785,10 @@ mod tests {
use clap::{CommandFactory, Parser};
- use super::{TargetCliArgs, TargetOutputFormat};
+ use super::{
+ OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, TargetCliArgs,
+ TargetOutputFormat,
+ };
use crate::operation_registry::OPERATION_REGISTRY;
#[test]
@@ -829,6 +876,31 @@ mod tests {
}
#[test]
+ fn target_parser_accepts_order_fulfillment_update_state() {
+ let parsed = TargetCliArgs::try_parse_from([
+ "radroots",
+ "order",
+ "fulfillment",
+ "update",
+ "ord_test",
+ "--state",
+ "ready_for_pickup",
+ ])
+ .expect("target args parse");
+
+ assert_eq!(parsed.command.operation_id(), "order.fulfillment.update");
+ let crate::target_cli::TargetCommand::Order(order) = parsed.command else {
+ panic!("expected order command")
+ };
+ let OrderCommand::Fulfillment(fulfillment) = order.command else {
+ panic!("expected order fulfillment command")
+ };
+ let OrderFulfillmentCommand::Update(args) = fulfillment.command;
+ assert_eq!(args.order_id.as_deref(), Some("ord_test"));
+ assert_eq!(args.state, Some(OrderFulfillmentStateArg::ReadyForPickup));
+ }
+
+ #[test]
fn target_parser_rejects_removed_global_flags() {
let rejected = [
vec!["radroots", "--output", "json", "config", "get"],
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -143,6 +143,21 @@ fn seller_order_decision_and_status_commands_are_public() {
"order.status.get",
["--format", "json", "order", "status", "get", "ord_public"].as_slice(),
),
+ (
+ "order.fulfillment.update",
+ [
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "fulfillment",
+ "update",
+ "ord_public",
+ "--state",
+ "ready_for_pickup",
+ ]
+ .as_slice(),
+ ),
] {
let output = radroots()
.args(args)