1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19pub struct DenyDetails {
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub tool_name: Option<String>,
23
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub tool_server: Option<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub requested_action: Option<String>,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub required_scope: Option<String>,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub granted_scope: Option<String>,
42
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub reason_code: Option<String>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub receipt_id: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub hint: Option<String>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub docs_url: Option<String>,
60}
61
62impl DenyDetails {
63 #[must_use]
66 pub fn is_empty(&self) -> bool {
67 self.tool_name.is_none()
68 && self.tool_server.is_none()
69 && self.requested_action.is_none()
70 && self.required_scope.is_none()
71 && self.granted_scope.is_none()
72 && self.reason_code.is_none()
73 && self.receipt_id.is_none()
74 && self.hint.is_none()
75 && self.docs_url.is_none()
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(tag = "verdict", rename_all = "snake_case")]
83pub enum Verdict {
84 Allow,
86
87 Deny {
89 reason: String,
91 guard: String,
93 #[serde(default = "default_deny_status")]
95 http_status: u16,
96 #[serde(default, skip_serializing_if = "deny_details_is_empty")]
105 details: Box<DenyDetails>,
106 },
107
108 Cancel {
110 reason: String,
112 },
113
114 Incomplete {
116 reason: String,
118 },
119}
120
121fn default_deny_status() -> u16 {
122 403
123}
124
125fn deny_details_is_empty(details: &DenyDetails) -> bool {
126 details.is_empty()
127}
128
129impl Verdict {
130 #[must_use]
132 pub fn deny(reason: impl Into<String>, guard: impl Into<String>) -> Self {
133 Self::Deny {
134 reason: reason.into(),
135 guard: guard.into(),
136 http_status: 403,
137 details: Box::new(DenyDetails::default()),
138 }
139 }
140
141 #[must_use]
143 pub fn deny_with_status(
144 reason: impl Into<String>,
145 guard: impl Into<String>,
146 http_status: u16,
147 ) -> Self {
148 Self::Deny {
149 reason: reason.into(),
150 guard: guard.into(),
151 http_status,
152 details: Box::new(DenyDetails::default()),
153 }
154 }
155
156 #[must_use]
162 pub fn deny_detailed(
163 reason: impl Into<String>,
164 guard: impl Into<String>,
165 details: DenyDetails,
166 ) -> Self {
167 Self::Deny {
168 reason: reason.into(),
169 guard: guard.into(),
170 http_status: 403,
171 details: Box::new(details),
172 }
173 }
174
175 pub fn with_deny_details(mut self, new_details: DenyDetails) -> Self {
180 if let Self::Deny { details, .. } = &mut self {
181 **details = new_details;
182 }
183 self
184 }
185
186 #[must_use]
187 pub fn is_allowed(&self) -> bool {
188 matches!(self, Self::Allow)
189 }
190
191 #[must_use]
192 pub fn is_denied(&self) -> bool {
193 matches!(self, Self::Deny { .. })
194 }
195
196 #[must_use]
198 pub fn to_decision(&self) -> chio_core_types::Decision {
199 match self {
200 Self::Allow => chio_core_types::Decision::Allow,
201 Self::Deny { reason, guard, .. } => chio_core_types::Decision::Deny {
202 reason: reason.clone(),
203 guard: guard.clone(),
204 },
205 Self::Cancel { reason } => chio_core_types::Decision::Cancelled {
206 reason: reason.clone(),
207 },
208 Self::Incomplete { reason } => chio_core_types::Decision::Incomplete {
209 reason: reason.clone(),
210 },
211 }
212 }
213}
214
215impl From<chio_core_types::Decision> for Verdict {
216 fn from(decision: chio_core_types::Decision) -> Self {
217 match decision {
218 chio_core_types::Decision::Allow => Self::Allow,
219 chio_core_types::Decision::Deny { reason, guard } => Self::Deny {
220 reason,
221 guard,
222 http_status: 403,
223 details: Box::new(DenyDetails::default()),
224 },
225 chio_core_types::Decision::Cancelled { reason } => Self::Cancel { reason },
226 chio_core_types::Decision::Incomplete { reason } => Self::Incomplete { reason },
227 }
228 }
229}
230
231#[cfg(test)]
232#[allow(clippy::expect_used, clippy::unwrap_used)]
233mod tests {
234 use super::*;
235
236 fn expect_deny(v: Verdict) -> (String, String, u16, DenyDetails) {
237 match v {
238 Verdict::Deny {
239 reason,
240 guard,
241 http_status,
242 details,
243 } => (reason, guard, http_status, *details),
244 other => panic!("expected Deny, got {other:?}"),
245 }
246 }
247
248 #[test]
249 fn verdict_deny_default_status() {
250 let v = Verdict::deny("no capability", "CapabilityGuard");
251 assert!(v.is_denied());
252 assert!(!v.is_allowed());
253 let (_, _, http_status, details) = expect_deny(v);
254 assert_eq!(http_status, 403);
255 assert!(details.is_empty());
256 }
257
258 #[test]
259 fn verdict_to_decision_roundtrip() {
260 let v = Verdict::deny("blocked", "TestGuard");
261 let d = v.to_decision();
262 let v2 = Verdict::from(d);
263 assert!(v2.is_denied());
264 }
265
266 #[test]
267 fn serde_roundtrip() {
268 let v = Verdict::Allow;
269 let json = serde_json::to_string(&v).expect("allow serializes");
270 let back: Verdict = serde_json::from_str(&json).expect("allow deserializes");
271 assert_eq!(back, v);
272 }
273
274 #[test]
275 fn deny_serde_includes_status() {
276 let v = Verdict::deny_with_status("rate limited", "RateGuard", 429);
277 let json = serde_json::to_string(&v).expect("serializes");
278 assert!(json.contains("429"));
279 let back: Verdict = serde_json::from_str(&json).expect("deserializes");
280 let (_, _, http_status, _) = expect_deny(back);
281 assert_eq!(http_status, 429);
282 }
283
284 #[test]
285 fn cancel_verdict_conversion() {
286 let v = Verdict::Cancel {
287 reason: "timed out".to_string(),
288 };
289 assert!(!v.is_allowed());
290 assert!(!v.is_denied());
291 let decision = v.to_decision();
292 assert!(matches!(
293 decision,
294 chio_core_types::Decision::Cancelled { .. }
295 ));
296 let v2 = Verdict::from(decision);
297 assert!(matches!(v2, Verdict::Cancel { reason } if reason == "timed out"));
298 }
299
300 #[test]
301 fn incomplete_verdict_conversion() {
302 let v = Verdict::Incomplete {
303 reason: "partial evaluation".to_string(),
304 };
305 assert!(!v.is_allowed());
306 assert!(!v.is_denied());
307 let decision = v.to_decision();
308 assert!(matches!(
309 decision,
310 chio_core_types::Decision::Incomplete { .. }
311 ));
312 let v2 = Verdict::from(decision);
313 assert!(matches!(v2, Verdict::Incomplete { reason } if reason == "partial evaluation"));
314 }
315
316 #[test]
317 fn cancel_serde_roundtrip() {
318 let v = Verdict::Cancel {
319 reason: "circuit breaker".to_string(),
320 };
321 let json = serde_json::to_string(&v).expect("serializes");
322 let back: Verdict = serde_json::from_str(&json).expect("deserializes");
323 assert_eq!(back, v);
324 }
325
326 #[test]
327 fn incomplete_serde_roundtrip() {
328 let v = Verdict::Incomplete {
329 reason: "pending approval".to_string(),
330 };
331 let json = serde_json::to_string(&v).expect("serializes");
332 let back: Verdict = serde_json::from_str(&json).expect("deserializes");
333 assert_eq!(back, v);
334 }
335
336 #[test]
337 fn deny_default_status_via_serde_default() {
338 let json = r#"{"verdict":"deny","reason":"blocked","guard":"TestGuard"}"#;
341 let v: Verdict = serde_json::from_str(json).expect("deserializes");
342 let (_, _, http_status, details) = expect_deny(v);
343 assert_eq!(http_status, 403);
344 assert!(details.is_empty());
347 }
348
349 #[test]
350 fn allow_roundtrip_through_decision() {
351 let v = Verdict::Allow;
352 let decision = v.to_decision();
353 assert!(matches!(decision, chio_core_types::Decision::Allow));
354 let v2 = Verdict::from(decision);
355 assert!(v2.is_allowed());
356 }
357
358 #[test]
359 fn deny_detailed_carries_structured_fields() {
360 let details = DenyDetails {
361 tool_name: Some("write_file".into()),
362 tool_server: Some("filesystem".into()),
363 requested_action: Some("write_file(path=.env)".into()),
364 required_scope: Some("ToolGrant(server_id=filesystem, tool_name=write_file)".into()),
365 granted_scope: Some("ToolGrant(server_id=filesystem, tool_name=read_file)".into()),
366 reason_code: Some("scope.missing".into()),
367 receipt_id: Some("chio-receipt-7f3a9b2c".into()),
368 hint: Some("Request scope filesystem::write_file from the authority.".into()),
369 docs_url: Some("https://docs.chio-protocol.dev/errors/Chio-DENIED".into()),
370 };
371 let v = Verdict::deny_detailed("scope check failed", "ScopeGuard", details);
372 let (reason, guard, http_status, details) = expect_deny(v);
373 assert_eq!(reason, "scope check failed");
374 assert_eq!(guard, "ScopeGuard");
375 assert_eq!(http_status, 403);
376 assert_eq!(details.tool_name.as_deref(), Some("write_file"));
377 assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
378 }
379
380 #[test]
381 fn deny_details_empty_is_omitted_on_the_wire() {
382 let v = Verdict::deny("no capability", "CapabilityGuard");
385 let json = serde_json::to_string(&v).expect("serializes");
386 assert!(
387 !json.contains("details"),
388 "unexpected details in JSON: {json}"
389 );
390 assert!(json.contains("\"verdict\":\"deny\""));
391 assert!(json.contains("\"reason\":\"no capability\""));
392 assert!(json.contains("\"guard\":\"CapabilityGuard\""));
393 }
394
395 #[test]
396 fn with_deny_details_attaches_context() {
397 let details = DenyDetails {
398 tool_name: Some("read_file".into()),
399 reason_code: Some("scope.missing".into()),
400 ..DenyDetails::default()
401 };
402 let v = Verdict::deny("missing scope", "ScopeGuard").with_deny_details(details);
403 let (_, _, _, details) = expect_deny(v);
404 assert_eq!(details.tool_name.as_deref(), Some("read_file"));
405 assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
406 }
407
408 #[test]
409 fn with_deny_details_is_noop_for_non_deny() {
410 let v = Verdict::Allow.with_deny_details(DenyDetails {
411 tool_name: Some("should_be_ignored".into()),
412 ..DenyDetails::default()
413 });
414 assert!(v.is_allowed());
415 }
416}