cli

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

signer_runtime_modes.rs (84034B)


      1 mod support;
      2 
      3 use std::fs;
      4 use std::path::Path;
      5 
      6 use serde_json::Value;
      7 use support::{
      8     RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
      9     assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret,
     10     json_from_stdout, make_listing_publishable, seed_orderable_listing, shell_single_quoted,
     11     toml_string, write_public_identity_profile, write_secret_identity_profile,
     12 };
     13 
     14 const LISTING_ADDR: &str =
     15     "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg";
     16 
     17 #[test]
     18 fn local_signer_status_reports_unconfigured_without_account() {
     19     let sandbox = RadrootsCliSandbox::new();
     20 
     21     let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
     22 
     23     assert_eq!(value["schema_version"], "radroots.cli.output.v1");
     24     assert_eq!(value["operation_id"], "signer.status.get");
     25     assert_eq!(value["kind"], "signer.status.get");
     26     assert_eq!(value["result"]["mode"], "local");
     27     assert_eq!(value["result"]["state"], "unconfigured");
     28     assert_eq!(
     29         value["result"]["signer_account_id"],
     30         serde_json::Value::Null
     31     );
     32     assert_eq!(value["result"]["binding"]["state"], "disabled");
     33     assert_eq!(value["result"]["local"], serde_json::Value::Null);
     34     assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
     35 }
     36 
     37 #[test]
     38 fn local_signer_status_reports_ready_after_account_create() {
     39     let sandbox = RadrootsCliSandbox::new();
     40 
     41     let created = sandbox.json_success(&["--format", "json", "account", "create"]);
     42     assert_eq!(created["operation_id"], "account.create");
     43     assert_eq!(created["result"]["state"], "created");
     44     assert_eq!(created["result"]["account"]["signer"], "local");
     45     assert_eq!(created["result"]["account"]["custody"], "secret_backed");
     46     assert_eq!(created["result"]["account"]["write_capable"], true);
     47     assert_eq!(created["result"]["account"]["is_default"], true);
     48     let account_id = created["result"]["account"]["id"]
     49         .as_str()
     50         .expect("created account id");
     51 
     52     let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
     53 
     54     assert_eq!(status["operation_id"], "signer.status.get");
     55     assert_eq!(status["result"]["mode"], "local");
     56     assert_eq!(status["result"]["state"], "ready");
     57     assert_eq!(status["result"]["signer_account_id"], account_id);
     58     assert_eq!(status["result"]["local"]["account_id"], account_id);
     59     assert_eq!(status["result"]["local"]["availability"], "secret_backed");
     60     assert_eq!(status["result"]["local"]["secret_backed"], true);
     61     assert_eq!(status["result"]["local"]["backend"], "encrypted_file");
     62     assert_eq!(status["result"]["local"]["used_fallback"], false);
     63     assert_eq!(status["result"]["binding"]["state"], "disabled");
     64 }
     65 
     66 #[test]
     67 fn local_account_selection_and_invocation_override_resolve_signer_actor() {
     68     let sandbox = RadrootsCliSandbox::new();
     69 
     70     let first = sandbox.json_success(&["--format", "json", "account", "create"]);
     71     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
     72     let first_account_id = first["result"]["account"]["id"]
     73         .as_str()
     74         .expect("first account id");
     75     let second_account_id = second["result"]["account"]["id"]
     76         .as_str()
     77         .expect("second account id");
     78     assert_eq!(first["result"]["account"]["is_default"], true);
     79     assert_eq!(second["result"]["account"]["is_default"], false);
     80 
     81     let default_status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
     82     assert_eq!(default_status["result"]["state"], "ready");
     83     assert_eq!(
     84         default_status["result"]["signer_account_id"],
     85         first_account_id
     86     );
     87     assert_eq!(
     88         default_status["result"]["account_resolution"]["source"],
     89         "default_account"
     90     );
     91 
     92     let override_status = sandbox.json_success(&[
     93         "--format",
     94         "json",
     95         "--account-id",
     96         second_account_id,
     97         "signer",
     98         "status",
     99         "get",
    100     ]);
    101     assert_eq!(override_status["actor"]["account_id"], second_account_id);
    102     assert_eq!(override_status["actor"]["role"], "account");
    103     assert_eq!(
    104         override_status["result"]["signer_account_id"],
    105         second_account_id
    106     );
    107     assert_eq!(
    108         override_status["result"]["account_resolution"]["source"],
    109         "invocation_override"
    110     );
    111     assert_eq!(
    112         override_status["result"]["account_resolution"]["default_account"]["id"],
    113         first_account_id
    114     );
    115 
    116     let selected = sandbox.json_success(&[
    117         "--format",
    118         "json",
    119         "account",
    120         "selection",
    121         "update",
    122         second_account_id,
    123     ]);
    124     assert_eq!(selected["operation_id"], "account.selection.update");
    125     assert_eq!(selected["result"]["state"], "default");
    126     assert_eq!(selected["result"]["account"]["id"], second_account_id);
    127 
    128     let selected_status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
    129     assert_eq!(
    130         selected_status["result"]["signer_account_id"],
    131         second_account_id
    132     );
    133     assert_eq!(
    134         selected_status["result"]["account_resolution"]["source"],
    135         "default_account"
    136     );
    137 
    138     let selected_get =
    139         sandbox.json_success(&["--format", "json", "account", "get", first_account_id]);
    140     assert_eq!(selected_get["operation_id"], "account.get");
    141     assert_eq!(
    142         selected_get["result"]["account_resolution"]["source"],
    143         "invocation_override"
    144     );
    145     assert_eq!(
    146         selected_get["result"]["account_resolution"]["resolved_account"]["id"],
    147         first_account_id
    148     );
    149 }
    150 
    151 #[test]
    152 fn account_import_dry_run_validates_profile_without_mutating_store() {
    153     let sandbox = RadrootsCliSandbox::new();
    154     let public_identity = identity_public(21);
    155     let public_identity_file =
    156         write_public_identity_profile(&sandbox, "dry-run-import", &public_identity);
    157 
    158     let value = sandbox.json_success(&[
    159         "--format",
    160         "json",
    161         "--dry-run",
    162         "account",
    163         "import",
    164         "--default",
    165         public_identity_file.to_string_lossy().as_ref(),
    166     ]);
    167 
    168     assert_eq!(value["operation_id"], "account.import");
    169     assert_eq!(value["dry_run"], true);
    170     assert_eq!(value["result"]["state"], "dry_run");
    171     assert_eq!(
    172         value["result"]["account"]["id"],
    173         public_identity.id.as_str()
    174     );
    175     assert_eq!(value["result"]["account"]["signer"], "watch_only");
    176     assert_eq!(value["result"]["account"]["custody"], "watch_only");
    177     assert_eq!(value["result"]["account"]["write_capable"], false);
    178     assert_eq!(value["result"]["account"]["is_default"], true);
    179 
    180     let list = sandbox.json_success(&["--format", "json", "account", "list"]);
    181     assert_eq!(list["result"]["count"], 0);
    182 }
    183 
    184 #[test]
    185 fn account_import_dry_run_validates_missing_profile_file() {
    186     let sandbox = RadrootsCliSandbox::new();
    187     let missing = sandbox.root().join("missing-account.json");
    188 
    189     let (output, value) = sandbox.json_output(&[
    190         "--format",
    191         "json",
    192         "--dry-run",
    193         "account",
    194         "import",
    195         missing.to_string_lossy().as_ref(),
    196     ]);
    197 
    198     assert!(!output.status.success());
    199     assert_eq!(value["operation_id"], "account.import");
    200     assert_eq!(value["errors"][0]["code"], "not_found");
    201     assert_eq!(value["errors"][0]["exit_code"], 4);
    202 }
    203 
    204 #[test]
    205 fn account_attach_secret_dry_run_validates_without_mutating_store() {
    206     let sandbox = RadrootsCliSandbox::new();
    207     let default_account = sandbox.json_success(&["--format", "json", "account", "create"]);
    208     let default_account_id = default_account["result"]["account"]["id"]
    209         .as_str()
    210         .expect("default account id");
    211     let identity = identity_secret(31);
    212     let public_identity = identity.to_public();
    213     let public_identity_file =
    214         write_public_identity_profile(&sandbox, "attach-dry-public", &public_identity);
    215     let secret_identity_file =
    216         write_secret_identity_profile(&sandbox, "attach-dry-secret", &identity);
    217     let imported = sandbox.json_success(&[
    218         "--format",
    219         "json",
    220         "--approval-token",
    221         "approve",
    222         "account",
    223         "import",
    224         public_identity_file.to_string_lossy().as_ref(),
    225     ]);
    226     let watch_account_id = imported["result"]["account"]["id"]
    227         .as_str()
    228         .expect("watch account id");
    229     assert_eq!(imported["result"]["account"]["signer"], "watch_only");
    230     assert_eq!(imported["result"]["account"]["custody"], "watch_only");
    231     assert_eq!(imported["result"]["account"]["write_capable"], false);
    232     assert_eq!(imported["result"]["account"]["is_default"], false);
    233 
    234     let value = sandbox.json_success(&[
    235         "--format",
    236         "json",
    237         "--dry-run",
    238         "account",
    239         "attach-secret",
    240         watch_account_id,
    241         secret_identity_file.to_string_lossy().as_ref(),
    242         "--default",
    243     ]);
    244 
    245     assert_eq!(value["operation_id"], "account.attach_secret");
    246     assert_eq!(value["dry_run"], true);
    247     assert_eq!(value["result"]["state"], "dry_run");
    248     assert_eq!(value["result"]["default"], true);
    249     assert_eq!(value["result"]["account"]["id"], watch_account_id);
    250     assert_eq!(value["result"]["account"]["signer"], "local");
    251     assert_eq!(value["result"]["account"]["custody"], "secret_backed");
    252     assert_eq!(value["result"]["account"]["write_capable"], true);
    253     assert_eq!(value["result"]["account"]["is_default"], true);
    254 
    255     let watch_get = sandbox.json_success(&["--format", "json", "account", "get", watch_account_id]);
    256     assert_eq!(
    257         watch_get["result"]["account_resolution"]["resolved_account"]["signer"],
    258         "watch_only"
    259     );
    260     assert_eq!(
    261         watch_get["result"]["account_resolution"]["resolved_account"]["custody"],
    262         "watch_only"
    263     );
    264     assert_eq!(
    265         watch_get["result"]["account_resolution"]["resolved_account"]["write_capable"],
    266         false
    267     );
    268     let selected = sandbox.json_success(&["--format", "json", "account", "selection", "get"]);
    269     assert_eq!(
    270         selected["result"]["account_resolution"]["resolved_account"]["id"],
    271         default_account_id
    272     );
    273 }
    274 
    275 #[test]
    276 fn account_attach_secret_attaches_matching_secret_and_can_make_default() {
    277     let sandbox = RadrootsCliSandbox::new();
    278     sandbox.json_success(&["--format", "json", "account", "create"]);
    279     let identity = identity_secret(32);
    280     let public_identity = identity.to_public();
    281     let public_identity_file =
    282         write_public_identity_profile(&sandbox, "attach-public", &public_identity);
    283     let secret_identity_file = write_secret_identity_profile(&sandbox, "attach-secret", &identity);
    284     let imported = sandbox.json_success(&[
    285         "--format",
    286         "json",
    287         "--approval-token",
    288         "approve",
    289         "account",
    290         "import",
    291         public_identity_file.to_string_lossy().as_ref(),
    292     ]);
    293     let watch_account_id = imported["result"]["account"]["id"]
    294         .as_str()
    295         .expect("watch account id");
    296 
    297     let attached = sandbox.json_success(&[
    298         "--format",
    299         "json",
    300         "--approval-token",
    301         "approve",
    302         "account",
    303         "attach-secret",
    304         watch_account_id,
    305         secret_identity_file.to_string_lossy().as_ref(),
    306         "--default",
    307     ]);
    308 
    309     assert_eq!(attached["operation_id"], "account.attach_secret");
    310     assert_eq!(attached["result"]["state"], "secret_attached");
    311     assert_eq!(attached["result"]["account"]["id"], watch_account_id);
    312     assert_eq!(attached["result"]["account"]["signer"], "local");
    313     assert_eq!(attached["result"]["account"]["custody"], "secret_backed");
    314     assert_eq!(attached["result"]["account"]["write_capable"], true);
    315     assert_eq!(attached["result"]["account"]["is_default"], true);
    316 
    317     let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
    318     assert_eq!(status["result"]["state"], "ready");
    319     assert_eq!(status["result"]["signer_account_id"], watch_account_id);
    320     assert_eq!(status["result"]["local"]["availability"], "secret_backed");
    321 }
    322 
    323 #[test]
    324 fn account_attach_secret_requires_approval_before_writing_secret() {
    325     let sandbox = RadrootsCliSandbox::new();
    326     let identity = identity_secret(33);
    327     let public_identity = identity.to_public();
    328     let public_identity_file =
    329         write_public_identity_profile(&sandbox, "attach-approval-public", &public_identity);
    330     let secret_identity_file =
    331         write_secret_identity_profile(&sandbox, "attach-approval-secret", &identity);
    332     let imported = sandbox.json_success(&[
    333         "--format",
    334         "json",
    335         "--approval-token",
    336         "approve",
    337         "account",
    338         "import",
    339         "--default",
    340         public_identity_file.to_string_lossy().as_ref(),
    341     ]);
    342     let account_id = imported["result"]["account"]["id"]
    343         .as_str()
    344         .expect("account id");
    345 
    346     let (output, value) = sandbox.json_output(&[
    347         "--format",
    348         "json",
    349         "account",
    350         "attach-secret",
    351         account_id,
    352         secret_identity_file.to_string_lossy().as_ref(),
    353     ]);
    354 
    355     assert!(!output.status.success());
    356     assert_eq!(value["operation_id"], "account.attach_secret");
    357     assert_eq!(value["errors"][0]["code"], "approval_required");
    358     assert_eq!(value["errors"][0]["exit_code"], 6);
    359     let get = sandbox.json_success(&["--format", "json", "account", "get", account_id]);
    360     assert_eq!(
    361         get["result"]["account_resolution"]["resolved_account"]["signer"],
    362         "watch_only"
    363     );
    364     assert_eq!(
    365         get["result"]["account_resolution"]["resolved_account"]["custody"],
    366         "watch_only"
    367     );
    368     assert_eq!(
    369         get["result"]["account_resolution"]["resolved_account"]["write_capable"],
    370         false
    371     );
    372 }
    373 
    374 #[test]
    375 fn account_attach_secret_reports_structured_validation_failures() {
    376     let sandbox = RadrootsCliSandbox::new();
    377     let matching_identity = identity_secret(34);
    378     let public_identity = matching_identity.to_public();
    379     let public_identity_file =
    380         write_public_identity_profile(&sandbox, "attach-fail-public", &public_identity);
    381     let secret_identity_file =
    382         write_secret_identity_profile(&sandbox, "attach-fail-secret", &matching_identity);
    383     let imported = sandbox.json_success(&[
    384         "--format",
    385         "json",
    386         "--approval-token",
    387         "approve",
    388         "account",
    389         "import",
    390         public_identity_file.to_string_lossy().as_ref(),
    391     ]);
    392     let account_id = imported["result"]["account"]["id"]
    393         .as_str()
    394         .expect("account id");
    395 
    396     let (missing_input_output, missing_input) =
    397         sandbox.json_output(&["--format", "json", "--dry-run", "account", "attach-secret"]);
    398     assert!(!missing_input_output.status.success());
    399     assert_eq!(missing_input["operation_id"], "account.attach_secret");
    400     assert_eq!(missing_input["errors"][0]["code"], "invalid_input");
    401 
    402     let (missing_account_output, missing_account) = sandbox.json_output(&[
    403         "--format",
    404         "json",
    405         "--dry-run",
    406         "account",
    407         "attach-secret",
    408         "missing-account",
    409         secret_identity_file.to_string_lossy().as_ref(),
    410     ]);
    411     assert!(!missing_account_output.status.success());
    412     assert_eq!(missing_account["errors"][0]["code"], "account_unresolved");
    413     assert_eq!(missing_account["errors"][0]["exit_code"], 5);
    414 
    415     let mismatched_identity = identity_secret(35);
    416     let mismatched_identity_file =
    417         write_secret_identity_profile(&sandbox, "attach-mismatch-secret", &mismatched_identity);
    418     let (mismatch_output, mismatch) = sandbox.json_output(&[
    419         "--format",
    420         "json",
    421         "--dry-run",
    422         "account",
    423         "attach-secret",
    424         account_id,
    425         mismatched_identity_file.to_string_lossy().as_ref(),
    426     ]);
    427     assert!(!mismatch_output.status.success());
    428     assert_eq!(mismatch["errors"][0]["code"], "account_mismatch");
    429     assert_eq!(mismatch["errors"][0]["exit_code"], 5);
    430 
    431     let invalid_identity_file = sandbox.root().join("attach-invalid-secret.json");
    432     fs::write(&invalid_identity_file, "{ invalid json").expect("write invalid identity");
    433     let (invalid_output, invalid) = sandbox.json_output(&[
    434         "--format",
    435         "json",
    436         "--dry-run",
    437         "account",
    438         "attach-secret",
    439         account_id,
    440         invalid_identity_file.to_string_lossy().as_ref(),
    441     ]);
    442     assert!(!invalid_output.status.success());
    443     assert_eq!(invalid["errors"][0]["code"], "validation_failed");
    444     assert_eq!(invalid["errors"][0]["exit_code"], 10);
    445 
    446     let mut unavailable_command = sandbox.command();
    447     unavailable_command
    448         .env("RADROOTS_CLI_ACCOUNT_SECRET_BACKEND", "host_vault")
    449         .env("RADROOTS_CLI_ACCOUNT_SECRET_FALLBACK", "none")
    450         .env("RADROOTS_CLI_ACCOUNT_HOST_VAULT_AVAILABLE", "false")
    451         .args([
    452             "--format",
    453             "json",
    454             "--dry-run",
    455             "account",
    456             "attach-secret",
    457             account_id,
    458             secret_identity_file.to_string_lossy().as_ref(),
    459         ]);
    460     let unavailable_output = unavailable_command
    461         .output()
    462         .expect("run unavailable backend");
    463     let unavailable = json_from_stdout(&unavailable_output);
    464     assert!(!unavailable_output.status.success());
    465     assert_eq!(unavailable["errors"][0]["code"], "operation_unavailable");
    466     assert_eq!(unavailable["errors"][0]["exit_code"], 3);
    467 }
    468 
    469 #[test]
    470 fn account_remove_dry_run_validates_selector_without_mutating_store() {
    471     let sandbox = RadrootsCliSandbox::new();
    472     let created = sandbox.json_success(&["--format", "json", "account", "create"]);
    473     let account_id = created["result"]["account"]["id"]
    474         .as_str()
    475         .expect("account id");
    476 
    477     let value = sandbox.json_success(&[
    478         "--format",
    479         "json",
    480         "--dry-run",
    481         "account",
    482         "remove",
    483         account_id,
    484     ]);
    485 
    486     assert_eq!(value["operation_id"], "account.remove");
    487     assert_eq!(value["result"]["state"], "dry_run");
    488     assert_eq!(value["result"]["removed_account"]["id"], account_id);
    489     assert_eq!(value["result"]["default_would_clear"], true);
    490     assert_eq!(value["result"]["remaining_account_count"], 0);
    491 
    492     let get = sandbox.json_success(&["--format", "json", "account", "get", account_id]);
    493     assert_eq!(get["result"]["state"], "ready");
    494     assert_eq!(
    495         get["result"]["account_resolution"]["resolved_account"]["id"],
    496         account_id
    497     );
    498 }
    499 
    500 #[test]
    501 fn account_selection_update_dry_run_validates_selector_without_mutating_selection() {
    502     let sandbox = RadrootsCliSandbox::new();
    503     let first = sandbox.json_success(&["--format", "json", "account", "create"]);
    504     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
    505     let first_account_id = first["result"]["account"]["id"]
    506         .as_str()
    507         .expect("first account id");
    508     let second_account_id = second["result"]["account"]["id"]
    509         .as_str()
    510         .expect("second account id");
    511 
    512     let value = sandbox.json_success(&[
    513         "--format",
    514         "json",
    515         "--dry-run",
    516         "account",
    517         "selection",
    518         "update",
    519         second_account_id,
    520     ]);
    521 
    522     assert_eq!(value["operation_id"], "account.selection.update");
    523     assert_eq!(value["result"]["state"], "dry_run");
    524     assert_eq!(value["result"]["account"]["id"], second_account_id);
    525 
    526     let selected = sandbox.json_success(&["--format", "json", "account", "selection", "get"]);
    527     assert_eq!(
    528         selected["result"]["account_resolution"]["resolved_account"]["id"],
    529         first_account_id
    530     );
    531 }
    532 
    533 #[test]
    534 fn account_selection_update_dry_run_rejects_missing_selector() {
    535     let sandbox = RadrootsCliSandbox::new();
    536     let (output, value) = sandbox.json_output(&[
    537         "--format",
    538         "json",
    539         "--dry-run",
    540         "account",
    541         "selection",
    542         "update",
    543         "missing-account",
    544     ]);
    545 
    546     assert!(!output.status.success());
    547     assert_eq!(value["operation_id"], "account.selection.update");
    548     assert_eq!(value["errors"][0]["code"], "account_unresolved");
    549     assert_eq!(value["errors"][0]["exit_code"], 5);
    550 }
    551 
    552 #[test]
    553 fn unresolved_account_override_returns_account_failure() {
    554     let sandbox = RadrootsCliSandbox::new();
    555     let (output, value) = sandbox.json_output(&[
    556         "--format",
    557         "json",
    558         "--account-id",
    559         "missing-account",
    560         "account",
    561         "get",
    562     ]);
    563 
    564     assert!(!output.status.success());
    565     assert_eq!(value["operation_id"], "account.get");
    566     assert_eq!(value["result"], serde_json::Value::Null);
    567     assert_eq!(value["errors"][0]["code"], "account_unresolved");
    568     assert_eq!(value["errors"][0]["exit_code"], 5);
    569     assert_eq!(value["errors"][0]["detail"]["class"], "account");
    570     assert_contains(&value["errors"][0]["message"], "account selector");
    571 }
    572 
    573 #[test]
    574 fn watch_only_import_reports_unconfigured_local_signer() {
    575     let sandbox = RadrootsCliSandbox::new();
    576     let public_identity = identity_public(11);
    577     let public_identity_file =
    578         write_public_identity_profile(&sandbox, "watch-only", &public_identity);
    579 
    580     let imported = sandbox.json_success(&[
    581         "--format",
    582         "json",
    583         "--approval-token",
    584         "approve",
    585         "account",
    586         "import",
    587         "--default",
    588         public_identity_file.to_string_lossy().as_ref(),
    589     ]);
    590 
    591     assert_eq!(imported["operation_id"], "account.import");
    592     assert_eq!(imported["result"]["state"], "imported");
    593     assert_eq!(
    594         imported["result"]["account"]["id"],
    595         public_identity.id.as_str()
    596     );
    597     assert_eq!(imported["result"]["account"]["signer"], "watch_only");
    598     assert_eq!(imported["result"]["account"]["custody"], "watch_only");
    599     assert_eq!(imported["result"]["account"]["write_capable"], false);
    600     assert_eq!(imported["result"]["account"]["is_default"], true);
    601 
    602     let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
    603 
    604     assert_eq!(status["result"]["mode"], "local");
    605     assert_eq!(status["result"]["state"], "unconfigured");
    606     assert_eq!(
    607         status["result"]["signer_account_id"],
    608         public_identity.id.as_str()
    609     );
    610     assert_eq!(
    611         status["result"]["account_resolution"]["source"],
    612         "default_account"
    613     );
    614     assert_eq!(
    615         status["result"]["account_resolution"]["resolved_account"]["signer"],
    616         "watch_only"
    617     );
    618     assert_eq!(
    619         status["result"]["account_resolution"]["resolved_account"]["custody"],
    620         "watch_only"
    621     );
    622     assert_eq!(
    623         status["result"]["account_resolution"]["resolved_account"]["write_capable"],
    624         false
    625     );
    626     assert_eq!(
    627         status["result"]["local"]["account_id"],
    628         public_identity.id.as_str()
    629     );
    630     assert_eq!(status["result"]["local"]["availability"], "public_only");
    631     assert_eq!(status["result"]["local"]["secret_backed"], false);
    632     assert_contains(&status["result"]["reason"], "not secret-backed");
    633     assert!(
    634         status["result"]["write_kinds"]
    635             .as_array()
    636             .expect("write kinds")
    637             .iter()
    638             .all(|kind| kind["ready"] == false)
    639     );
    640 }
    641 
    642 #[test]
    643 fn myc_signer_status_reports_missing_binding() {
    644     let sandbox = RadrootsCliSandbox::new();
    645     let missing_myc = sandbox.root().join("bin/missing-myc");
    646     configure_myc_mode(&sandbox, &missing_myc);
    647 
    648     let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]);
    649 
    650     assert!(output.status.success());
    651     assert_eq!(value["operation_id"], "signer.status.get");
    652     assert!(value["errors"].as_array().expect("errors").is_empty());
    653     assert_eq!(value["result"]["mode"], "myc");
    654     assert_eq!(value["result"]["state"], "unconfigured");
    655     assert_eq!(value["result"]["binding"]["state"], "unconfigured");
    656     assert_eq!(value["result"]["myc"]["state"], "unconfigured");
    657     assert_eq!(value["result"]["myc"]["ready"], false);
    658     assert_contains(
    659         &value["result"]["reason"],
    660         "signer.remote_nip46 binding is missing",
    661     );
    662     assert_no_removed_command_reference(&value, &["signer", "status", "get"]);
    663 }
    664 
    665 #[test]
    666 fn myc_signer_status_fails_closed_when_managed_account_is_unresolved() {
    667     let sandbox = RadrootsCliSandbox::new();
    668     let missing_myc = sandbox.root().join("bin/missing-myc");
    669     let remote_signer = identity_public(91);
    670     sandbox.write_app_config(&format!(
    671         r#"[signer]
    672 backend = "myc"
    673 
    674 [myc]
    675 executable = "{}"
    676 
    677 [[capability_binding]]
    678 capability = "signer.remote_nip46"
    679 provider = "myc"
    680 target_kind = "explicit_endpoint"
    681 target = "bunker://{}?relay=wss%3A%2F%2Frelay.example"
    682 managed_account_ref = "acct_missing"
    683 signer_session_ref = "session_missing"
    684 "#,
    685         toml_string(missing_myc.display().to_string().as_str()),
    686         remote_signer.public_key_hex,
    687     ));
    688 
    689     let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
    690 
    691     assert_eq!(value["result"]["mode"], "myc");
    692     assert_eq!(value["result"]["state"], "unconfigured");
    693     assert_eq!(value["result"]["binding"]["state"], "unconfigured");
    694     assert_eq!(value["result"]["myc"]["ready"], false);
    695     assert_contains(
    696         &value["result"]["reason"],
    697         "managed_account_ref `acct_missing` cannot be evaluated",
    698     );
    699     assert!(
    700         value["result"]["write_kinds"]
    701             .as_array()
    702             .expect("write kinds")
    703             .iter()
    704             .all(|kind| kind["ready"] == false)
    705     );
    706 }
    707 
    708 #[cfg(unix)]
    709 #[test]
    710 fn myc_signer_status_does_not_invoke_configured_executable() {
    711     let sandbox = RadrootsCliSandbox::new();
    712     let invoked = sandbox.root().join("myc-invoked.txt");
    713     let myc = sandbox.write_fake_myc(
    714         "myc-deferred",
    715         format!(
    716             "printf invoked > '{}'",
    717             shell_single_quoted(invoked.to_string_lossy().as_ref())
    718         )
    719         .as_str(),
    720     );
    721     configure_myc_mode(&sandbox, &myc);
    722 
    723     let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]);
    724 
    725     assert!(output.status.success());
    726     assert_eq!(value["operation_id"], "signer.status.get");
    727     assert!(value["errors"].as_array().expect("errors").is_empty());
    728     assert_eq!(value["result"]["state"], "unconfigured");
    729     assert_eq!(value["result"]["myc"]["ready"], false);
    730     assert!(!invoked.exists(), "target CLI must not execute MYC");
    731 }
    732 
    733 #[test]
    734 fn myc_mode_allows_read_inspection_commands() {
    735     let sandbox = RadrootsCliSandbox::new();
    736     let missing_myc = sandbox.root().join("bin/missing-myc");
    737     configure_myc_mode(&sandbox, &missing_myc);
    738 
    739     for args in [
    740         &["--format", "json", "workspace", "get"][..],
    741         &["--format", "json", "config", "get"][..],
    742         &["--format", "json", "account", "list"][..],
    743         &["--format", "json", "relay", "list"][..],
    744     ] {
    745         let (output, value) = sandbox.json_output(args);
    746 
    747         assert!(
    748             output.status.success(),
    749             "`{args:?}` should remain observable under MYC mode: {value:?}"
    750         );
    751         assert_eq!(value["errors"].as_array().expect("errors").len(), 0);
    752     }
    753 }
    754 
    755 #[test]
    756 fn local_listing_publish_fails_without_local_account_authority() {
    757     let sandbox = RadrootsCliSandbox::new();
    758     let account = sandbox.json_success(&["--format", "json", "account", "create"]);
    759     let account_id = account["result"]["account"]["id"]
    760         .as_str()
    761         .expect("account id");
    762     let listing_file = create_listing_draft(&sandbox, "local-no-account");
    763     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    764     sandbox.json_success(&[
    765         "--format",
    766         "json",
    767         "--approval-token",
    768         "approve",
    769         "account",
    770         "remove",
    771         account_id,
    772     ]);
    773 
    774     let (output, value) = sandbox.json_output(&[
    775         "--format",
    776         "json",
    777         "--relay",
    778         "ws://127.0.0.1:9",
    779         "--approval-token",
    780         "approve",
    781         "listing",
    782         "publish",
    783         listing_file.to_string_lossy().as_ref(),
    784     ]);
    785 
    786     assert!(!output.status.success());
    787     assert_eq!(value["operation_id"], "listing.publish");
    788     assert_eq!(value["result"], serde_json::Value::Null);
    789     assert_eq!(value["errors"][0]["code"], "account_unresolved");
    790     assert_eq!(value["errors"][0]["exit_code"], 5);
    791     assert_eq!(value["errors"][0]["detail"]["class"], "account");
    792     assert_contains(
    793         &value["errors"][0]["message"],
    794         "listing-bound seller account",
    795     );
    796 }
    797 
    798 #[test]
    799 fn local_listing_publish_dry_run_validates_local_account_authority() {
    800     let sandbox = RadrootsCliSandbox::new();
    801     let account = sandbox.json_success(&["--format", "json", "account", "create"]);
    802     let account_id = account["result"]["account"]["id"]
    803         .as_str()
    804         .expect("account id");
    805     let listing_file = create_listing_draft(&sandbox, "local-dry-run-no-account");
    806     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    807     sandbox.json_success(&[
    808         "--format",
    809         "json",
    810         "--approval-token",
    811         "approve",
    812         "account",
    813         "remove",
    814         account_id,
    815     ]);
    816 
    817     let (output, value) = sandbox.json_output(&[
    818         "--format",
    819         "json",
    820         "--dry-run",
    821         "listing",
    822         "publish",
    823         listing_file.to_string_lossy().as_ref(),
    824     ]);
    825 
    826     assert!(!output.status.success());
    827     assert_eq!(value["operation_id"], "listing.publish");
    828     assert_eq!(value["result"], serde_json::Value::Null);
    829     assert_eq!(value["errors"][0]["code"], "account_unresolved");
    830     assert_eq!(value["errors"][0]["detail"]["class"], "account");
    831     assert_no_removed_command_reference(&value, &["listing", "publish", "--dry-run"]);
    832 }
    833 
    834 #[test]
    835 fn local_listing_update_dry_run_validates_local_account_authority() {
    836     let sandbox = RadrootsCliSandbox::new();
    837     let account = sandbox.json_success(&["--format", "json", "account", "create"]);
    838     let account_id = account["result"]["account"]["id"]
    839         .as_str()
    840         .expect("account id");
    841     let listing_file = create_listing_draft(&sandbox, "local-update-dry-run-no-account");
    842     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    843     sandbox.json_success(&[
    844         "--format",
    845         "json",
    846         "--approval-token",
    847         "approve",
    848         "account",
    849         "remove",
    850         account_id,
    851     ]);
    852 
    853     let (output, value) = sandbox.json_output(&[
    854         "--format",
    855         "json",
    856         "--dry-run",
    857         "listing",
    858         "update",
    859         listing_file.to_string_lossy().as_ref(),
    860     ]);
    861 
    862     assert!(!output.status.success());
    863     assert_eq!(value["operation_id"], "listing.update");
    864     assert_eq!(value["result"], serde_json::Value::Null);
    865     assert_eq!(value["errors"][0]["code"], "account_unresolved");
    866     assert_eq!(value["errors"][0]["detail"]["class"], "account");
    867     assert_no_removed_command_reference(&value, &["listing", "update", "--dry-run"]);
    868 }
    869 
    870 #[test]
    871 fn local_listing_update_dry_run_rejects_mismatched_local_account() {
    872     let sandbox = RadrootsCliSandbox::new();
    873     let first = sandbox.json_success(&["--format", "json", "account", "create"]);
    874     let listing_file = create_listing_draft(&sandbox, "local-update-dry-run-mismatch");
    875     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    876     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
    877     let second_account_id = second["result"]["account"]["id"]
    878         .as_str()
    879         .expect("second account id");
    880 
    881     let (output, value) = sandbox.json_output(&[
    882         "--format",
    883         "json",
    884         "--account-id",
    885         second_account_id,
    886         "--dry-run",
    887         "listing",
    888         "update",
    889         listing_file.to_string_lossy().as_ref(),
    890     ]);
    891 
    892     assert_ne!(
    893         first["result"]["account"]["id"],
    894         second["result"]["account"]["id"]
    895     );
    896     assert!(!output.status.success());
    897     assert_eq!(value["operation_id"], "listing.update");
    898     assert_eq!(value["errors"][0]["code"], "account_mismatch");
    899     assert_eq!(value["errors"][0]["detail"]["class"], "account");
    900 }
    901 
    902 #[test]
    903 fn local_listing_publish_fails_without_configured_relay() {
    904     let sandbox = RadrootsCliSandbox::new();
    905     sandbox.json_success(&["--format", "json", "account", "create"]);
    906     let listing_file = create_listing_draft(&sandbox, "local-unavailable");
    907     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    908 
    909     let (output, value) = sandbox.json_output(&[
    910         "--format",
    911         "json",
    912         "--approval-token",
    913         "approve",
    914         "listing",
    915         "publish",
    916         listing_file.to_string_lossy().as_ref(),
    917     ]);
    918 
    919     assert!(!output.status.success());
    920     assert_eq!(value["operation_id"], "listing.publish");
    921     assert_eq!(value["result"], serde_json::Value::Null);
    922     assert_eq!(value["errors"][0]["code"], "empty_target_relays");
    923     assert_eq!(value["errors"][0]["detail"]["class"], "configuration");
    924     assert_contains(&value["errors"][0]["message"], "sdk empty target relays");
    925     assert_no_removed_command_reference(&value, &["listing", "publish"]);
    926     assert_no_daemon_runtime_reference(&value, &["listing", "publish"]);
    927 }
    928 
    929 #[test]
    930 fn local_listing_publish_dry_run_does_not_sign_matching_listing() {
    931     let sandbox = RadrootsCliSandbox::new();
    932     sandbox.json_success(&["--format", "json", "account", "create"]);
    933     let listing_file = create_listing_draft(&sandbox, "local-dry-run");
    934     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    935 
    936     let value = sandbox.json_success(&[
    937         "--format",
    938         "json",
    939         "--dry-run",
    940         "listing",
    941         "publish",
    942         listing_file.to_string_lossy().as_ref(),
    943     ]);
    944 
    945     assert_eq!(value["operation_id"], "listing.publish");
    946     assert_eq!(value["dry_run"], true);
    947     assert_eq!(value["result"]["state"], "dry_run");
    948     assert_eq!(value["result"]["dry_run"], true);
    949     assert_eq!(
    950         value["result"]["event_id"]
    951             .as_str()
    952             .expect("dry-run event id")
    953             .len(),
    954         64
    955     );
    956     assert!(
    957         !sandbox.root().join("data/apps/cli/replica/sdk").exists(),
    958         "dry-run must not materialize durable SDK storage"
    959     );
    960     assert_no_removed_command_reference(&value, &["listing", "publish", "--dry-run"]);
    961     assert_no_daemon_runtime_reference(&value, &["listing", "publish", "--dry-run"]);
    962 }
    963 
    964 #[test]
    965 fn local_listing_archive_dry_run_validates_local_account_authority() {
    966     let sandbox = RadrootsCliSandbox::new();
    967     sandbox.json_success(&["--format", "json", "account", "create"]);
    968     let listing_file = create_listing_draft(&sandbox, "local-archive-mismatch");
    969     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
    970     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
    971     let second_account_id = second["result"]["account"]["id"]
    972         .as_str()
    973         .expect("second account id");
    974 
    975     let (output, value) = sandbox.json_output(&[
    976         "--format",
    977         "json",
    978         "--account-id",
    979         second_account_id,
    980         "--dry-run",
    981         "listing",
    982         "archive",
    983         listing_file.to_string_lossy().as_ref(),
    984     ]);
    985 
    986     assert!(!output.status.success());
    987     assert_eq!(value["operation_id"], "listing.archive");
    988     assert_eq!(value["errors"][0]["code"], "account_mismatch");
    989     assert_eq!(value["errors"][0]["detail"]["class"], "account");
    990 }
    991 
    992 #[test]
    993 fn local_listing_publish_fails_when_selected_account_does_not_match_seller() {
    994     let sandbox = RadrootsCliSandbox::new();
    995     let first = sandbox.json_success(&["--format", "json", "account", "create"]);
    996     let first_account_id = first["result"]["account"]["id"]
    997         .as_str()
    998         .expect("first account id");
    999     let listing_file = create_listing_draft(&sandbox, "local-mismatch");
   1000     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
   1001     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
   1002     let second_account_id = second["result"]["account"]["id"]
   1003         .as_str()
   1004         .expect("second account id");
   1005     assert_ne!(first_account_id, second_account_id);
   1006 
   1007     let (output, value) = sandbox.json_output(&[
   1008         "--format",
   1009         "json",
   1010         "--relay",
   1011         "ws://127.0.0.1:9",
   1012         "--account-id",
   1013         second_account_id,
   1014         "--approval-token",
   1015         "approve",
   1016         "listing",
   1017         "publish",
   1018         listing_file.to_string_lossy().as_ref(),
   1019     ]);
   1020 
   1021     assert!(!output.status.success());
   1022     assert_eq!(value["operation_id"], "listing.publish");
   1023     assert_eq!(value["result"], serde_json::Value::Null);
   1024     assert_eq!(value["errors"][0]["code"], "account_mismatch");
   1025     assert_eq!(value["errors"][0]["exit_code"], 5);
   1026     assert_eq!(value["errors"][0]["detail"]["class"], "account");
   1027     assert_contains(
   1028         &value["errors"][0]["message"],
   1029         "listing draft is bound to seller account",
   1030     );
   1031     assert_no_removed_command_reference(&value, &["listing", "publish", "account mismatch"]);
   1032 }
   1033 
   1034 #[test]
   1035 fn local_farm_publish_dry_run_validates_secret_backed_account() {
   1036     let sandbox = RadrootsCliSandbox::new();
   1037     sandbox.json_success(&["--format", "json", "account", "create"]);
   1038     sandbox.json_success(&[
   1039         "--format",
   1040         "json",
   1041         "farm",
   1042         "create",
   1043         "--name",
   1044         "Green Farm",
   1045         "--location",
   1046         "farmstand",
   1047         "--country",
   1048         "US",
   1049         "--delivery-method",
   1050         "pickup",
   1051     ]);
   1052 
   1053     let value = sandbox.json_success(&[
   1054         "--format",
   1055         "json",
   1056         "--relay",
   1057         "ws://127.0.0.1:9",
   1058         "--dry-run",
   1059         "farm",
   1060         "publish",
   1061     ]);
   1062 
   1063     assert_eq!(value["operation_id"], "farm.publish");
   1064     assert_eq!(value["dry_run"], true);
   1065     assert_eq!(value["result"]["state"], "dry_run");
   1066     assert_eq!(value["result"]["dry_run"], true);
   1067     assert_no_daemon_runtime_reference(&value, &["farm", "publish", "--dry-run"]);
   1068 }
   1069 
   1070 #[test]
   1071 fn local_farm_publish_dry_run_fails_without_configured_relay() {
   1072     let sandbox = RadrootsCliSandbox::new();
   1073     sandbox.json_success(&["--format", "json", "account", "create"]);
   1074     sandbox.json_success(&[
   1075         "--format",
   1076         "json",
   1077         "farm",
   1078         "create",
   1079         "--name",
   1080         "Green Farm",
   1081         "--location",
   1082         "farmstand",
   1083         "--country",
   1084         "US",
   1085         "--delivery-method",
   1086         "pickup",
   1087     ]);
   1088 
   1089     let (output, value) =
   1090         sandbox.json_output(&["--format", "json", "--dry-run", "farm", "publish"]);
   1091 
   1092     assert!(!output.status.success());
   1093     assert_eq!(value["operation_id"], "farm.publish");
   1094     assert_eq!(value["dry_run"], true);
   1095     assert_eq!(value["result"], serde_json::Value::Null);
   1096     assert_eq!(value["errors"][0]["code"], "network_unavailable");
   1097     assert_eq!(value["errors"][0]["detail"]["class"], "network");
   1098     assert_contains(
   1099         &value["errors"][0]["message"],
   1100         "requires at least one configured relay",
   1101     );
   1102     assert_no_removed_command_reference(&value, &["farm", "publish", "--dry-run"]);
   1103     assert_no_daemon_runtime_reference(&value, &["farm", "publish", "--dry-run"]);
   1104 }
   1105 
   1106 #[test]
   1107 fn local_farm_publish_fails_without_configured_relay() {
   1108     let sandbox = RadrootsCliSandbox::new();
   1109     sandbox.json_success(&["--format", "json", "account", "create"]);
   1110     sandbox.json_success(&[
   1111         "--format",
   1112         "json",
   1113         "farm",
   1114         "create",
   1115         "--name",
   1116         "Green Farm",
   1117         "--location",
   1118         "farmstand",
   1119         "--country",
   1120         "US",
   1121         "--delivery-method",
   1122         "pickup",
   1123     ]);
   1124 
   1125     let (output, value) = sandbox.json_output(&[
   1126         "--format",
   1127         "json",
   1128         "--approval-token",
   1129         "approve",
   1130         "farm",
   1131         "publish",
   1132     ]);
   1133 
   1134     assert!(!output.status.success());
   1135     assert_eq!(value["operation_id"], "farm.publish");
   1136     assert_eq!(value["result"], serde_json::Value::Null);
   1137     assert_eq!(value["errors"][0]["code"], "network_unavailable");
   1138     assert_eq!(value["errors"][0]["detail"]["class"], "network");
   1139     assert_contains(
   1140         &value["errors"][0]["message"],
   1141         "requires at least one configured relay",
   1142     );
   1143     assert_no_removed_command_reference(&value, &["farm", "publish"]);
   1144     assert_no_daemon_runtime_reference(&value, &["farm", "publish"]);
   1145 }
   1146 
   1147 #[test]
   1148 fn farm_setup_actions_offer_publish_only_when_relay_publish_executable() {
   1149     let sandbox = RadrootsCliSandbox::new();
   1150     sandbox.json_success(&["--format", "json", "account", "create"]);
   1151 
   1152     let unconfigured = sandbox.json_success(&[
   1153         "--format",
   1154         "json",
   1155         "farm",
   1156         "create",
   1157         "--name",
   1158         "Green Farm",
   1159         "--location",
   1160         "farmstand",
   1161         "--country",
   1162         "US",
   1163         "--delivery-method",
   1164         "pickup",
   1165     ]);
   1166 
   1167     assert_action_present(&unconfigured, "radroots farm readiness check");
   1168     assert_action_absent(&unconfigured, "radroots farm publish");
   1169 
   1170     let configured = sandbox.json_success(&[
   1171         "--format",
   1172         "json",
   1173         "--relay",
   1174         "ws://127.0.0.1:9",
   1175         "farm",
   1176         "profile",
   1177         "update",
   1178         "--field",
   1179         "name",
   1180         "--value",
   1181         "Green Farm Updated",
   1182     ]);
   1183 
   1184     assert_action_present(&configured, "radroots farm readiness check");
   1185     assert_action_present(&configured, "radroots farm publish");
   1186 }
   1187 
   1188 #[test]
   1189 fn farm_setup_actions_withhold_publish_for_watch_only_account() {
   1190     let sandbox = RadrootsCliSandbox::new();
   1191     let public_identity = identity_public(51);
   1192     let public_identity_file =
   1193         write_public_identity_profile(&sandbox, "farm-watch-only", &public_identity);
   1194     sandbox.json_success(&[
   1195         "--format",
   1196         "json",
   1197         "--approval-token",
   1198         "approve",
   1199         "account",
   1200         "import",
   1201         "--default",
   1202         public_identity_file.to_string_lossy().as_ref(),
   1203     ]);
   1204 
   1205     let created = sandbox.json_success(&[
   1206         "--format",
   1207         "json",
   1208         "--relay",
   1209         "ws://127.0.0.1:9",
   1210         "farm",
   1211         "create",
   1212         "--name",
   1213         "Watch Farm",
   1214         "--location",
   1215         "farmstand",
   1216         "--country",
   1217         "US",
   1218         "--delivery-method",
   1219         "pickup",
   1220     ]);
   1221 
   1222     assert_action_present(&created, "radroots farm readiness check");
   1223     assert_action_absent(&created, "radroots farm publish");
   1224 
   1225     let updated = sandbox.json_success(&[
   1226         "--format",
   1227         "json",
   1228         "--relay",
   1229         "ws://127.0.0.1:9",
   1230         "farm",
   1231         "profile",
   1232         "update",
   1233         "--field",
   1234         "name",
   1235         "--value",
   1236         "Watch Farm Updated",
   1237     ]);
   1238 
   1239     assert_action_present(&updated, "radroots farm readiness check");
   1240     assert_action_absent(&updated, "radroots farm publish");
   1241 }
   1242 
   1243 #[test]
   1244 fn local_farm_publish_reports_sdk_push_failure_without_profile_publish() {
   1245     let sandbox = RadrootsCliSandbox::new();
   1246     sandbox.json_success(&["--format", "json", "account", "create"]);
   1247     sandbox.json_success(&[
   1248         "--format",
   1249         "json",
   1250         "farm",
   1251         "create",
   1252         "--name",
   1253         "Green Farm",
   1254         "--location",
   1255         "farmstand",
   1256         "--country",
   1257         "US",
   1258         "--delivery-method",
   1259         "pickup",
   1260     ]);
   1261     let relay_url = "ws://127.0.0.1:9";
   1262 
   1263     let (output, value) = sandbox.json_output(&[
   1264         "--format",
   1265         "json",
   1266         "--relay",
   1267         relay_url,
   1268         "--approval-token",
   1269         "approve",
   1270         "--idempotency-key",
   1271         "farm_partial",
   1272         "farm",
   1273         "publish",
   1274     ]);
   1275 
   1276     assert!(!output.status.success());
   1277     assert_eq!(value["operation_id"], "farm.publish");
   1278     assert_eq!(value["result"], serde_json::Value::Null);
   1279     assert_eq!(value["errors"][0]["code"], "network_unavailable");
   1280     assert_eq!(value["errors"][0]["detail"]["class"], "network");
   1281     assert_contains(
   1282         &value["errors"][0]["message"],
   1283         "SDK relay publish did not reach accepted quorum",
   1284     );
   1285     let detail = &value["errors"][0]["detail"];
   1286     assert_eq!(detail["source"], "SDK farm publish ยท configured signer");
   1287     assert_eq!(detail["state"], "unavailable");
   1288     assert_eq!(detail["profile"]["state"], "not_submitted");
   1289     assert_eq!(detail["farm"]["state"], "unavailable");
   1290     assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null);
   1291     assert_eq!(
   1292         detail["farm"]["event_id"]
   1293             .as_str()
   1294             .expect("sdk farm event id")
   1295             .len(),
   1296         64
   1297     );
   1298     assert_eq!(detail["profile"]["idempotency_key"], "farm_partial:profile");
   1299     assert_eq!(detail["farm"]["idempotency_key"], "farm_partial:farm");
   1300     assert_eq!(detail["actions"][0], "radroots sync push");
   1301     assert_eq!(detail["farm"]["target_relays"][0], relay_url);
   1302     assert_relay_url(&detail["farm"]["failed_relays"][0]["relay"], relay_url);
   1303     assert_no_removed_command_reference(&value, &["farm", "publish"]);
   1304     assert_no_daemon_runtime_reference(&value, &["farm", "publish"]);
   1305 
   1306     let persisted = sandbox.json_success(&["--format", "json", "farm", "get"]);
   1307     assert_eq!(
   1308         persisted["result"]["document"]["publication"]["profile_state"],
   1309         "not_published"
   1310     );
   1311     assert_eq!(
   1312         persisted["result"]["document"]["publication"]["farm_state"],
   1313         "not_published"
   1314     );
   1315     assert_eq!(
   1316         persisted["result"]["document"]["publication"]["profile_event_id"],
   1317         serde_json::Value::Null
   1318     );
   1319     assert_eq!(
   1320         persisted["result"]["document"]["publication"]["farm_event_id"],
   1321         serde_json::Value::Null
   1322     );
   1323 }
   1324 
   1325 #[test]
   1326 fn local_farm_publish_does_not_persist_publication_until_sdk_push_publishes() {
   1327     let sandbox = RadrootsCliSandbox::new();
   1328     sandbox.json_success(&["--format", "json", "account", "create"]);
   1329     sandbox.json_success(&[
   1330         "--format",
   1331         "json",
   1332         "farm",
   1333         "create",
   1334         "--name",
   1335         "Green Farm",
   1336         "--location",
   1337         "farmstand",
   1338         "--country",
   1339         "US",
   1340         "--delivery-method",
   1341         "pickup",
   1342     ]);
   1343     let relay_url = "ws://127.0.0.1:9";
   1344 
   1345     let (output, value) = sandbox.json_output(&[
   1346         "--format",
   1347         "json",
   1348         "--relay",
   1349         relay_url,
   1350         "--approval-token",
   1351         "approve",
   1352         "--idempotency-key",
   1353         "farm_success",
   1354         "farm",
   1355         "publish",
   1356     ]);
   1357 
   1358     assert!(!output.status.success());
   1359     assert_eq!(value["operation_id"], "farm.publish");
   1360     assert_eq!(value["result"], serde_json::Value::Null);
   1361     assert_eq!(value["errors"][0]["code"], "network_unavailable");
   1362     assert_eq!(value["errors"][0]["detail"]["class"], "network");
   1363     let detail = &value["errors"][0]["detail"];
   1364     assert_eq!(detail["source"], "SDK farm publish ยท configured signer");
   1365     assert_eq!(detail["profile"]["state"], "not_submitted");
   1366     assert_eq!(detail["farm"]["state"], "unavailable");
   1367     assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null);
   1368     assert_eq!(
   1369         detail["farm"]["event_id"]
   1370             .as_str()
   1371             .expect("sdk farm event id")
   1372             .len(),
   1373         64
   1374     );
   1375     assert_eq!(detail["profile"]["idempotency_key"], "farm_success:profile");
   1376     assert_eq!(detail["farm"]["idempotency_key"], "farm_success:farm");
   1377     assert_no_removed_command_reference(&value, &["farm", "publish"]);
   1378     assert_no_daemon_runtime_reference(&value, &["farm", "publish"]);
   1379 
   1380     let persisted = sandbox.json_success(&["--format", "json", "farm", "get"]);
   1381     assert_eq!(
   1382         persisted["result"]["document"]["publication"]["profile_state"],
   1383         "not_published"
   1384     );
   1385     assert_eq!(
   1386         persisted["result"]["document"]["publication"]["farm_state"],
   1387         "not_published"
   1388     );
   1389     assert_eq!(
   1390         persisted["result"]["document"]["publication"]["profile_event_id"],
   1391         serde_json::Value::Null
   1392     );
   1393     assert_eq!(
   1394         persisted["result"]["document"]["publication"]["farm_event_id"],
   1395         serde_json::Value::Null
   1396     );
   1397 }
   1398 
   1399 #[test]
   1400 fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() {
   1401     let sandbox = RadrootsCliSandbox::new();
   1402     let first = sandbox.json_success(&["--format", "json", "account", "create"]);
   1403     let first_account_id = first["result"]["account"]["id"]
   1404         .as_str()
   1405         .expect("first account id");
   1406     let farm = sandbox.json_success(&[
   1407         "--format",
   1408         "json",
   1409         "farm",
   1410         "create",
   1411         "--name",
   1412         "Green Farm",
   1413         "--location",
   1414         "farmstand",
   1415         "--country",
   1416         "US",
   1417         "--delivery-method",
   1418         "pickup",
   1419     ]);
   1420     let farm_path = farm["result"]["config"]["path"]
   1421         .as_str()
   1422         .expect("farm path");
   1423     let farm_d_tag = farm["result"]["config"]["farm_d_tag"]
   1424         .as_str()
   1425         .expect("farm d tag");
   1426     let first_pubkey = farm["result"]["config"]["seller_pubkey"]
   1427         .as_str()
   1428         .expect("first pubkey");
   1429     assert_eq!(
   1430         farm["result"]["config"]["seller_account_id"],
   1431         first_account_id
   1432     );
   1433     assert_eq!(farm["result"]["config"]["seller_pubkey"], first_pubkey);
   1434     assert_eq!(
   1435         farm["result"]["config"]["seller_actor_source"],
   1436         "farm_config"
   1437     );
   1438     assert!(
   1439         farm["result"]["config"]
   1440             .get("selected_account_id")
   1441             .is_none()
   1442     );
   1443 
   1444     let published = sandbox.json_success(&["--format", "json", "farm", "get"]);
   1445     assert_eq!(
   1446         published["result"]["document"]["publication"]["profile_state"],
   1447         "not_published"
   1448     );
   1449     assert_eq!(
   1450         published["result"]["document"]["publication"]["farm_state"],
   1451         "not_published"
   1452     );
   1453 
   1454     let same_seller_dry_run = sandbox.json_success(&[
   1455         "--format",
   1456         "json",
   1457         "--dry-run",
   1458         "farm",
   1459         "rebind",
   1460         first_account_id,
   1461     ]);
   1462     assert_eq!(same_seller_dry_run["operation_id"], "farm.rebind");
   1463     assert_eq!(
   1464         same_seller_dry_run["result"]["publication_state_action"],
   1465         "preserved"
   1466     );
   1467 
   1468     let same_seller_live = sandbox.json_success(&[
   1469         "--format",
   1470         "json",
   1471         "--approval-token",
   1472         "approve",
   1473         "farm",
   1474         "rebind",
   1475         first_account_id,
   1476     ]);
   1477     assert_eq!(same_seller_live["operation_id"], "farm.rebind");
   1478     assert_eq!(
   1479         same_seller_live["result"]["publication_state_action"],
   1480         "preserved"
   1481     );
   1482     let same_seller_get = sandbox.json_success(&["--format", "json", "farm", "get"]);
   1483     assert_eq!(
   1484         same_seller_get["result"]["document"]["publication"]["profile_state"],
   1485         "not_published"
   1486     );
   1487     assert_eq!(
   1488         same_seller_get["result"]["document"]["publication"]["farm_state"],
   1489         "not_published"
   1490     );
   1491 
   1492     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
   1493     let second_account_id = second["result"]["account"]["id"]
   1494         .as_str()
   1495         .expect("second account id");
   1496     assert_ne!(first_account_id, second_account_id);
   1497     sandbox.json_success(&[
   1498         "--format",
   1499         "json",
   1500         "account",
   1501         "selection",
   1502         "update",
   1503         second_account_id,
   1504     ]);
   1505 
   1506     let (retarget_output, retarget) = sandbox.json_output(&[
   1507         "--format",
   1508         "json",
   1509         "farm",
   1510         "create",
   1511         "--name",
   1512         "Green Farm Retarget",
   1513         "--location",
   1514         "farmstand",
   1515         "--country",
   1516         "US",
   1517         "--delivery-method",
   1518         "pickup",
   1519     ]);
   1520     assert!(!retarget_output.status.success());
   1521     assert_eq!(retarget["operation_id"], "farm.create");
   1522     assert_eq!(retarget["errors"][0]["code"], "account_mismatch");
   1523     assert_contains(&retarget["errors"][0]["message"], "farm-bound seller");
   1524     assert_eq!(
   1525         retarget["errors"][0]["detail"]["seller_actor_source"],
   1526         "farm_config"
   1527     );
   1528     assert_eq!(
   1529         retarget["errors"][0]["detail"]["farm_bound_seller_account_id"],
   1530         first_account_id
   1531     );
   1532     assert_eq!(
   1533         retarget["errors"][0]["detail"]["attempted_seller_account_id"],
   1534         second_account_id
   1535     );
   1536     assert_next_action_present(
   1537         &retarget,
   1538         format!("radroots farm rebind {second_account_id}").as_str(),
   1539     );
   1540 
   1541     let (missing_rebind_output, missing_rebind) = sandbox.json_output(&[
   1542         "--format",
   1543         "json",
   1544         "--dry-run",
   1545         "farm",
   1546         "rebind",
   1547         "acct_missing",
   1548     ]);
   1549     assert!(!missing_rebind_output.status.success());
   1550     assert_eq!(missing_rebind["operation_id"], "farm.rebind");
   1551     assert_eq!(missing_rebind["errors"][0]["code"], "account_unresolved");
   1552     assert_eq!(
   1553         missing_rebind["errors"][0]["detail"]["seller_actor_source"],
   1554         "farm_config"
   1555     );
   1556     assert_eq!(
   1557         missing_rebind["errors"][0]["detail"]["selector"],
   1558         "acct_missing"
   1559     );
   1560     assert_next_action_present(&missing_rebind, "radroots account import <path>");
   1561     assert_next_action_present(&missing_rebind, "radroots account create");
   1562 
   1563     let publish_dry_run = sandbox.json_success(&[
   1564         "--format",
   1565         "json",
   1566         "--relay",
   1567         "ws://127.0.0.1:9",
   1568         "--dry-run",
   1569         "farm",
   1570         "publish",
   1571     ]);
   1572     assert_eq!(publish_dry_run["operation_id"], "farm.publish");
   1573     assert_eq!(publish_dry_run["result"]["state"], "dry_run");
   1574     assert_eq!(
   1575         publish_dry_run["result"]["seller_account_id"],
   1576         first_account_id
   1577     );
   1578     assert_eq!(publish_dry_run["result"]["seller_pubkey"], first_pubkey);
   1579     assert!(
   1580         publish_dry_run["result"]
   1581             .get("selected_account_id")
   1582             .is_none()
   1583     );
   1584 
   1585     let listing_path = sandbox.root().join("drift-listing.toml");
   1586     let listing = sandbox.json_success(&[
   1587         "--format",
   1588         "json",
   1589         "listing",
   1590         "create",
   1591         "--output",
   1592         listing_path.to_string_lossy().as_ref(),
   1593         "--key",
   1594         "drift-eggs",
   1595         "--title",
   1596         "Eggs",
   1597         "--category",
   1598         "eggs",
   1599         "--summary",
   1600         "Fresh eggs",
   1601         "--bin-id",
   1602         "bin-1",
   1603         "--quantity-amount",
   1604         "1",
   1605         "--quantity-unit",
   1606         "each",
   1607         "--price-amount",
   1608         "6",
   1609         "--price-currency",
   1610         "USD",
   1611         "--price-per-amount",
   1612         "1",
   1613         "--price-per-unit",
   1614         "each",
   1615         "--available",
   1616         "10",
   1617     ]);
   1618     assert_eq!(listing["operation_id"], "listing.create");
   1619     assert_eq!(listing["result"]["seller_pubkey"], first_pubkey);
   1620     assert_eq!(listing["result"]["farm_d_tag"], farm_d_tag);
   1621 
   1622     let farm_before_dry_run = fs::read_to_string(farm_path).expect("farm before dry-run rebind");
   1623     let dry_rebind = sandbox.json_success(&[
   1624         "--format",
   1625         "json",
   1626         "--dry-run",
   1627         "farm",
   1628         "rebind",
   1629         second_account_id,
   1630     ]);
   1631     assert_eq!(dry_rebind["operation_id"], "farm.rebind");
   1632     assert_eq!(dry_rebind["result"]["state"], "dry_run");
   1633     assert_eq!(
   1634         dry_rebind["result"]["from_seller_account_id"],
   1635         first_account_id
   1636     );
   1637     assert_eq!(dry_rebind["result"]["from_seller_pubkey"], first_pubkey);
   1638     assert_eq!(
   1639         dry_rebind["result"]["to_seller_account_id"],
   1640         second_account_id
   1641     );
   1642     let second_pubkey = dry_rebind["result"]["to_seller_pubkey"]
   1643         .as_str()
   1644         .expect("second pubkey");
   1645     assert_eq!(dry_rebind["result"]["to_seller_pubkey"], second_pubkey);
   1646     assert_eq!(dry_rebind["result"]["seller_pubkey_changed"], true);
   1647     assert_eq!(dry_rebind["result"]["publication_state_action"], "cleared");
   1648     assert_eq!(
   1649         fs::read_to_string(farm_path).expect("farm after dry-run rebind"),
   1650         farm_before_dry_run
   1651     );
   1652 
   1653     let (unapproved_output, unapproved) =
   1654         sandbox.json_output(&["--format", "json", "farm", "rebind", second_account_id]);
   1655     assert!(!unapproved_output.status.success());
   1656     assert_eq!(unapproved["operation_id"], "farm.rebind");
   1657     assert_eq!(unapproved["errors"][0]["code"], "approval_required");
   1658 
   1659     let rebound = sandbox.json_success(&[
   1660         "--format",
   1661         "json",
   1662         "--approval-token",
   1663         "approve",
   1664         "farm",
   1665         "rebind",
   1666         second_account_id,
   1667     ]);
   1668     assert_eq!(rebound["operation_id"], "farm.rebind");
   1669     assert_eq!(rebound["result"]["state"], "rebound");
   1670     assert_eq!(
   1671         rebound["result"]["config"]["seller_account_id"],
   1672         second_account_id
   1673     );
   1674     assert_eq!(rebound["result"]["config"]["seller_pubkey"], second_pubkey);
   1675     assert_eq!(rebound["result"]["config"]["farm_d_tag"], farm_d_tag);
   1676     assert_eq!(rebound["result"]["config"]["name"], "Green Farm");
   1677     assert_eq!(rebound["result"]["config"]["location_primary"], "farmstand");
   1678     assert_eq!(rebound["result"]["config"]["delivery_method"], "pickup");
   1679     assert_eq!(rebound["result"]["publication_state_action"], "cleared");
   1680 
   1681     let rebound_get = sandbox.json_success(&["--format", "json", "farm", "get"]);
   1682     assert_eq!(
   1683         rebound_get["result"]["document"]["selection"]["seller_account_id"],
   1684         second_account_id
   1685     );
   1686     assert_eq!(
   1687         rebound_get["result"]["document"]["publication"]["profile_state"],
   1688         "not_published"
   1689     );
   1690     assert_eq!(
   1691         rebound_get["result"]["document"]["publication"]["farm_state"],
   1692         "not_published"
   1693     );
   1694 }
   1695 
   1696 #[test]
   1697 fn missing_farm_bound_seller_blocks_listing_create_and_guides_setup_repair() {
   1698     let sandbox = RadrootsCliSandbox::new();
   1699     let first = sandbox.json_success(&["--format", "json", "account", "create"]);
   1700     let first_account_id = first["result"]["account"]["id"]
   1701         .as_str()
   1702         .expect("first account id");
   1703     sandbox.json_success(&[
   1704         "--format",
   1705         "json",
   1706         "farm",
   1707         "create",
   1708         "--name",
   1709         "Missing Seller Farm",
   1710         "--location",
   1711         "farmstand",
   1712         "--country",
   1713         "US",
   1714         "--delivery-method",
   1715         "pickup",
   1716     ]);
   1717     let second = sandbox.json_success(&["--format", "json", "account", "create"]);
   1718     let second_account_id = second["result"]["account"]["id"]
   1719         .as_str()
   1720         .expect("second account id");
   1721     sandbox.json_success(&[
   1722         "--format",
   1723         "json",
   1724         "account",
   1725         "selection",
   1726         "update",
   1727         second_account_id,
   1728     ]);
   1729     sandbox.json_success(&[
   1730         "--format",
   1731         "json",
   1732         "--approval-token",
   1733         "approve",
   1734         "account",
   1735         "remove",
   1736         first_account_id,
   1737     ]);
   1738 
   1739     let updated = sandbox.json_success(&[
   1740         "--format",
   1741         "json",
   1742         "farm",
   1743         "profile",
   1744         "update",
   1745         "--field",
   1746         "name",
   1747         "--value",
   1748         "Missing Seller Farm Updated",
   1749     ]);
   1750     assert_eq!(updated["operation_id"], "farm.profile.update");
   1751     assert_contains(&updated["result"]["reason"], "farm-bound seller account");
   1752     assert_action_present(&updated, "radroots account import <path>");
   1753     assert_action_present(&updated, "radroots farm rebind <selector>");
   1754 
   1755     let listing_path = sandbox.root().join("missing-seller-listing.toml");
   1756     let (listing_output, listing) = sandbox.json_output(&[
   1757         "--format",
   1758         "json",
   1759         "listing",
   1760         "create",
   1761         "--output",
   1762         listing_path.to_string_lossy().as_ref(),
   1763         "--key",
   1764         "missing-seller-eggs",
   1765         "--title",
   1766         "Missing Seller Eggs",
   1767         "--category",
   1768         "eggs",
   1769         "--summary",
   1770         "Fresh eggs",
   1771         "--bin-id",
   1772         "bin-1",
   1773         "--quantity-amount",
   1774         "1",
   1775         "--quantity-unit",
   1776         "each",
   1777         "--price-amount",
   1778         "6",
   1779         "--price-currency",
   1780         "USD",
   1781         "--price-per-amount",
   1782         "1",
   1783         "--price-per-unit",
   1784         "each",
   1785         "--available",
   1786         "10",
   1787     ]);
   1788     assert!(!listing_output.status.success());
   1789     assert_eq!(listing["operation_id"], "listing.create");
   1790     assert_eq!(listing["errors"][0]["code"], "account_unresolved");
   1791     assert_contains(
   1792         &listing["errors"][0]["message"],
   1793         "farm-bound seller account",
   1794     );
   1795     assert_eq!(
   1796         listing["errors"][0]["detail"]["seller_actor_source"],
   1797         "farm_config"
   1798     );
   1799     assert_eq!(
   1800         listing["errors"][0]["detail"]["farm_bound_seller_account_id"],
   1801         first_account_id
   1802     );
   1803     assert_next_action_present(&listing, "radroots account import <path>");
   1804     assert_next_action_present(&listing, "radroots farm rebind <selector>");
   1805     assert!(!listing_path.exists());
   1806 }
   1807 
   1808 #[test]
   1809 fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() {
   1810     let sandbox = RadrootsCliSandbox::new();
   1811     sandbox.json_success(&["--format", "json", "account", "create"]);
   1812     sandbox.json_success(&[
   1813         "--format",
   1814         "json",
   1815         "farm",
   1816         "create",
   1817         "--name",
   1818         "Watch Rebind Farm",
   1819         "--location",
   1820         "farmstand",
   1821         "--country",
   1822         "US",
   1823         "--delivery-method",
   1824         "pickup",
   1825     ]);
   1826     let watch_identity = identity_secret(56);
   1827     let watch_public = watch_identity.to_public();
   1828     let public_identity_file =
   1829         write_public_identity_profile(&sandbox, "watch-rebind-public", &watch_public);
   1830     let secret_identity_file =
   1831         write_secret_identity_profile(&sandbox, "watch-rebind-secret", &watch_identity);
   1832     let imported = sandbox.json_success(&[
   1833         "--format",
   1834         "json",
   1835         "--approval-token",
   1836         "approve",
   1837         "account",
   1838         "import",
   1839         public_identity_file.to_string_lossy().as_ref(),
   1840     ]);
   1841     let watch_account_id = imported["result"]["account"]["id"]
   1842         .as_str()
   1843         .expect("watch account id");
   1844     assert_eq!(imported["result"]["account"]["custody"], "watch_only");
   1845 
   1846     let rebound = sandbox.json_success(&[
   1847         "--format",
   1848         "json",
   1849         "--approval-token",
   1850         "approve",
   1851         "farm",
   1852         "rebind",
   1853         watch_account_id,
   1854     ]);
   1855     assert_eq!(rebound["operation_id"], "farm.rebind");
   1856     assert_eq!(
   1857         rebound["result"]["config"]["seller_account_id"],
   1858         watch_account_id
   1859     );
   1860 
   1861     let readiness = sandbox.json_success(&[
   1862         "--format",
   1863         "json",
   1864         "--relay",
   1865         "ws://127.0.0.1:9",
   1866         "farm",
   1867         "readiness",
   1868         "check",
   1869     ]);
   1870     assert_eq!(readiness["operation_id"], "farm.readiness.check");
   1871     assert_eq!(readiness["result"]["publish_state"], "unconfigured");
   1872     assert_eq!(
   1873         readiness["result"]["missing"][0],
   1874         "Write-capable farm-bound seller account"
   1875     );
   1876     assert_action_present(
   1877         &readiness,
   1878         format!("radroots account attach-secret {watch_account_id} <path>").as_str(),
   1879     );
   1880 
   1881     let (publish_output, publish) = sandbox.json_output(&[
   1882         "--format",
   1883         "json",
   1884         "--relay",
   1885         "ws://127.0.0.1:9",
   1886         "--dry-run",
   1887         "farm",
   1888         "publish",
   1889     ]);
   1890     assert!(!publish_output.status.success());
   1891     assert_eq!(publish["operation_id"], "farm.publish");
   1892     assert_eq!(publish["errors"][0]["code"], "account_watch_only");
   1893 
   1894     sandbox.json_success(&[
   1895         "--format",
   1896         "json",
   1897         "--approval-token",
   1898         "approve",
   1899         "account",
   1900         "attach-secret",
   1901         watch_account_id,
   1902         secret_identity_file.to_string_lossy().as_ref(),
   1903     ]);
   1904     let recovered = sandbox.json_success(&[
   1905         "--format",
   1906         "json",
   1907         "--relay",
   1908         "ws://127.0.0.1:9",
   1909         "--dry-run",
   1910         "farm",
   1911         "publish",
   1912     ]);
   1913     assert_eq!(recovered["operation_id"], "farm.publish");
   1914     assert_eq!(recovered["result"]["state"], "dry_run");
   1915     assert_eq!(recovered["result"]["seller_account_id"], watch_account_id);
   1916     assert_eq!(
   1917         recovered["result"]["seller_pubkey"],
   1918         watch_public.public_key_hex
   1919     );
   1920 }
   1921 
   1922 #[test]
   1923 fn local_seller_publish_commands_attempt_configured_relay() {
   1924     let sandbox = RadrootsCliSandbox::new();
   1925     sandbox.json_success(&["--format", "json", "account", "create"]);
   1926     let farm = sandbox.json_success(&[
   1927         "--format",
   1928         "json",
   1929         "farm",
   1930         "create",
   1931         "--name",
   1932         "Green Farm",
   1933         "--location",
   1934         "farmstand",
   1935         "--country",
   1936         "US",
   1937         "--delivery-method",
   1938         "pickup",
   1939     ]);
   1940     let farm_d_tag = farm["result"]["config"]["farm_d_tag"]
   1941         .as_str()
   1942         .expect("farm d tag");
   1943     let relay = "ws://127.0.0.1:9";
   1944 
   1945     let (farm_output, farm_value) = sandbox.json_output(&[
   1946         "--format",
   1947         "json",
   1948         "--relay",
   1949         relay,
   1950         "--approval-token",
   1951         "approve",
   1952         "farm",
   1953         "publish",
   1954     ]);
   1955     assert!(!farm_output.status.success());
   1956     assert_eq!(farm_value["operation_id"], "farm.publish");
   1957     assert_eq!(farm_value["result"], serde_json::Value::Null);
   1958     assert_eq!(farm_value["errors"][0]["code"], "network_unavailable");
   1959     assert_eq!(farm_value["errors"][0]["detail"]["class"], "network");
   1960     assert_contains(
   1961         &farm_value["errors"][0]["message"],
   1962         "SDK relay publish did not reach accepted quorum",
   1963     );
   1964     assert_eq!(
   1965         farm_value["errors"][0]["detail"]["source"],
   1966         "SDK farm publish ยท configured signer"
   1967     );
   1968     assert_eq!(
   1969         farm_value["errors"][0]["detail"]["farm"]["target_relays"][0],
   1970         relay
   1971     );
   1972     assert_eq!(
   1973         farm_value["errors"][0]["detail"]["farm"]["failed_relays"][0]["relay"],
   1974         relay
   1975     );
   1976     assert_no_removed_command_reference(&farm_value, &["farm", "publish"]);
   1977     assert_no_daemon_runtime_reference(&farm_value, &["farm", "publish"]);
   1978 
   1979     let listing_file = create_listing_draft(&sandbox, "direct-relay-attempt");
   1980     make_listing_publishable(&listing_file, farm_d_tag);
   1981     let listing_file_arg = listing_file.to_string_lossy();
   1982 
   1983     let (publish_output, publish_value) = sandbox.json_output(&[
   1984         "--format",
   1985         "json",
   1986         "--relay",
   1987         relay,
   1988         "--approval-token",
   1989         "approve",
   1990         "listing",
   1991         "publish",
   1992         listing_file_arg.as_ref(),
   1993     ]);
   1994     assert!(!publish_output.status.success());
   1995     assert_eq!(publish_value["operation_id"], "listing.publish");
   1996     assert_eq!(publish_value["result"], serde_json::Value::Null);
   1997     assert_eq!(publish_value["errors"][0]["code"], "network_unavailable");
   1998     assert_eq!(publish_value["errors"][0]["detail"]["class"], "network");
   1999     assert_contains(
   2000         &publish_value["errors"][0]["message"],
   2001         "SDK relay publish did not reach accepted quorum",
   2002     );
   2003     assert_no_removed_command_reference(&publish_value, &["listing", "publish"]);
   2004     assert_no_daemon_runtime_reference(&publish_value, &["listing", "publish"]);
   2005     assert_eq!(
   2006         publish_value["errors"][0]["detail"]["target_relays"][0],
   2007         relay
   2008     );
   2009     assert_eq!(
   2010         publish_value["errors"][0]["detail"]["connected_relays"]
   2011             .as_array()
   2012             .expect("connected relays")
   2013             .len(),
   2014         1
   2015     );
   2016     assert_eq!(
   2017         publish_value["errors"][0]["detail"]["failed_relays"]
   2018             .as_array()
   2019             .expect("failed relays")
   2020             .len(),
   2021         1
   2022     );
   2023 
   2024     let (archive_output, archive_value) = sandbox.json_output(&[
   2025         "--format",
   2026         "json",
   2027         "--relay",
   2028         relay,
   2029         "--approval-token",
   2030         "approve",
   2031         "listing",
   2032         "archive",
   2033         listing_file_arg.as_ref(),
   2034     ]);
   2035     assert!(!archive_output.status.success());
   2036     assert_eq!(archive_value["operation_id"], "listing.archive");
   2037     assert_eq!(archive_value["result"], serde_json::Value::Null);
   2038     assert_eq!(archive_value["errors"][0]["code"], "network_unavailable");
   2039     assert_eq!(archive_value["errors"][0]["detail"]["class"], "network");
   2040     assert_contains(
   2041         &archive_value["errors"][0]["message"],
   2042         "SDK relay publish did not reach accepted quorum",
   2043     );
   2044     assert_no_removed_command_reference(&archive_value, &["listing", "archive"]);
   2045     assert_no_daemon_runtime_reference(&archive_value, &["listing", "archive"]);
   2046     assert_eq!(
   2047         archive_value["errors"][0]["detail"]["target_relays"][0],
   2048         relay
   2049     );
   2050     assert_eq!(
   2051         archive_value["errors"][0]["detail"]["connected_relays"]
   2052             .as_array()
   2053             .expect("connected relays")
   2054             .len(),
   2055         1
   2056     );
   2057     assert_eq!(
   2058         archive_value["errors"][0]["detail"]["failed_relays"]
   2059             .as_array()
   2060             .expect("failed relays")
   2061             .len(),
   2062         1
   2063     );
   2064 
   2065     seed_orderable_listing(&sandbox, LISTING_ADDR);
   2066     sandbox.json_success(&["--format", "json", "basket", "create", "direct_order"]);
   2067     sandbox.json_success(&[
   2068         "--format",
   2069         "json",
   2070         "basket",
   2071         "item",
   2072         "add",
   2073         "direct_order",
   2074         "--listing-addr",
   2075         LISTING_ADDR,
   2076         "--bin-id",
   2077         "bin-1",
   2078         "--quantity",
   2079         "1",
   2080     ]);
   2081     let quote = sandbox.json_success(&[
   2082         "--format",
   2083         "json",
   2084         "basket",
   2085         "quote",
   2086         "create",
   2087         "direct_order",
   2088     ]);
   2089     let order_id = quote["result"]["quote"]["order_id"]
   2090         .as_str()
   2091         .expect("order id");
   2092     let (order_output, order_value) = sandbox.json_output(&[
   2093         "--format",
   2094         "json",
   2095         "--relay",
   2096         relay,
   2097         "--approval-token",
   2098         "approve",
   2099         "order",
   2100         "submit",
   2101         order_id,
   2102     ]);
   2103     assert!(!order_output.status.success());
   2104     assert_eq!(order_value["operation_id"], "order.submit");
   2105     assert_eq!(order_value["result"], serde_json::Value::Null);
   2106     assert_eq!(order_value["errors"][0]["code"], "operation_unavailable");
   2107     assert_eq!(
   2108         order_value["errors"][0]["detail"]["issues"][0]["field"],
   2109         "order.listing_addr"
   2110     );
   2111     assert_contains(
   2112         &order_value["errors"][0]["detail"]["issues"][0]["message"],
   2113         "local market freshness",
   2114     );
   2115     assert_no_removed_command_reference(&order_value, &["order", "submit"]);
   2116     assert_no_daemon_runtime_reference(&order_value, &["order", "submit"]);
   2117 }
   2118 
   2119 #[test]
   2120 fn local_order_event_list_attempts_configured_direct_relay() {
   2121     let sandbox = RadrootsCliSandbox::new();
   2122     sandbox.json_success(&["--format", "json", "account", "create"]);
   2123     let relay = "ws://127.0.0.1:9";
   2124 
   2125     let (output, value) = sandbox.json_output(&[
   2126         "--format", "json", "--relay", relay, "order", "event", "list",
   2127     ]);
   2128 
   2129     assert!(!output.status.success());
   2130     assert_direct_relay_connection_failure(&value, "order.event.list", &["order", "event", "list"]);
   2131     assert_eq!(value["errors"][0]["detail"]["state"], "unavailable");
   2132     assert_eq!(value["errors"][0]["detail"]["target_relays"][0], relay);
   2133     assert_eq!(
   2134         value["errors"][0]["detail"]["connected_relays"]
   2135             .as_array()
   2136             .expect("connected relays")
   2137             .len(),
   2138         0
   2139     );
   2140     assert_eq!(
   2141         value["errors"][0]["detail"]["failed_relays"]
   2142             .as_array()
   2143             .expect("failed relays")
   2144             .len(),
   2145         1
   2146     );
   2147     assert_contains(
   2148         &value["errors"][0]["detail"]["failed_relays"][0]["relay"],
   2149         "127.0.0.1:9",
   2150     );
   2151     assert_eq!(value["errors"][0]["detail"]["fetched_count"], 0);
   2152     assert_eq!(value["errors"][0]["detail"]["decoded_count"], 0);
   2153     assert_eq!(value["errors"][0]["detail"]["skipped_count"], 0);
   2154 }
   2155 
   2156 #[test]
   2157 fn local_order_failure_envelopes_are_structured_and_actionable() {
   2158     let sandbox = RadrootsCliSandbox::new();
   2159     let watch_args = ["--format", "json", "order", "event", "watch", "ord_missing"];
   2160     let (watch_output, watch) = sandbox.json_output(&watch_args);
   2161     assert!(!watch_output.status.success());
   2162     assert_eq!(watch["operation_id"], "order.event.watch");
   2163     assert_eq!(watch["result"], Value::Null);
   2164     assert_eq!(watch["errors"][0]["code"], "not_implemented");
   2165     assert_eq!(watch["errors"][0]["detail"]["state"], "not_implemented");
   2166     assert_eq!(watch["errors"][0]["detail"]["order_id"], "ord_missing");
   2167     assert_eq!(
   2168         watch["next_actions"][0]["command"],
   2169         "radroots order status get ord_missing"
   2170     );
   2171     assert_no_daemon_runtime_reference(&watch, &watch_args);
   2172 
   2173     let submit_args = [
   2174         "--format",
   2175         "json",
   2176         "--publish-transport",
   2177         "direct_nostr_relay",
   2178         "--dry-run",
   2179         "order",
   2180         "submit",
   2181         "ord_missing",
   2182     ];
   2183     let (submit_output, submit) = sandbox.json_output(&submit_args);
   2184     assert!(!submit_output.status.success());
   2185     assert_eq!(submit["errors"][0]["code"], "not_found");
   2186     assert_eq!(submit["errors"][0]["detail"]["state"], "missing");
   2187     assert_eq!(submit["errors"][0]["detail"]["order_id"], "ord_missing");
   2188     assert_eq!(submit["next_actions"][0]["command"], "radroots order list");
   2189     assert_eq!(
   2190         submit["next_actions"][1]["command"],
   2191         "radroots basket create"
   2192     );
   2193     assert_no_daemon_runtime_reference(&submit, &submit_args);
   2194 
   2195     let status_args = ["--format", "json", "order", "status", "get", "ord_missing"];
   2196     let status = sandbox.json_success(&status_args);
   2197     assert_eq!(status["operation_id"], "order.status.get");
   2198     assert_eq!(status["result"]["state"], "missing");
   2199     assert_eq!(status["result"]["source"], "SDK local order projection");
   2200     assert_eq!(
   2201         status["result"]["actor_context_source"],
   2202         "sdk_local_projection"
   2203     );
   2204     assert_eq!(status["result"]["order_id"], "ord_missing");
   2205     assert_eq!(status["result"]["fetched_count"], 0);
   2206     assert_eq!(status["result"]["decoded_count"], 0);
   2207     assert_eq!(
   2208         status["result"]["reason"],
   2209         "no local SDK order events matched `ord_missing`"
   2210     );
   2211     assert_no_daemon_runtime_reference(&status, &status_args);
   2212 
   2213     let event_list_no_relay_args = ["--format", "json", "order", "event", "list"];
   2214     let (event_list_no_relay_output, event_list_no_relay) =
   2215         sandbox.json_output(&event_list_no_relay_args);
   2216     assert!(!event_list_no_relay_output.status.success());
   2217     assert_eq!(
   2218         event_list_no_relay["errors"][0]["code"],
   2219         "operation_unavailable"
   2220     );
   2221     assert_eq!(
   2222         event_list_no_relay["errors"][0]["detail"]["state"],
   2223         "unconfigured"
   2224     );
   2225     assert_eq!(
   2226         event_list_no_relay["next_actions"][0]["command"],
   2227         "radroots --relay wss://relay.example.com order event list"
   2228     );
   2229     assert_no_daemon_runtime_reference(&event_list_no_relay, &event_list_no_relay_args);
   2230 
   2231     let event_list_no_account_args = [
   2232         "--format",
   2233         "json",
   2234         "--relay",
   2235         "ws://127.0.0.1:9",
   2236         "order",
   2237         "event",
   2238         "list",
   2239     ];
   2240     let (event_list_no_account_output, event_list_no_account) =
   2241         sandbox.json_output(&event_list_no_account_args);
   2242     assert!(!event_list_no_account_output.status.success());
   2243     assert_eq!(
   2244         event_list_no_account["errors"][0]["code"],
   2245         "operation_unavailable"
   2246     );
   2247     assert_eq!(
   2248         event_list_no_account["errors"][0]["detail"]["state"],
   2249         "unconfigured"
   2250     );
   2251     assert_eq!(
   2252         event_list_no_account["next_actions"][0]["command"],
   2253         "radroots account create"
   2254     );
   2255     assert_no_daemon_runtime_reference(&event_list_no_account, &event_list_no_account_args);
   2256 
   2257     let accept_args = [
   2258         "--format",
   2259         "json",
   2260         "--publish-transport",
   2261         "direct_nostr_relay",
   2262         "--dry-run",
   2263         "order",
   2264         "accept",
   2265         "ord_missing",
   2266     ];
   2267     let (accept_output, accept) = sandbox.json_output(&accept_args);
   2268     assert!(!accept_output.status.success());
   2269     assert_eq!(accept["errors"][0]["code"], "operation_unavailable");
   2270     assert_eq!(accept["errors"][0]["detail"]["state"], "unconfigured");
   2271     assert_eq!(accept["errors"][0]["detail"]["order_id"], "ord_missing");
   2272     assert_eq!(accept["errors"][0]["detail"]["decision"], "accepted");
   2273     assert_no_daemon_runtime_reference(&accept, &accept_args);
   2274 
   2275     let decline_args = [
   2276         "--format",
   2277         "json",
   2278         "--publish-transport",
   2279         "direct_nostr_relay",
   2280         "--dry-run",
   2281         "order",
   2282         "decline",
   2283         "ord_missing",
   2284         "--reason",
   2285         "not available",
   2286     ];
   2287     let (decline_output, decline) = sandbox.json_output(&decline_args);
   2288     assert!(!decline_output.status.success());
   2289     assert_eq!(decline["errors"][0]["code"], "operation_unavailable");
   2290     assert_eq!(decline["errors"][0]["detail"]["state"], "unconfigured");
   2291     assert_eq!(decline["errors"][0]["detail"]["order_id"], "ord_missing");
   2292     assert_eq!(decline["errors"][0]["detail"]["decision"], "declined");
   2293     assert_no_daemon_runtime_reference(&decline, &decline_args);
   2294 }
   2295 
   2296 #[test]
   2297 fn watch_only_farm_publish_dry_run_fails_as_account_watch_only() {
   2298     let sandbox = RadrootsCliSandbox::new();
   2299     let public_identity = identity_public(13);
   2300     let public_identity_file =
   2301         write_public_identity_profile(&sandbox, "watch-only-farm", &public_identity);
   2302     sandbox.json_success(&[
   2303         "--format",
   2304         "json",
   2305         "--approval-token",
   2306         "approve",
   2307         "account",
   2308         "import",
   2309         "--default",
   2310         public_identity_file.to_string_lossy().as_ref(),
   2311     ]);
   2312     sandbox.json_success(&[
   2313         "--format",
   2314         "json",
   2315         "farm",
   2316         "create",
   2317         "--name",
   2318         "Green Farm",
   2319         "--location",
   2320         "farmstand",
   2321         "--country",
   2322         "US",
   2323         "--delivery-method",
   2324         "pickup",
   2325     ]);
   2326 
   2327     let (output, value) = sandbox.json_output(&[
   2328         "--format",
   2329         "json",
   2330         "--relay",
   2331         "ws://127.0.0.1:9",
   2332         "--dry-run",
   2333         "farm",
   2334         "publish",
   2335     ]);
   2336 
   2337     assert!(!output.status.success());
   2338     assert_eq!(value["operation_id"], "farm.publish");
   2339     assert_eq!(value["errors"][0]["code"], "account_watch_only");
   2340     assert_eq!(value["errors"][0]["detail"]["class"], "account");
   2341 }
   2342 
   2343 #[test]
   2344 fn watch_only_listing_publish_fails_as_account_watch_only() {
   2345     let sandbox = RadrootsCliSandbox::new();
   2346     let public_identity = identity_public(12);
   2347     let public_identity_file =
   2348         write_public_identity_profile(&sandbox, "watch-only-publish", &public_identity);
   2349     sandbox.json_success(&[
   2350         "--format",
   2351         "json",
   2352         "--approval-token",
   2353         "approve",
   2354         "account",
   2355         "import",
   2356         "--default",
   2357         public_identity_file.to_string_lossy().as_ref(),
   2358     ]);
   2359     let listing_file = create_listing_draft(&sandbox, "watch-only-publish");
   2360     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
   2361 
   2362     let (output, value) = sandbox.json_output(&[
   2363         "--format",
   2364         "json",
   2365         "--relay",
   2366         "ws://127.0.0.1:9",
   2367         "--approval-token",
   2368         "approve",
   2369         "listing",
   2370         "publish",
   2371         listing_file.to_string_lossy().as_ref(),
   2372     ]);
   2373 
   2374     assert!(!output.status.success());
   2375     assert_eq!(value["operation_id"], "listing.publish");
   2376     assert_eq!(value["result"], serde_json::Value::Null);
   2377     assert_eq!(value["errors"][0]["code"], "account_watch_only");
   2378     assert_eq!(value["errors"][0]["exit_code"], 7);
   2379     assert_eq!(value["errors"][0]["detail"]["class"], "account");
   2380     assert_contains(&value["errors"][0]["message"], "resolved account");
   2381     assert_contains(&value["errors"][0]["message"], "watch_only");
   2382 }
   2383 
   2384 #[test]
   2385 fn watch_only_listing_update_dry_run_fails_as_account_watch_only() {
   2386     let sandbox = RadrootsCliSandbox::new();
   2387     let public_identity = identity_public(13);
   2388     let public_identity_file =
   2389         write_public_identity_profile(&sandbox, "watch-only-update", &public_identity);
   2390     sandbox.json_success(&[
   2391         "--format",
   2392         "json",
   2393         "--approval-token",
   2394         "approve",
   2395         "account",
   2396         "import",
   2397         "--default",
   2398         public_identity_file.to_string_lossy().as_ref(),
   2399     ]);
   2400     let listing_file = create_listing_draft(&sandbox, "watch-only-update");
   2401     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
   2402 
   2403     let (output, value) = sandbox.json_output(&[
   2404         "--format",
   2405         "json",
   2406         "--dry-run",
   2407         "listing",
   2408         "update",
   2409         listing_file.to_string_lossy().as_ref(),
   2410     ]);
   2411 
   2412     assert!(!output.status.success());
   2413     assert_eq!(value["operation_id"], "listing.update");
   2414     assert_eq!(value["result"], serde_json::Value::Null);
   2415     assert_eq!(value["errors"][0]["code"], "account_watch_only");
   2416     assert_eq!(value["errors"][0]["exit_code"], 7);
   2417     assert_eq!(value["errors"][0]["detail"]["class"], "account");
   2418     assert_contains(&value["errors"][0]["message"], "watch_only");
   2419 }
   2420 
   2421 #[cfg(unix)]
   2422 #[test]
   2423 fn myc_listing_publish_does_not_fallback_to_local_account() {
   2424     let sandbox = RadrootsCliSandbox::new();
   2425     sandbox.json_success(&["--format", "json", "account", "create"]);
   2426     let listing_file = create_listing_draft(&sandbox, "myc-no-binding");
   2427     make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
   2428     let invoked = sandbox.root().join("myc-listing-invoked.txt");
   2429     let myc = sandbox.write_fake_myc(
   2430         "myc-listing-deferred",
   2431         format!(
   2432             "printf invoked > '{}'",
   2433             shell_single_quoted(invoked.to_string_lossy().as_ref())
   2434         )
   2435         .as_str(),
   2436     );
   2437     configure_myc_mode(&sandbox, &myc);
   2438 
   2439     let (output, value) = sandbox.json_output(&[
   2440         "--format",
   2441         "json",
   2442         "--approval-token",
   2443         "approve",
   2444         "listing",
   2445         "publish",
   2446         listing_file.to_string_lossy().as_ref(),
   2447     ]);
   2448 
   2449     assert!(!output.status.success());
   2450     assert_eq!(value["operation_id"], "listing.publish");
   2451     assert_eq!(value["result"], serde_json::Value::Null);
   2452     assert_eq!(value["errors"][0]["code"], "signer_unconfigured");
   2453     assert_eq!(value["errors"][0]["exit_code"], 7);
   2454     assert_eq!(value["errors"][0]["detail"]["class"], "signer");
   2455     assert_contains(
   2456         &value["errors"][0]["message"],
   2457         "signer.remote_nip46 binding is missing",
   2458     );
   2459     assert!(!invoked.exists(), "target CLI must not execute MYC");
   2460 }
   2461 
   2462 fn configure_myc_mode(sandbox: &RadrootsCliSandbox, executable: &Path) {
   2463     sandbox.write_app_config(&format!(
   2464         "[signer]\nbackend = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n",
   2465         toml_string(executable.display().to_string().as_str())
   2466     ));
   2467 }
   2468 
   2469 fn assert_direct_relay_connection_failure(
   2470     value: &serde_json::Value,
   2471     operation_id: &str,
   2472     args: &[&str],
   2473 ) {
   2474     assert_eq!(value["operation_id"], operation_id);
   2475     assert_eq!(value["result"], serde_json::Value::Null);
   2476     assert_eq!(value["errors"][0]["code"], "network_unavailable");
   2477     assert_ne!(value["errors"][0]["code"], "operation_unavailable");
   2478     assert_eq!(value["errors"][0]["detail"]["class"], "network");
   2479     assert_contains(
   2480         &value["errors"][0]["message"],
   2481         "direct relay connection failed",
   2482     );
   2483     assert_no_removed_command_reference(value, args);
   2484     assert_no_daemon_runtime_reference(value, args);
   2485 }
   2486 
   2487 fn assert_relay_url(value: &Value, relay_url: &str) {
   2488     let actual = value.as_str().expect("relay url");
   2489     assert!(
   2490         actual == relay_url || actual == format!("{relay_url}/"),
   2491         "expected relay url `{actual}` to match `{relay_url}`"
   2492     );
   2493 }
   2494 
   2495 fn assert_action_present(value: &Value, action: &str) {
   2496     assert!(
   2497         action_list(value).iter().any(|entry| *entry == action),
   2498         "expected action `{action}` in `{}`",
   2499         value["result"]["actions"]
   2500     );
   2501 }
   2502 
   2503 fn assert_next_action_present(value: &Value, action: &str) {
   2504     assert!(
   2505         next_action_commands(value)
   2506             .iter()
   2507             .any(|entry| *entry == action),
   2508         "expected next action `{action}` in `{}`",
   2509         value["next_actions"]
   2510     );
   2511 }
   2512 
   2513 fn assert_action_absent(value: &Value, action: &str) {
   2514     assert!(
   2515         action_list(value).iter().all(|entry| *entry != action),
   2516         "did not expect action `{action}` in `{}`",
   2517         value["result"]["actions"]
   2518     );
   2519 }
   2520 
   2521 fn action_list(value: &Value) -> Vec<&str> {
   2522     value["result"]["actions"]
   2523         .as_array()
   2524         .expect("actions")
   2525         .iter()
   2526         .map(|entry| entry.as_str().expect("action"))
   2527         .collect()
   2528 }
   2529 
   2530 fn next_action_commands(value: &Value) -> Vec<&str> {
   2531     value["next_actions"]
   2532         .as_array()
   2533         .expect("next actions")
   2534         .iter()
   2535         .filter_map(|entry| entry["command"].as_str())
   2536         .collect()
   2537 }