commit 0570a2c155ab3488a66077fa1fbbf1113a606c4c
parent 982502a94010d80a5a8b119941d50813828ce607
Author: triesap <tyson@radroots.org>
Date: Sat, 9 May 2026 17:10:15 +0000
cli: add farm seller recovery actions
Diffstat:
5 files changed, 387 insertions(+), 29 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -803,20 +803,62 @@ fn account_runtime_failure(
operation_id: &str,
failure: &AccountRuntimeFailure,
) -> OperationAdapterError {
- let message = failure.to_string();
+ let message = failure.message().to_owned();
match failure {
- AccountRuntimeFailure::Unresolved(_) => OperationAdapterError::AccountUnresolved {
- operation_id: operation_id.to_owned(),
+ AccountRuntimeFailure::Unresolved(_) => account_failure_output(
+ operation_id,
+ "account_unresolved",
message,
- },
- AccountRuntimeFailure::WatchOnly(_) => OperationAdapterError::AccountWatchOnly {
- operation_id: operation_id.to_owned(),
+ CliExitCode::AuthorizationFailed,
+ failure.detail_json(),
+ || OperationAdapterError::AccountUnresolved {
+ operation_id: operation_id.to_owned(),
+ message: failure.message().to_owned(),
+ },
+ ),
+ AccountRuntimeFailure::WatchOnly(_) => account_failure_output(
+ operation_id,
+ "account_watch_only",
message,
- },
- AccountRuntimeFailure::Mismatch(_) => OperationAdapterError::AccountMismatch {
+ CliExitCode::SignerUnavailable,
+ failure.detail_json(),
+ || OperationAdapterError::AccountWatchOnly {
+ operation_id: operation_id.to_owned(),
+ message: failure.message().to_owned(),
+ },
+ ),
+ AccountRuntimeFailure::Mismatch(_) => account_failure_output(
+ operation_id,
+ "account_mismatch",
+ message,
+ CliExitCode::AuthorizationFailed,
+ failure.detail_json(),
+ || OperationAdapterError::AccountMismatch {
+ operation_id: operation_id.to_owned(),
+ message: failure.message().to_owned(),
+ },
+ ),
+ }
+}
+
+fn account_failure_output(
+ operation_id: &str,
+ code: &str,
+ message: String,
+ exit_code: CliExitCode,
+ detail_json: Option<&str>,
+ fallback: impl FnOnce() -> OperationAdapterError,
+) -> OperationAdapterError {
+ match detail_json {
+ Some(detail_json) => OperationAdapterError::DetailedFailure {
operation_id: operation_id.to_owned(),
+ code: code.to_owned(),
+ class: "account".to_owned(),
message,
+ exit_code,
+ detail_json: detail_json.to_owned(),
},
+ None => fallback(),
}
}
diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs
@@ -24,35 +24,81 @@ pub const SHARED_ACCOUNT_STORE_SOURCE: &str = "shared account store ยท local fir
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountRuntimeFailure {
- Unresolved(String),
- WatchOnly(String),
- Mismatch(String),
+ Unresolved(AccountRuntimeFailureIssue),
+ WatchOnly(AccountRuntimeFailureIssue),
+ Mismatch(AccountRuntimeFailureIssue),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AccountRuntimeFailureIssue {
+ message: String,
+ detail_json: Option<String>,
+}
+
+impl AccountRuntimeFailureIssue {
+ fn new(message: impl Into<String>) -> Self {
+ Self {
+ message: message.into(),
+ detail_json: None,
+ }
+ }
+
+ fn with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self {
+ Self {
+ message: message.into(),
+ detail_json: Some(detail.to_string()),
+ }
+ }
+
+ pub fn message(&self) -> &str {
+ self.message.as_str()
+ }
}
impl AccountRuntimeFailure {
pub fn unresolved(message: impl Into<String>) -> Self {
- Self::Unresolved(message.into())
+ Self::Unresolved(AccountRuntimeFailureIssue::new(message))
+ }
+
+ pub fn unresolved_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self {
+ Self::Unresolved(AccountRuntimeFailureIssue::with_detail(message, detail))
}
pub fn watch_only(account_id: &radroots_identity::RadrootsIdentityId) -> Self {
- Self::WatchOnly(format!(
+ Self::WatchOnly(AccountRuntimeFailureIssue::new(format!(
"resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed"
- ))
+ )))
}
pub fn mismatch(message: impl Into<String>) -> Self {
- Self::Mismatch(message.into())
+ Self::Mismatch(AccountRuntimeFailureIssue::new(message))
}
-}
-impl fmt::Display for AccountRuntimeFailure {
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ pub fn mismatch_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self {
+ Self::Mismatch(AccountRuntimeFailureIssue::with_detail(message, detail))
+ }
+
+ pub fn message(&self) -> &str {
match self {
- Self::Unresolved(message) | Self::WatchOnly(message) | Self::Mismatch(message) => {
- formatter.write_str(message)
+ Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => {
+ issue.message.as_str()
}
}
}
+
+ pub fn detail_json(&self) -> Option<&str> {
+ match self {
+ Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => {
+ issue.detail_json.as_deref()
+ }
+ }
+ }
+}
+
+impl fmt::Display for AccountRuntimeFailure {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.message())
+ }
}
impl std::error::Error for AccountRuntimeFailure {}
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -15,6 +15,7 @@ use radroots_sdk::{
SdkRadrootsdPublishReceipt, SdkRadrootsdSignerSessionRef, SdkTransportMode,
SdkTransportReceipt, SignerConfig as SdkSignerConfig,
};
+use serde_json::json;
use crate::domain::runtime::{
FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView,
@@ -148,7 +149,8 @@ fn rebind_inner(
let from_seller_pubkey = from_account
.as_ref()
.map(|account| account.record.public_identity.public_key_hex.clone());
- let target_account = accounts::resolve_account_selector(config, args.selector.as_str())?;
+ let target_account = accounts::resolve_account_selector(config, args.selector.as_str())
+ .map_err(|error| farm_rebind_selector_error(args.selector.as_str(), error))?;
let to_seller_pubkey = target_account.record.public_identity.public_key_hex.clone();
let seller_pubkey_changed = from_seller_pubkey
.as_deref()
@@ -206,6 +208,23 @@ fn rebind_inner(
})
}
+fn farm_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError {
+ match error {
+ RuntimeError::Account(accounts::AccountRuntimeFailure::Unresolved(issue)) => {
+ accounts::AccountRuntimeFailure::unresolved_with_detail(
+ issue.message().to_owned(),
+ json!({
+ "seller_actor_source": FARM_SELLER_ACTOR_SOURCE,
+ "selector": selector,
+ "actions": account_recovery_actions(),
+ }),
+ )
+ .into()
+ }
+ other => other,
+ }
+}
+
pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> {
let scope = scope_from_arg(args.scope);
let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
@@ -230,6 +249,13 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView,
let account_pubkey = configured_account
.as_ref()
.map(|account| account.record.public_identity.public_key_hex.as_str());
+ let reason = if configured_account.is_none() {
+ Some(missing_farm_bound_seller_reason(
+ resolved.document.selection.account.as_str(),
+ ))
+ } else {
+ None
+ };
Ok(FarmSetView {
state: "updated".to_owned(),
@@ -242,7 +268,7 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView,
&resolved.document,
account_pubkey,
)),
- reason: None,
+ reason,
actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()),
})
}
@@ -273,6 +299,14 @@ pub fn set_preflight(
let account_pubkey = configured_account
.as_ref()
.map(|account| account.record.public_identity.public_key_hex.as_str());
+ let reason = if configured_account.is_none() {
+ Some(format!(
+ "dry run requested; farm draft was not written; {}",
+ missing_farm_bound_seller_reason(resolved.document.selection.account.as_str())
+ ))
+ } else {
+ Some("dry run requested; farm draft was not written".to_owned())
+ };
Ok(FarmSetView {
state: "dry_run".to_owned(),
@@ -285,7 +319,7 @@ pub fn set_preflight(
&resolved.document,
account_pubkey,
)),
- reason: Some("dry run requested; farm draft was not written".to_owned()),
+ reason,
actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()),
})
}
@@ -1734,10 +1768,19 @@ fn init_document(
if let Some(document) = existing_document
&& document.selection.account != account.record.account_id.to_string()
{
- return Err(accounts::AccountRuntimeFailure::mismatch(format!(
+ let message = format!(
"account mismatch: farm config is bound to seller account `{}`; use `radroots farm rebind {}` to change the farm-bound seller account",
document.selection.account, account.record.account_id
- ))
+ );
+ return Err(accounts::AccountRuntimeFailure::mismatch_with_detail(
+ message,
+ json!({
+ "seller_actor_source": FARM_SELLER_ACTOR_SOURCE,
+ "farm_bound_seller_account_id": document.selection.account,
+ "attempted_seller_account_id": account.record.account_id.to_string(),
+ "actions": [format!("radroots farm rebind {}", account.record.account_id)],
+ }),
+ )
.into());
}
let farm_d_tag = match args.farm_d_tag.as_deref() {
@@ -1876,6 +1919,10 @@ fn farm_setup_actions(
account: Option<&AccountRecordView>,
) -> Vec<String> {
let mut actions = vec!["radroots farm readiness check".to_owned()];
+ if account.is_none() {
+ actions.extend(farm_bound_seller_recovery_actions());
+ return actions;
+ }
if farm_config::missing_fields(document).is_empty()
&& account
.map(|account| farm_publish_readiness(config, account).executable)
@@ -1886,6 +1933,24 @@ fn farm_setup_actions(
actions
}
+fn missing_farm_bound_seller_reason(account_id: &str) -> String {
+ format!("farm-bound seller account `{account_id}` is not present in the local account store")
+}
+
+fn farm_bound_seller_recovery_actions() -> Vec<String> {
+ vec![
+ "radroots account import <path>".to_owned(),
+ "radroots farm rebind <selector>".to_owned(),
+ ]
+}
+
+fn account_recovery_actions() -> Vec<String> {
+ vec![
+ "radroots account import <path>".to_owned(),
+ "radroots account create".to_owned(),
+ ]
+}
+
fn missing_blocks_listing_defaults(missing: &[FarmMissingField]) -> bool {
missing.iter().any(|field| {
matches!(
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -33,6 +33,7 @@ use radroots_sql_core::SqliteExecutor;
use radroots_trade::listing::publish::validate_listing_for_seller;
use radroots_trade::listing::validation::validate_listing_event;
use serde::{Deserialize, Serialize};
+use serde_json::json;
use crate::domain::runtime::{
FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView,
@@ -2246,10 +2247,21 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults
return Ok(defaults);
};
let Some(account) = configured_account(config, &resolved.document.selection.account)? else {
- return Err(RuntimeError::Config(format!(
- "farm config account `{}` is not present in the local account store",
- resolved.document.selection.account
- )));
+ let account_id = resolved.document.selection.account.clone();
+ return Err(accounts::AccountRuntimeFailure::unresolved_with_detail(
+ format!(
+ "farm-bound seller account `{account_id}` is not present in the local account store"
+ ),
+ json!({
+ "seller_actor_source": "farm_config",
+ "farm_bound_seller_account_id": account_id,
+ "actions": [
+ "radroots account import <path>",
+ "radroots farm rebind <selector>",
+ ],
+ }),
+ )
+ .into());
};
defaults.farm_config_present = true;
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -1474,6 +1474,30 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() {
"preserved"
);
+ let same_seller_live = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "farm",
+ "rebind",
+ first_account_id,
+ ]);
+ assert_eq!(same_seller_live["operation_id"], "farm.rebind");
+ assert_eq!(
+ same_seller_live["result"]["publication_state_action"],
+ "preserved"
+ );
+ let same_seller_get = sandbox.json_success(&["--format", "json", "farm", "get"]);
+ assert_eq!(
+ same_seller_get["result"]["document"]["publication"]["profile_state"],
+ "published"
+ );
+ assert_eq!(
+ same_seller_get["result"]["document"]["publication"]["farm_state"],
+ "published"
+ );
+
let second = sandbox.json_success(&["--format", "json", "account", "create"]);
let second_account_id = second["result"]["account"]["id"]
.as_str()
@@ -1506,6 +1530,44 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() {
assert_eq!(retarget["operation_id"], "farm.create");
assert_eq!(retarget["errors"][0]["code"], "account_mismatch");
assert_contains(&retarget["errors"][0]["message"], "farm-bound seller");
+ assert_eq!(
+ retarget["errors"][0]["detail"]["seller_actor_source"],
+ "farm_config"
+ );
+ assert_eq!(
+ retarget["errors"][0]["detail"]["farm_bound_seller_account_id"],
+ first_account_id
+ );
+ assert_eq!(
+ retarget["errors"][0]["detail"]["attempted_seller_account_id"],
+ second_account_id
+ );
+ assert_next_action_present(
+ &retarget,
+ format!("radroots farm rebind {second_account_id}").as_str(),
+ );
+
+ let (missing_rebind_output, missing_rebind) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "farm",
+ "rebind",
+ "acct_missing",
+ ]);
+ assert!(!missing_rebind_output.status.success());
+ assert_eq!(missing_rebind["operation_id"], "farm.rebind");
+ assert_eq!(missing_rebind["errors"][0]["code"], "account_unresolved");
+ assert_eq!(
+ missing_rebind["errors"][0]["detail"]["seller_actor_source"],
+ "farm_config"
+ );
+ assert_eq!(
+ missing_rebind["errors"][0]["detail"]["selector"],
+ "acct_missing"
+ );
+ assert_next_action_present(&missing_rebind, "radroots account import <path>");
+ assert_next_action_present(&missing_rebind, "radroots account create");
let publish_dry_run = sandbox.json_success(&[
"--format",
@@ -1641,6 +1703,118 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() {
}
#[test]
+fn missing_farm_bound_seller_blocks_listing_create_and_guides_setup_repair() {
+ let sandbox = RadrootsCliSandbox::new();
+ let first = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let first_account_id = first["result"]["account"]["id"]
+ .as_str()
+ .expect("first account id");
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Missing Seller Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let second = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let second_account_id = second["result"]["account"]["id"]
+ .as_str()
+ .expect("second account id");
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "account",
+ "selection",
+ "update",
+ second_account_id,
+ ]);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "remove",
+ first_account_id,
+ ]);
+
+ let updated = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "profile",
+ "update",
+ "--field",
+ "name",
+ "--value",
+ "Missing Seller Farm Updated",
+ ]);
+ assert_eq!(updated["operation_id"], "farm.profile.update");
+ assert_contains(&updated["result"]["reason"], "farm-bound seller account");
+ assert_action_present(&updated, "radroots account import <path>");
+ assert_action_present(&updated, "radroots farm rebind <selector>");
+
+ let listing_path = sandbox.root().join("missing-seller-listing.toml");
+ let (listing_output, listing) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "listing",
+ "create",
+ "--output",
+ listing_path.to_string_lossy().as_ref(),
+ "--key",
+ "missing-seller-eggs",
+ "--title",
+ "Missing Seller Eggs",
+ "--category",
+ "eggs",
+ "--summary",
+ "Fresh eggs",
+ "--bin-id",
+ "bin-1",
+ "--quantity-amount",
+ "1",
+ "--quantity-unit",
+ "each",
+ "--price-amount",
+ "6",
+ "--price-currency",
+ "USD",
+ "--price-per-amount",
+ "1",
+ "--price-per-unit",
+ "each",
+ "--available",
+ "10",
+ ]);
+ assert!(!listing_output.status.success());
+ assert_eq!(listing["operation_id"], "listing.create");
+ assert_eq!(listing["errors"][0]["code"], "account_unresolved");
+ assert_contains(
+ &listing["errors"][0]["message"],
+ "farm-bound seller account",
+ );
+ assert_eq!(
+ listing["errors"][0]["detail"]["seller_actor_source"],
+ "farm_config"
+ );
+ assert_eq!(
+ listing["errors"][0]["detail"]["farm_bound_seller_account_id"],
+ first_account_id
+ );
+ assert_next_action_present(&listing, "radroots account import <path>");
+ assert_next_action_present(&listing, "radroots farm rebind <selector>");
+ assert!(!listing_path.exists());
+}
+
+#[test]
fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);
@@ -2380,6 +2554,16 @@ fn assert_action_present(value: &Value, action: &str) {
);
}
+fn assert_next_action_present(value: &Value, action: &str) {
+ assert!(
+ next_action_commands(value)
+ .iter()
+ .any(|entry| *entry == action),
+ "expected next action `{action}` in `{}`",
+ value["next_actions"]
+ );
+}
+
fn assert_action_absent(value: &Value, action: &str) {
assert!(
action_list(value).iter().all(|entry| *entry != action),
@@ -2396,3 +2580,12 @@ fn action_list(value: &Value) -> Vec<&str> {
.map(|entry| entry.as_str().expect("action"))
.collect()
}
+
+fn next_action_commands(value: &Value) -> Vec<&str> {
+ value["next_actions"]
+ .as_array()
+ .expect("next actions")
+ .iter()
+ .filter_map(|entry| entry["command"].as_str())
+ .collect()
+}