use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DenyDetails {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub receipt_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docs_url: Option<String>,
}
impl DenyDetails {
#[must_use]
pub fn is_empty(&self) -> bool {
self.tool_name.is_none()
&& self.tool_server.is_none()
&& self.requested_action.is_none()
&& self.required_scope.is_none()
&& self.granted_scope.is_none()
&& self.reason_code.is_none()
&& self.receipt_id.is_none()
&& self.hint.is_none()
&& self.docs_url.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "verdict", rename_all = "snake_case")]
pub enum Verdict {
Allow,
Deny {
reason: String,
guard: String,
#[serde(default = "default_deny_status")]
http_status: u16,
#[serde(default, skip_serializing_if = "deny_details_is_empty")]
details: Box<DenyDetails>,
},
Cancel {
reason: String,
},
Incomplete {
reason: String,
},
}
fn default_deny_status() -> u16 {
403
}
fn deny_details_is_empty(details: &DenyDetails) -> bool {
details.is_empty()
}
impl Verdict {
#[must_use]
pub fn deny(reason: impl Into<String>, guard: impl Into<String>) -> Self {
Self::Deny {
reason: reason.into(),
guard: guard.into(),
http_status: 403,
details: Box::new(DenyDetails::default()),
}
}
#[must_use]
pub fn deny_with_status(
reason: impl Into<String>,
guard: impl Into<String>,
http_status: u16,
) -> Self {
Self::Deny {
reason: reason.into(),
guard: guard.into(),
http_status,
details: Box::new(DenyDetails::default()),
}
}
#[must_use]
pub fn deny_detailed(
reason: impl Into<String>,
guard: impl Into<String>,
details: DenyDetails,
) -> Self {
Self::Deny {
reason: reason.into(),
guard: guard.into(),
http_status: 403,
details: Box::new(details),
}
}
pub fn with_deny_details(mut self, new_details: DenyDetails) -> Self {
if let Self::Deny { details, .. } = &mut self {
**details = new_details;
}
self
}
#[must_use]
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allow)
}
#[must_use]
pub fn is_denied(&self) -> bool {
matches!(self, Self::Deny { .. })
}
#[must_use]
pub fn to_decision(&self) -> chio_core_types::Decision {
match self {
Self::Allow => chio_core_types::Decision::Allow,
Self::Deny { reason, guard, .. } => chio_core_types::Decision::Deny {
reason: reason.clone(),
guard: guard.clone(),
},
Self::Cancel { reason } => chio_core_types::Decision::Cancelled {
reason: reason.clone(),
},
Self::Incomplete { reason } => chio_core_types::Decision::Incomplete {
reason: reason.clone(),
},
}
}
}
impl From<chio_core_types::Decision> for Verdict {
fn from(decision: chio_core_types::Decision) -> Self {
match decision {
chio_core_types::Decision::Allow => Self::Allow,
chio_core_types::Decision::Deny { reason, guard } => Self::Deny {
reason,
guard,
http_status: 403,
details: Box::new(DenyDetails::default()),
},
chio_core_types::Decision::Cancelled { reason } => Self::Cancel { reason },
chio_core_types::Decision::Incomplete { reason } => Self::Incomplete { reason },
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn expect_deny(v: Verdict) -> (String, String, u16, DenyDetails) {
match v {
Verdict::Deny {
reason,
guard,
http_status,
details,
} => (reason, guard, http_status, *details),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn verdict_deny_default_status() {
let v = Verdict::deny("no capability", "CapabilityGuard");
assert!(v.is_denied());
assert!(!v.is_allowed());
let (_, _, http_status, details) = expect_deny(v);
assert_eq!(http_status, 403);
assert!(details.is_empty());
}
#[test]
fn verdict_to_decision_roundtrip() {
let v = Verdict::deny("blocked", "TestGuard");
let d = v.to_decision();
let v2 = Verdict::from(d);
assert!(v2.is_denied());
}
#[test]
fn serde_roundtrip() {
let v = Verdict::Allow;
let json = serde_json::to_string(&v).expect("allow serializes");
let back: Verdict = serde_json::from_str(&json).expect("allow deserializes");
assert_eq!(back, v);
}
#[test]
fn deny_serde_includes_status() {
let v = Verdict::deny_with_status("rate limited", "RateGuard", 429);
let json = serde_json::to_string(&v).expect("serializes");
assert!(json.contains("429"));
let back: Verdict = serde_json::from_str(&json).expect("deserializes");
let (_, _, http_status, _) = expect_deny(back);
assert_eq!(http_status, 429);
}
#[test]
fn cancel_verdict_conversion() {
let v = Verdict::Cancel {
reason: "timed out".to_string(),
};
assert!(!v.is_allowed());
assert!(!v.is_denied());
let decision = v.to_decision();
assert!(matches!(
decision,
chio_core_types::Decision::Cancelled { .. }
));
let v2 = Verdict::from(decision);
assert!(matches!(v2, Verdict::Cancel { reason } if reason == "timed out"));
}
#[test]
fn incomplete_verdict_conversion() {
let v = Verdict::Incomplete {
reason: "partial evaluation".to_string(),
};
assert!(!v.is_allowed());
assert!(!v.is_denied());
let decision = v.to_decision();
assert!(matches!(
decision,
chio_core_types::Decision::Incomplete { .. }
));
let v2 = Verdict::from(decision);
assert!(matches!(v2, Verdict::Incomplete { reason } if reason == "partial evaluation"));
}
#[test]
fn cancel_serde_roundtrip() {
let v = Verdict::Cancel {
reason: "circuit breaker".to_string(),
};
let json = serde_json::to_string(&v).expect("serializes");
let back: Verdict = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, v);
}
#[test]
fn incomplete_serde_roundtrip() {
let v = Verdict::Incomplete {
reason: "pending approval".to_string(),
};
let json = serde_json::to_string(&v).expect("serializes");
let back: Verdict = serde_json::from_str(&json).expect("deserializes");
assert_eq!(back, v);
}
#[test]
fn deny_default_status_via_serde_default() {
let json = r#"{"verdict":"deny","reason":"blocked","guard":"TestGuard"}"#;
let v: Verdict = serde_json::from_str(json).expect("deserializes");
let (_, _, http_status, details) = expect_deny(v);
assert_eq!(http_status, 403);
assert!(details.is_empty());
}
#[test]
fn allow_roundtrip_through_decision() {
let v = Verdict::Allow;
let decision = v.to_decision();
assert!(matches!(decision, chio_core_types::Decision::Allow));
let v2 = Verdict::from(decision);
assert!(v2.is_allowed());
}
#[test]
fn deny_detailed_carries_structured_fields() {
let details = DenyDetails {
tool_name: Some("write_file".into()),
tool_server: Some("filesystem".into()),
requested_action: Some("write_file(path=.env)".into()),
required_scope: Some("ToolGrant(server_id=filesystem, tool_name=write_file)".into()),
granted_scope: Some("ToolGrant(server_id=filesystem, tool_name=read_file)".into()),
reason_code: Some("scope.missing".into()),
receipt_id: Some("chio-receipt-7f3a9b2c".into()),
hint: Some("Request scope filesystem::write_file from the authority.".into()),
docs_url: Some("https://docs.chio-protocol.dev/errors/Chio-DENIED".into()),
};
let v = Verdict::deny_detailed("scope check failed", "ScopeGuard", details);
let (reason, guard, http_status, details) = expect_deny(v);
assert_eq!(reason, "scope check failed");
assert_eq!(guard, "ScopeGuard");
assert_eq!(http_status, 403);
assert_eq!(details.tool_name.as_deref(), Some("write_file"));
assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
}
#[test]
fn deny_details_empty_is_omitted_on_the_wire() {
let v = Verdict::deny("no capability", "CapabilityGuard");
let json = serde_json::to_string(&v).expect("serializes");
assert!(
!json.contains("details"),
"unexpected details in JSON: {json}"
);
assert!(json.contains("\"verdict\":\"deny\""));
assert!(json.contains("\"reason\":\"no capability\""));
assert!(json.contains("\"guard\":\"CapabilityGuard\""));
}
#[test]
fn with_deny_details_attaches_context() {
let details = DenyDetails {
tool_name: Some("read_file".into()),
reason_code: Some("scope.missing".into()),
..DenyDetails::default()
};
let v = Verdict::deny("missing scope", "ScopeGuard").with_deny_details(details);
let (_, _, _, details) = expect_deny(v);
assert_eq!(details.tool_name.as_deref(), Some("read_file"));
assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
}
#[test]
fn with_deny_details_is_noop_for_non_deny() {
let v = Verdict::Allow.with_deny_details(DenyDetails {
tool_name: Some("should_be_ignored".into()),
..DenyDetails::default()
});
assert!(v.is_allowed());
}
}