cli

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

envelope.rs (23341B)


      1 #![allow(dead_code)]
      2 
      3 use serde::Serialize;
      4 use serde_json::{Value, json};
      5 
      6 pub const OUTPUT_SCHEMA_VERSION: &str = "radroots.cli.output.v1";
      7 
      8 #[derive(Debug, Clone, PartialEq, Eq)]
      9 pub struct EnvelopeContext {
     10     pub request_id: String,
     11     pub correlation_id: Option<String>,
     12     pub idempotency_key: Option<String>,
     13     pub output_format: OutputFormat,
     14     pub dry_run: bool,
     15     pub actor: Option<EnvelopeActor>,
     16 }
     17 
     18 impl EnvelopeContext {
     19     pub fn new(request_id: impl Into<String>, dry_run: bool) -> Self {
     20         Self {
     21             request_id: request_id.into(),
     22             correlation_id: None,
     23             idempotency_key: None,
     24             output_format: OutputFormat::Human,
     25             dry_run,
     26             actor: None,
     27         }
     28     }
     29 }
     30 
     31 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
     32 pub struct EnvelopeActor {
     33     pub account_id: String,
     34     pub role: String,
     35 }
     36 
     37 #[derive(Debug, Clone, PartialEq, Serialize)]
     38 pub struct OutputEnvelope {
     39     pub schema_version: &'static str,
     40     pub operation_id: String,
     41     pub kind: String,
     42     pub status: OutputStatus,
     43     pub output_format: OutputFormat,
     44     pub request_id: String,
     45     pub correlation_id: Option<String>,
     46     pub idempotency_key: Option<String>,
     47     pub dry_run: bool,
     48     pub actor: Option<EnvelopeActor>,
     49     pub resource: Option<OutputResource>,
     50     pub result: Value,
     51     pub reason_code: Option<String>,
     52     pub warnings: Vec<OutputWarning>,
     53     pub errors: Vec<OutputError>,
     54     pub next_actions: Vec<NextAction>,
     55 }
     56 
     57 impl OutputEnvelope {
     58     pub fn success(
     59         operation_id: impl Into<String>,
     60         result: Value,
     61         context: EnvelopeContext,
     62     ) -> Self {
     63         let operation_id = operation_id.into();
     64         let resource = output_resource_from_value(&result);
     65         let reason_code = output_reason_code_from_value(&result);
     66         Self {
     67             schema_version: OUTPUT_SCHEMA_VERSION,
     68             kind: operation_id.clone(),
     69             operation_id,
     70             status: OutputStatus::Ok,
     71             output_format: context.output_format,
     72             request_id: context.request_id,
     73             correlation_id: context.correlation_id,
     74             idempotency_key: context.idempotency_key,
     75             dry_run: context.dry_run,
     76             actor: context.actor,
     77             resource,
     78             result,
     79             reason_code,
     80             warnings: Vec::new(),
     81             errors: Vec::new(),
     82             next_actions: Vec::new(),
     83         }
     84     }
     85 
     86     pub fn failure(
     87         operation_id: impl Into<String>,
     88         error: OutputError,
     89         context: EnvelopeContext,
     90     ) -> Self {
     91         let operation_id = operation_id.into();
     92         let next_actions = next_actions_from_error_detail(&error);
     93         let resource = error.detail.as_ref().and_then(output_resource_from_value);
     94         let reason_code = Some(error.reason_code.clone());
     95         Self {
     96             schema_version: OUTPUT_SCHEMA_VERSION,
     97             kind: operation_id.clone(),
     98             operation_id,
     99             status: OutputStatus::Error,
    100             output_format: context.output_format,
    101             request_id: context.request_id,
    102             correlation_id: context.correlation_id,
    103             idempotency_key: context.idempotency_key,
    104             dry_run: context.dry_run,
    105             actor: context.actor,
    106             resource,
    107             result: Value::Null,
    108             reason_code,
    109             warnings: Vec::new(),
    110             errors: vec![error],
    111             next_actions,
    112         }
    113     }
    114 
    115     pub fn to_ndjson_frames(&self) -> Vec<NdjsonFrame> {
    116         let started = NdjsonFrame::new(
    117             self.operation_id.clone(),
    118             self.request_id.clone(),
    119             0,
    120             NdjsonFrameType::Started,
    121             json!({
    122                 "state": "started",
    123                 "status": self.status,
    124                 "output_format": self.output_format,
    125                 "dry_run": self.dry_run,
    126                 "correlation_id": &self.correlation_id,
    127                 "idempotency_key": &self.idempotency_key,
    128                 "actor": &self.actor,
    129                 "resource": &self.resource,
    130             }),
    131         );
    132         let mut terminal = NdjsonFrame::new(
    133             self.operation_id.clone(),
    134             self.request_id.clone(),
    135             1,
    136             if self.errors.is_empty() {
    137                 NdjsonFrameType::Completed
    138             } else {
    139                 NdjsonFrameType::Error
    140             },
    141             json!({
    142                 "status": self.status,
    143                 "reason_code": &self.reason_code,
    144                 "output_format": self.output_format,
    145                 "resource": &self.resource,
    146                 "result": &self.result,
    147                 "next_actions": &self.next_actions,
    148                 "dry_run": self.dry_run,
    149                 "correlation_id": &self.correlation_id,
    150                 "idempotency_key": &self.idempotency_key,
    151                 "actor": &self.actor,
    152             }),
    153         );
    154         terminal.warnings = self.warnings.clone();
    155         terminal.errors = self.errors.clone();
    156         vec![started, terminal]
    157     }
    158 }
    159 
    160 fn output_reason_code_from_value(value: &Value) -> Option<String> {
    161     value
    162         .get("reason_code")
    163         .and_then(Value::as_str)
    164         .filter(|reason_code| !reason_code.trim().is_empty())
    165         .map(str::to_owned)
    166 }
    167 
    168 fn output_resource_from_value(value: &Value) -> Option<OutputResource> {
    169     let object = value.as_object()?;
    170     if let Some(resource) = object.get("resource").and_then(declared_output_resource) {
    171         return Some(resource);
    172     }
    173     output_resource_from_fields(object).or_else(|| {
    174         let nested_fields = [
    175             "account",
    176             "resolved_account",
    177             "default_account",
    178             "bound_account",
    179             "farm",
    180             "listing",
    181             "basket",
    182             "quote",
    183             "order",
    184         ];
    185         nested_fields
    186             .into_iter()
    187             .filter_map(|field| {
    188                 object
    189                     .get(field)
    190                     .and_then(|value| nested_output_resource(field, value))
    191             })
    192             .next()
    193     })
    194 }
    195 
    196 fn nested_output_resource(field: &str, value: &Value) -> Option<OutputResource> {
    197     let mut resource = output_resource_from_value(value)?;
    198     if resource.kind == "resource" {
    199         resource.kind = match field {
    200             "resolved_account" | "default_account" | "bound_account" => "account",
    201             other => other,
    202         }
    203         .to_owned();
    204     }
    205     Some(resource)
    206 }
    207 
    208 fn declared_output_resource(value: &Value) -> Option<OutputResource> {
    209     let object = value.as_object()?;
    210     let kind = object
    211         .get("kind")
    212         .and_then(Value::as_str)
    213         .filter(|kind| !kind.trim().is_empty())?;
    214     let id = object
    215         .get("id")
    216         .and_then(Value::as_str)
    217         .filter(|id| !id.trim().is_empty())?;
    218     Some(OutputResource {
    219         kind: kind.to_owned(),
    220         id: id.to_owned(),
    221     })
    222 }
    223 
    224 fn output_resource_from_fields(object: &serde_json::Map<String, Value>) -> Option<OutputResource> {
    225     [
    226         ("account_id", "account"),
    227         ("id", "resource"),
    228         ("farm_id", "farm"),
    229         ("seller_account_id", "account"),
    230         ("buyer_account_id", "account"),
    231         ("listing_id", "listing"),
    232         ("listing_address", "listing"),
    233         ("listing_addr", "listing"),
    234         ("basket_id", "basket"),
    235         ("order_id", "order"),
    236     ]
    237     .into_iter()
    238     .find_map(|(field, kind)| {
    239         object
    240             .get(field)
    241             .and_then(Value::as_str)
    242             .filter(|id| !id.trim().is_empty())
    243             .map(|id| OutputResource {
    244                 kind: kind.to_owned(),
    245                 id: id.to_owned(),
    246             })
    247     })
    248 }
    249 
    250 pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> {
    251     next_actions_from_actions_value(result.get("actions"))
    252 }
    253 
    254 fn next_actions_from_error_detail(error: &OutputError) -> Vec<NextAction> {
    255     next_actions_from_actions_value(
    256         error
    257             .detail
    258             .as_ref()
    259             .and_then(|detail| detail.get("actions")),
    260     )
    261 }
    262 
    263 fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAction> {
    264     actions_value
    265         .and_then(Value::as_array)
    266         .into_iter()
    267         .flatten()
    268         .filter_map(Value::as_str)
    269         .filter_map(next_action_from_action_string)
    270         .fold(Vec::<NextAction>::new(), |mut actions, action| {
    271             if !actions.contains(&action) {
    272                 actions.push(action);
    273             }
    274             actions
    275         })
    276 }
    277 
    278 fn next_action_from_action_string(action: &str) -> Option<NextAction> {
    279     let action = action.trim();
    280     if action
    281         == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"
    282     {
    283         return Some(NextAction {
    284             kind: NextActionKind::OperatorConfig,
    285             label: "configure radrootsd proxy token source".to_owned(),
    286             command: None,
    287             description: Some(action.to_owned()),
    288             env_var: Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()),
    289             config_key: None,
    290         });
    291     }
    292     if action == "configure signer.remote_nip46 signer_session_ref" {
    293         return Some(NextAction {
    294             kind: NextActionKind::OperatorConfig,
    295             label: "configure signer session binding".to_owned(),
    296             command: None,
    297             description: Some(action.to_owned()),
    298             env_var: None,
    299             config_key: Some("signer.remote_nip46.signer_session_ref".to_owned()),
    300         });
    301     }
    302     let command = action.trim().strip_prefix("run ").unwrap_or(action).trim();
    303     if !command.starts_with("radroots ") {
    304         return None;
    305     }
    306     Some(NextAction {
    307         kind: NextActionKind::CliCommand,
    308         label: next_action_label(command),
    309         command: Some(command.to_owned()),
    310         description: None,
    311         env_var: None,
    312         config_key: None,
    313     })
    314 }
    315 
    316 fn next_action_label(command: &str) -> String {
    317     let parts = command.split_whitespace().collect::<Vec<_>>();
    318     let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots"));
    319     let mut labels = Vec::new();
    320     while index < parts.len() {
    321         let part = parts[index];
    322         if part.starts_with("--") {
    323             index += 1;
    324             if matches!(
    325                 part,
    326                 "--format"
    327                     | "--account-id"
    328                     | "--relay"
    329                     | "--publish-transport"
    330                     | "--idempotency-key"
    331                     | "--correlation-id"
    332                     | "--approval-token"
    333             ) && index < parts.len()
    334             {
    335                 index += 1;
    336             }
    337             continue;
    338         }
    339         labels.push(part);
    340         index += 1;
    341     }
    342     if labels.is_empty() {
    343         "radroots".to_owned()
    344     } else {
    345         labels.join(" ")
    346     }
    347 }
    348 
    349 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    350 pub struct OutputWarning {
    351     pub code: String,
    352     pub message: String,
    353 }
    354 
    355 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
    356 #[serde(rename_all = "snake_case")]
    357 pub enum OutputStatus {
    358     Ok,
    359     Error,
    360 }
    361 
    362 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
    363 #[serde(rename_all = "snake_case")]
    364 pub enum OutputFormat {
    365     Human,
    366     Json,
    367     Ndjson,
    368 }
    369 
    370 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    371 pub struct OutputResource {
    372     pub kind: String,
    373     pub id: String,
    374 }
    375 
    376 #[derive(Debug, Clone, PartialEq, Serialize)]
    377 pub struct OutputError {
    378     pub code: String,
    379     pub reason_code: String,
    380     pub message: String,
    381     pub exit_code: u8,
    382     pub detail: Option<Value>,
    383 }
    384 
    385 impl OutputError {
    386     pub fn new(
    387         code: impl Into<String>,
    388         message: impl Into<String>,
    389         exit_code: CliExitCode,
    390     ) -> Self {
    391         let code = code.into();
    392         Self {
    393             reason_code: code.clone(),
    394             code,
    395             message: message.into(),
    396             exit_code: exit_code.code(),
    397             detail: None,
    398         }
    399     }
    400 }
    401 
    402 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    403 pub enum CliExitCode {
    404     Success,
    405     InternalError,
    406     InvalidInput,
    407     RuntimeUnavailable,
    408     NotFound,
    409     AuthorizationFailed,
    410     ApprovalRequiredOrDenied,
    411     SignerUnavailable,
    412     SyncOrNetworkFailure,
    413     Conflict,
    414     ValidationFailed,
    415     UnsafeOperationRefused,
    416 }
    417 
    418 impl CliExitCode {
    419     pub fn code(self) -> u8 {
    420         match self {
    421             Self::Success => 0,
    422             Self::InternalError => 1,
    423             Self::InvalidInput => 2,
    424             Self::RuntimeUnavailable => 3,
    425             Self::NotFound => 4,
    426             Self::AuthorizationFailed => 5,
    427             Self::ApprovalRequiredOrDenied => 6,
    428             Self::SignerUnavailable => 7,
    429             Self::SyncOrNetworkFailure => 8,
    430             Self::Conflict => 9,
    431             Self::ValidationFailed => 10,
    432             Self::UnsafeOperationRefused => 11,
    433         }
    434     }
    435 }
    436 
    437 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    438 pub struct NextAction {
    439     pub kind: NextActionKind,
    440     pub label: String,
    441     #[serde(skip_serializing_if = "Option::is_none")]
    442     pub command: Option<String>,
    443     #[serde(skip_serializing_if = "Option::is_none")]
    444     pub description: Option<String>,
    445     #[serde(skip_serializing_if = "Option::is_none")]
    446     pub env_var: Option<String>,
    447     #[serde(skip_serializing_if = "Option::is_none")]
    448     pub config_key: Option<String>,
    449 }
    450 
    451 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
    452 #[serde(rename_all = "snake_case")]
    453 pub enum NextActionKind {
    454     CliCommand,
    455     OperatorConfig,
    456 }
    457 
    458 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
    459 #[serde(rename_all = "snake_case")]
    460 pub enum NdjsonFrameType {
    461     Started,
    462     Event,
    463     Progress,
    464     Warning,
    465     Error,
    466     Completed,
    467     Heartbeat,
    468 }
    469 
    470 #[derive(Debug, Clone, PartialEq, Serialize)]
    471 pub struct NdjsonFrame {
    472     pub schema_version: &'static str,
    473     pub operation_id: String,
    474     pub kind: String,
    475     pub request_id: String,
    476     pub sequence: u64,
    477     pub frame_type: NdjsonFrameType,
    478     pub payload: Value,
    479     pub warnings: Vec<OutputWarning>,
    480     pub errors: Vec<OutputError>,
    481 }
    482 
    483 impl NdjsonFrame {
    484     pub fn new(
    485         operation_id: impl Into<String>,
    486         request_id: impl Into<String>,
    487         sequence: u64,
    488         frame_type: NdjsonFrameType,
    489         payload: Value,
    490     ) -> Self {
    491         let operation_id = operation_id.into();
    492         Self {
    493             schema_version: OUTPUT_SCHEMA_VERSION,
    494             kind: operation_id.clone(),
    495             operation_id,
    496             request_id: request_id.into(),
    497             sequence,
    498             frame_type,
    499             payload,
    500             warnings: Vec::new(),
    501             errors: Vec::new(),
    502         }
    503     }
    504 }
    505 
    506 #[cfg(test)]
    507 mod tests {
    508     use serde_json::{Value, json};
    509 
    510     use super::{
    511         CliExitCode, EnvelopeContext, NdjsonFrame, NdjsonFrameType, NextActionKind,
    512         OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError,
    513     };
    514 
    515     #[test]
    516     fn success_envelope_serializes_required_fields() {
    517         let mut context = EnvelopeContext::new("req_test", true);
    518         context.correlation_id = Some("corr_test".to_owned());
    519         context.idempotency_key = Some("idem_test".to_owned());
    520         let envelope = OutputEnvelope::success(
    521             "listing.publish",
    522             json!({ "listing_id": "listing_test" }),
    523             context,
    524         );
    525         let value = serde_json::to_value(envelope).expect("serialize envelope");
    526 
    527         assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION);
    528         assert_eq!(value["operation_id"], "listing.publish");
    529         assert_eq!(value["kind"], "listing.publish");
    530         assert_eq!(value["status"], "ok");
    531         assert_eq!(value["output_format"], "human");
    532         assert_eq!(value["request_id"], "req_test");
    533         assert_eq!(value["correlation_id"], "corr_test");
    534         assert_eq!(value["idempotency_key"], "idem_test");
    535         assert_eq!(value["dry_run"], true);
    536         assert_eq!(value["resource"]["kind"], "listing");
    537         assert_eq!(value["resource"]["id"], "listing_test");
    538         assert_eq!(value["result"]["listing_id"], "listing_test");
    539         assert_eq!(value["reason_code"], Value::Null);
    540         assert_eq!(value["warnings"].as_array().unwrap().len(), 0);
    541         assert_eq!(value["errors"].as_array().unwrap().len(), 0);
    542         assert_eq!(value["next_actions"].as_array().unwrap().len(), 0);
    543     }
    544 
    545     #[test]
    546     fn failure_envelope_carries_structured_error_and_exit_code() {
    547         let error = OutputError::new(
    548             "approval_required",
    549             "operation requires approval token",
    550             CliExitCode::ApprovalRequiredOrDenied,
    551         );
    552         let envelope = OutputEnvelope::failure(
    553             "order.submit",
    554             error,
    555             EnvelopeContext::new("req_order", false),
    556         );
    557         let value = serde_json::to_value(envelope).expect("serialize envelope");
    558 
    559         assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION);
    560         assert_eq!(value["operation_id"], "order.submit");
    561         assert_eq!(value["status"], "error");
    562         assert_eq!(value["reason_code"], "approval_required");
    563         assert_eq!(value["result"], Value::Null);
    564         assert_eq!(value["errors"][0]["code"], "approval_required");
    565         assert_eq!(value["errors"][0]["reason_code"], "approval_required");
    566         assert_eq!(value["errors"][0]["exit_code"], 6);
    567     }
    568 
    569     #[test]
    570     fn failure_envelope_derives_next_actions_from_error_detail() {
    571         let mut error = OutputError::new(
    572             "not_found",
    573             "order draft was not found",
    574             CliExitCode::NotFound,
    575         );
    576         error.detail = Some(json!({
    577             "actions": [
    578                 "radroots order list",
    579                 "run radroots basket create"
    580             ]
    581         }));
    582         let envelope = OutputEnvelope::failure(
    583             "order.submit",
    584             error,
    585             EnvelopeContext::new("req_order", true),
    586         );
    587 
    588         assert_eq!(envelope.next_actions.len(), 2);
    589         assert_eq!(envelope.next_actions[0].kind, NextActionKind::CliCommand);
    590         assert_eq!(envelope.next_actions[0].label, "order list");
    591         assert_eq!(
    592             envelope.next_actions[0].command.as_deref(),
    593             Some("radroots order list")
    594         );
    595         assert_eq!(envelope.next_actions[1].kind, NextActionKind::CliCommand);
    596         assert_eq!(envelope.next_actions[1].label, "basket create");
    597         assert_eq!(
    598             envelope.next_actions[1].command.as_deref(),
    599             Some("radroots basket create")
    600         );
    601     }
    602 
    603     #[test]
    604     fn failure_envelope_derives_operator_config_next_actions() {
    605         let mut error = OutputError::new(
    606             "operation_unavailable",
    607             "publish transport needs operator configuration",
    608             CliExitCode::RuntimeUnavailable,
    609         );
    610         error.detail = Some(json!({
    611             "actions": [
    612                 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID",
    613                 "configure signer.remote_nip46 signer_session_ref",
    614                 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"
    615             ]
    616         }));
    617         let envelope = OutputEnvelope::failure(
    618             "config.get",
    619             error,
    620             EnvelopeContext::new("req_config", false),
    621         );
    622         let value = serde_json::to_value(&envelope).expect("serialize envelope");
    623 
    624         assert_eq!(envelope.next_actions.len(), 2);
    625         assert_eq!(
    626             envelope.next_actions[0].kind,
    627             NextActionKind::OperatorConfig
    628         );
    629         assert_eq!(
    630             envelope.next_actions[0].label,
    631             "configure radrootsd proxy token source"
    632         );
    633         assert_eq!(envelope.next_actions[0].command, None);
    634         assert_eq!(
    635             envelope.next_actions[0].env_var.as_deref(),
    636             Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE")
    637         );
    638         assert_eq!(
    639             envelope.next_actions[1].kind,
    640             NextActionKind::OperatorConfig
    641         );
    642         assert_eq!(
    643             envelope.next_actions[1].label,
    644             "configure signer session binding"
    645         );
    646         assert_eq!(envelope.next_actions[1].command, None);
    647         assert_eq!(
    648             envelope.next_actions[1].config_key.as_deref(),
    649             Some("signer.remote_nip46.signer_session_ref")
    650         );
    651         assert_eq!(value["next_actions"][0]["kind"], "operator_config");
    652         assert_eq!(value["next_actions"][0]["command"], Value::Null);
    653         assert_eq!(value["next_actions"][1]["kind"], "operator_config");
    654         assert_eq!(value["next_actions"][1]["command"], Value::Null);
    655     }
    656 
    657     #[test]
    658     fn ndjson_frames_serialize_one_json_object_per_line() {
    659         let frames = [
    660             NdjsonFrame::new(
    661                 "sync.watch",
    662                 "req_watch",
    663                 0,
    664                 NdjsonFrameType::Started,
    665                 json!({ "state": "started" }),
    666             ),
    667             NdjsonFrame::new(
    668                 "sync.watch",
    669                 "req_watch",
    670                 1,
    671                 NdjsonFrameType::Event,
    672                 json!({ "state": "submitted" }),
    673             ),
    674             NdjsonFrame::new(
    675                 "sync.watch",
    676                 "req_watch",
    677                 2,
    678                 NdjsonFrameType::Completed,
    679                 json!({ "state": "complete" }),
    680             ),
    681         ];
    682         let rendered = frames
    683             .iter()
    684             .map(|frame| serde_json::to_string(frame).expect("serialize frame"))
    685             .collect::<Vec<_>>()
    686             .join("\n");
    687 
    688         for line in rendered.lines() {
    689             let value: Value = serde_json::from_str(line).expect("line is json");
    690             assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION);
    691             assert_eq!(value["operation_id"], "sync.watch");
    692             assert!(value["frame_type"].is_string());
    693         }
    694     }
    695 
    696     #[test]
    697     fn ndjson_terminal_frame_carries_status_reason_and_resource() {
    698         let mut error = OutputError::new(
    699             "not_implemented",
    700             "operation is not implemented",
    701             CliExitCode::RuntimeUnavailable,
    702         );
    703         error.detail = Some(json!({
    704             "order_id": "ord_test",
    705         }));
    706         let envelope = OutputEnvelope::failure(
    707             "test.operation",
    708             error,
    709             EnvelopeContext::new("req_test", false),
    710         );
    711         let frames = envelope.to_ndjson_frames();
    712 
    713         assert_eq!(frames[0].payload["status"], "error");
    714         assert_eq!(frames[0].payload["output_format"], "human");
    715         assert_eq!(frames[1].payload["status"], "error");
    716         assert_eq!(frames[1].payload["reason_code"], "not_implemented");
    717         assert_eq!(frames[1].payload["resource"]["kind"], "order");
    718         assert_eq!(frames[1].payload["resource"]["id"], "ord_test");
    719         assert_eq!(frames[1].errors[0].reason_code, "not_implemented");
    720     }
    721 
    722     #[test]
    723     fn exit_code_contract_matches_handoff_range() {
    724         assert_eq!(CliExitCode::Success.code(), 0);
    725         assert_eq!(CliExitCode::InvalidInput.code(), 2);
    726         assert_eq!(CliExitCode::ApprovalRequiredOrDenied.code(), 6);
    727         assert_eq!(CliExitCode::UnsafeOperationRefused.code(), 11);
    728     }
    729 }