commit f114708403f2bfbb404a6f90b192a7b87eba8be1
parent 7b8710675cbbe50f062fe34312bb244ee2fbbb66
Author: triesap <tyson@radroots.org>
Date: Wed, 13 May 2026 03:06:00 +0000
cli: harden machine output reasons
- add status, output format, resource, and reason code fields
- propagate reason semantics into JSON and NDJSON envelopes
- document the machine-output contract for operators
- cover success, invalid-input, and deferred-payment states
Diffstat:
3 files changed, 254 insertions(+), 20 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -9,8 +9,8 @@ use serde_json::{Map, Value, json};
use crate::domain::runtime::CommandDisposition;
use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation};
use crate::output_contract::{
- CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope,
- OutputError, OutputWarning, next_actions_from_result_value,
+ CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OutputEnvelope, OutputError,
+ OutputFormat, OutputWarning, next_actions_from_result_value,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts::AccountRuntimeFailure;
@@ -112,6 +112,11 @@ impl OperationContext {
pub fn envelope_context(&self, request_id: impl Into<String>) -> EnvelopeContext {
let mut context = EnvelopeContext::new(request_id, self.dry_run);
+ context.output_format = match self.output_format {
+ OperationOutputFormat::Human => OutputFormat::Human,
+ OperationOutputFormat::Json => OutputFormat::Json,
+ OperationOutputFormat::Ndjson => OutputFormat::Ndjson,
+ };
context.correlation_id = self.correlation_id.clone();
context.idempotency_key = self.idempotency_key.clone();
context.actor = self.account_id.as_ref().map(|account_id| EnvelopeActor {
@@ -242,20 +247,10 @@ impl<P: OperationResultPayload> OperationResult<P> {
} else {
self.next_actions.clone()
};
- Ok(OutputEnvelope {
- schema_version: OUTPUT_SCHEMA_VERSION,
- operation_id: self.operation_id().to_owned(),
- kind: self.operation_id().to_owned(),
- request_id: context.request_id,
- correlation_id: context.correlation_id,
- idempotency_key: context.idempotency_key,
- dry_run: context.dry_run,
- actor: context.actor,
- result,
- warnings: self.warnings.clone(),
- errors: Vec::new(),
- next_actions,
- })
+ let mut envelope = OutputEnvelope::success(self.operation_id(), result, context);
+ envelope.warnings = self.warnings.clone();
+ envelope.next_actions = next_actions;
+ Ok(envelope)
}
}
diff --git a/src/output_contract.rs b/src/output_contract.rs
@@ -10,6 +10,7 @@ pub struct EnvelopeContext {
pub request_id: String,
pub correlation_id: Option<String>,
pub idempotency_key: Option<String>,
+ pub output_format: OutputFormat,
pub dry_run: bool,
pub actor: Option<EnvelopeActor>,
}
@@ -20,6 +21,7 @@ impl EnvelopeContext {
request_id: request_id.into(),
correlation_id: None,
idempotency_key: None,
+ output_format: OutputFormat::Human,
dry_run,
actor: None,
}
@@ -37,12 +39,16 @@ pub struct OutputEnvelope {
pub schema_version: &'static str,
pub operation_id: String,
pub kind: String,
+ pub status: OutputStatus,
+ pub output_format: OutputFormat,
pub request_id: String,
pub correlation_id: Option<String>,
pub idempotency_key: Option<String>,
pub dry_run: bool,
pub actor: Option<EnvelopeActor>,
+ pub resource: Option<OutputResource>,
pub result: Value,
+ pub reason_code: Option<String>,
pub warnings: Vec<OutputWarning>,
pub errors: Vec<OutputError>,
pub next_actions: Vec<NextAction>,
@@ -55,16 +61,22 @@ impl OutputEnvelope {
context: EnvelopeContext,
) -> Self {
let operation_id = operation_id.into();
+ let resource = output_resource_from_value(&result);
+ let reason_code = output_reason_code_from_value(&result);
Self {
schema_version: OUTPUT_SCHEMA_VERSION,
kind: operation_id.clone(),
operation_id,
+ status: OutputStatus::Ok,
+ output_format: context.output_format,
request_id: context.request_id,
correlation_id: context.correlation_id,
idempotency_key: context.idempotency_key,
dry_run: context.dry_run,
actor: context.actor,
+ resource,
result,
+ reason_code,
warnings: Vec::new(),
errors: Vec::new(),
next_actions: Vec::new(),
@@ -78,16 +90,22 @@ impl OutputEnvelope {
) -> Self {
let operation_id = operation_id.into();
let next_actions = next_actions_from_error_detail(&error);
+ let resource = error.detail.as_ref().and_then(output_resource_from_value);
+ let reason_code = Some(error.reason_code.clone());
Self {
schema_version: OUTPUT_SCHEMA_VERSION,
kind: operation_id.clone(),
operation_id,
+ status: OutputStatus::Error,
+ output_format: context.output_format,
request_id: context.request_id,
correlation_id: context.correlation_id,
idempotency_key: context.idempotency_key,
dry_run: context.dry_run,
actor: context.actor,
+ resource,
result: Value::Null,
+ reason_code,
warnings: Vec::new(),
errors: vec![error],
next_actions,
@@ -102,10 +120,13 @@ impl OutputEnvelope {
NdjsonFrameType::Started,
json!({
"state": "started",
+ "status": self.status,
+ "output_format": self.output_format,
"dry_run": self.dry_run,
"correlation_id": &self.correlation_id,
"idempotency_key": &self.idempotency_key,
"actor": &self.actor,
+ "resource": &self.resource,
}),
);
let mut terminal = NdjsonFrame::new(
@@ -118,6 +139,10 @@ impl OutputEnvelope {
NdjsonFrameType::Error
},
json!({
+ "status": self.status,
+ "reason_code": &self.reason_code,
+ "output_format": self.output_format,
+ "resource": &self.resource,
"result": &self.result,
"next_actions": &self.next_actions,
"dry_run": self.dry_run,
@@ -132,6 +157,100 @@ impl OutputEnvelope {
}
}
+fn output_reason_code_from_value(value: &Value) -> Option<String> {
+ value
+ .get("reason_code")
+ .and_then(Value::as_str)
+ .filter(|reason_code| !reason_code.trim().is_empty())
+ .map(str::to_owned)
+}
+
+fn output_resource_from_value(value: &Value) -> Option<OutputResource> {
+ let object = value.as_object()?;
+ if let Some(resource) = object.get("resource").and_then(declared_output_resource) {
+ return Some(resource);
+ }
+ output_resource_from_fields(object).or_else(|| {
+ let nested_fields = [
+ "account",
+ "resolved_account",
+ "default_account",
+ "bound_account",
+ "farm",
+ "listing",
+ "basket",
+ "quote",
+ "order",
+ "payment",
+ "settlement",
+ ];
+ nested_fields
+ .into_iter()
+ .filter_map(|field| {
+ object
+ .get(field)
+ .and_then(|value| nested_output_resource(field, value))
+ })
+ .next()
+ })
+}
+
+fn nested_output_resource(field: &str, value: &Value) -> Option<OutputResource> {
+ let mut resource = output_resource_from_value(value)?;
+ if resource.kind == "resource" {
+ resource.kind = match field {
+ "resolved_account" | "default_account" | "bound_account" => "account",
+ other => other,
+ }
+ .to_owned();
+ }
+ Some(resource)
+}
+
+fn declared_output_resource(value: &Value) -> Option<OutputResource> {
+ let object = value.as_object()?;
+ let kind = object
+ .get("kind")
+ .and_then(Value::as_str)
+ .filter(|kind| !kind.trim().is_empty())?;
+ let id = object
+ .get("id")
+ .and_then(Value::as_str)
+ .filter(|id| !id.trim().is_empty())?;
+ Some(OutputResource {
+ kind: kind.to_owned(),
+ id: id.to_owned(),
+ })
+}
+
+fn output_resource_from_fields(object: &serde_json::Map<String, Value>) -> Option<OutputResource> {
+ [
+ ("account_id", "account"),
+ ("id", "resource"),
+ ("farm_id", "farm"),
+ ("seller_account_id", "account"),
+ ("buyer_account_id", "account"),
+ ("listing_id", "listing"),
+ ("listing_address", "listing"),
+ ("listing_addr", "listing"),
+ ("basket_id", "basket"),
+ ("order_id", "order"),
+ ("payment_event_id", "payment"),
+ ("settlement_event_id", "settlement"),
+ ]
+ .into_iter()
+ .find_map(|(field, kind)| {
+ object
+ .get(field)
+ .and_then(Value::as_str)
+ .filter(|id| !id.trim().is_empty())
+ .map(|id| OutputResource {
+ kind: kind.to_owned(),
+ id: id.to_owned(),
+ })
+ })
+}
+
pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> {
next_actions_from_actions_value(result.get("actions"))
}
@@ -235,9 +354,31 @@ pub struct OutputWarning {
pub message: String,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OutputStatus {
+ Ok,
+ Error,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OutputFormat {
+ Human,
+ Json,
+ Ndjson,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct OutputResource {
+ pub kind: String,
+ pub id: String,
+}
+
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutputError {
pub code: String,
+ pub reason_code: String,
pub message: String,
pub exit_code: u8,
pub detail: Option<Value>,
@@ -249,8 +390,10 @@ impl OutputError {
message: impl Into<String>,
exit_code: CliExitCode,
) -> Self {
+ let code = code.into();
Self {
- code: code.into(),
+ reason_code: code.clone(),
+ code,
message: message.into(),
exit_code: exit_code.code(),
detail: None,
@@ -386,11 +529,16 @@ mod tests {
assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION);
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["kind"], "listing.publish");
+ assert_eq!(value["status"], "ok");
+ assert_eq!(value["output_format"], "human");
assert_eq!(value["request_id"], "req_test");
assert_eq!(value["correlation_id"], "corr_test");
assert_eq!(value["idempotency_key"], "idem_test");
assert_eq!(value["dry_run"], true);
+ assert_eq!(value["resource"]["kind"], "listing");
+ assert_eq!(value["resource"]["id"], "listing_test");
assert_eq!(value["result"]["listing_id"], "listing_test");
+ assert_eq!(value["reason_code"], Value::Null);
assert_eq!(value["warnings"].as_array().unwrap().len(), 0);
assert_eq!(value["errors"].as_array().unwrap().len(), 0);
assert_eq!(value["next_actions"].as_array().unwrap().len(), 0);
@@ -412,8 +560,11 @@ mod tests {
assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION);
assert_eq!(value["operation_id"], "order.submit");
+ assert_eq!(value["status"], "error");
+ assert_eq!(value["reason_code"], "approval_required");
assert_eq!(value["result"], Value::Null);
assert_eq!(value["errors"][0]["code"], "approval_required");
+ assert_eq!(value["errors"][0]["reason_code"], "approval_required");
assert_eq!(value["errors"][0]["exit_code"], 6);
}
@@ -542,6 +693,32 @@ mod tests {
}
#[test]
+ fn ndjson_terminal_frame_carries_status_reason_and_resource() {
+ let mut error = OutputError::new(
+ "not_implemented",
+ "payments are deferred",
+ CliExitCode::RuntimeUnavailable,
+ );
+ error.detail = Some(json!({
+ "order_id": "ord_test",
+ }));
+ let envelope = OutputEnvelope::failure(
+ "order.payment.record",
+ error,
+ EnvelopeContext::new("req_payment", false),
+ );
+ let frames = envelope.to_ndjson_frames();
+
+ assert_eq!(frames[0].payload["status"], "error");
+ assert_eq!(frames[0].payload["output_format"], "human");
+ assert_eq!(frames[1].payload["status"], "error");
+ assert_eq!(frames[1].payload["reason_code"], "not_implemented");
+ assert_eq!(frames[1].payload["resource"]["kind"], "order");
+ assert_eq!(frames[1].payload["resource"]["id"], "ord_test");
+ assert_eq!(frames[1].errors[0].reason_code, "not_implemented");
+ }
+
+ #[test]
fn exit_code_contract_matches_handoff_range() {
assert_eq!(CliExitCode::Success.code(), 0);
assert_eq!(CliExitCode::InvalidInput.code(), 2);
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -26,9 +26,10 @@ use serde_json::json;
use support::{
RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret,
- make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots,
- remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string,
- write_public_identity_profile, write_secret_identity_profile,
+ json_from_stdout, make_listing_publishable, make_listing_publishable_with_seller,
+ ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id,
+ seed_orderable_listing, toml_string, write_public_identity_profile,
+ write_secret_identity_profile,
};
const LISTING_ADDR: &str =
@@ -2199,6 +2200,67 @@ fn unsupported_ndjson_returns_structured_invalid_input() {
}
#[test]
+fn machine_output_exposes_status_format_resource_and_reason_code() {
+ let sandbox = RadrootsCliSandbox::new();
+
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ assert_eq!(account["status"], "ok");
+ assert_eq!(account["output_format"], "json");
+ assert_eq!(account["reason_code"], Value::Null);
+ assert_eq!(account["resource"]["kind"], "account");
+ assert_eq!(
+ account["resource"]["id"],
+ account["result"]["account"]["id"]
+ );
+
+ let (deferred_output, deferred) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "order",
+ "payment",
+ "record",
+ "ord_pending",
+ ]);
+ assert_eq!(deferred_output.status.code(), Some(3));
+ assert_eq!(deferred["status"], "error");
+ assert_eq!(deferred["output_format"], "json");
+ assert_eq!(deferred["reason_code"], "not_implemented");
+ assert_eq!(deferred["errors"][0]["reason_code"], "not_implemented");
+
+ let output = sandbox
+ .command()
+ .args(["--format", "json", "--dry-run", "workspace", "get"])
+ .output()
+ .expect("run invalid dry-run");
+ assert_eq!(output.status.code(), Some(2));
+ let invalid = json_from_stdout(&output);
+ assert_eq!(invalid["status"], "error");
+ assert_eq!(invalid["reason_code"], "invalid_input");
+ assert_eq!(invalid["errors"][0]["reason_code"], "invalid_input");
+
+ let ndjson_output = sandbox
+ .command()
+ .args([
+ "--format",
+ "ndjson",
+ "order",
+ "payment",
+ "record",
+ "ord_pending",
+ ])
+ .output()
+ .expect("run deferred ndjson");
+ assert_eq!(ndjson_output.status.code(), Some(3));
+ let frames = ndjson_from_stdout(&ndjson_output);
+ assert_eq!(frames[0]["payload"]["status"], "error");
+ assert_eq!(frames[0]["payload"]["output_format"], "ndjson");
+ assert_eq!(frames[1]["payload"]["status"], "error");
+ assert_eq!(frames[1]["payload"]["output_format"], "ndjson");
+ assert_eq!(frames[1]["payload"]["reason_code"], "not_implemented");
+ assert_eq!(frames[1]["errors"][0]["reason_code"], "not_implemented");
+}
+
+#[test]
fn offline_forbids_external_network_operations() {
for (operation_id, args) in [
(