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::receipt::decision::Decision {
199 match self {
200 Self::Allow => chio_core_types::receipt::decision::Decision::Allow,
201 Self::Deny { reason, guard, .. } => {
202 chio_core_types::receipt::decision::Decision::Deny {
203 reason: reason.clone(),
204 guard: guard.clone(),
205 }
206 }
207 Self::Cancel { reason } => chio_core_types::receipt::decision::Decision::Cancelled {
208 reason: reason.clone(),
209 },
210 Self::Incomplete { reason } => {
211 chio_core_types::receipt::decision::Decision::Incomplete {
212 reason: reason.clone(),
213 }
214 }
215 }
216 }
217}
218
219impl From<chio_core_types::receipt::decision::Decision> for Verdict {
220 fn from(decision: chio_core_types::receipt::decision::Decision) -> Self {
221 match decision {
222 chio_core_types::receipt::decision::Decision::Allow => Self::Allow,
223 chio_core_types::receipt::decision::Decision::Deny { reason, guard } => Self::Deny {
224 reason,
225 guard,
226 http_status: 403,
227 details: Box::new(DenyDetails::default()),
228 },
229 chio_core_types::receipt::decision::Decision::Cancelled { reason } => {
230 Self::Cancel { reason }
231 }
232 chio_core_types::receipt::decision::Decision::Incomplete { reason } => {
233 Self::Incomplete { reason }
234 }
235 }
236 }
237}
238
239#[cfg(test)]
240#[allow(clippy::expect_used, clippy::unwrap_used)]
241mod tests {
242 use super::*;
243
244 fn expect_deny(v: Verdict) -> (String, String, u16, DenyDetails) {
245 match v {
246 Verdict::Deny {
247 reason,
248 guard,
249 http_status,
250 details,
251 } => (reason, guard, http_status, *details),
252 other => panic!("expected Deny, got {other:?}"),
253 }
254 }
255
256 #[test]
257 fn verdict_deny_default_status() {
258 let v = Verdict::deny("no capability", "CapabilityGuard");
259 assert!(v.is_denied());
260 assert!(!v.is_allowed());
261 let (_, _, http_status, details) = expect_deny(v);
262 assert_eq!(http_status, 403);
263 assert!(details.is_empty());
264 }
265
266 #[test]
267 fn verdict_to_decision_roundtrip() {
268 let v = Verdict::deny("blocked", "TestGuard");
269 let d = v.to_decision();
270 let v2 = Verdict::from(d);
271 assert!(v2.is_denied());
272 }
273
274 #[test]
275 fn serde_roundtrip() {
276 let v = Verdict::Allow;
277 let json = serde_json::to_string(&v).expect("allow serializes");
278 let back: Verdict = serde_json::from_str(&json).expect("allow deserializes");
279 assert_eq!(back, v);
280 }
281
282 #[test]
283 fn deny_serde_includes_status() {
284 let v = Verdict::deny_with_status("rate limited", "RateGuard", 429);
285 let json = serde_json::to_string(&v).expect("serializes");
286 assert!(json.contains("429"));
287 let back: Verdict = serde_json::from_str(&json).expect("deserializes");
288 let (_, _, http_status, _) = expect_deny(back);
289 assert_eq!(http_status, 429);
290 }
291
292 #[test]
293 fn cancel_verdict_conversion() {
294 let v = Verdict::Cancel {
295 reason: "timed out".to_string(),
296 };
297 assert!(!v.is_allowed());
298 assert!(!v.is_denied());
299 let decision = v.to_decision();
300 assert!(matches!(
301 decision,
302 chio_core_types::receipt::decision::Decision::Cancelled { .. }
303 ));
304 let v2 = Verdict::from(decision);
305 assert!(matches!(v2, Verdict::Cancel { reason } if reason == "timed out"));
306 }
307
308 #[test]
309 fn incomplete_verdict_conversion() {
310 let v = Verdict::Incomplete {
311 reason: "partial evaluation".to_string(),
312 };
313 assert!(!v.is_allowed());
314 assert!(!v.is_denied());
315 let decision = v.to_decision();
316 assert!(matches!(
317 decision,
318 chio_core_types::receipt::decision::Decision::Incomplete { .. }
319 ));
320 let v2 = Verdict::from(decision);
321 assert!(matches!(v2, Verdict::Incomplete { reason } if reason == "partial evaluation"));
322 }
323
324 #[test]
325 fn cancel_serde_roundtrip() {
326 let v = Verdict::Cancel {
327 reason: "circuit breaker".to_string(),
328 };
329 let json = serde_json::to_string(&v).expect("serializes");
330 let back: Verdict = serde_json::from_str(&json).expect("deserializes");
331 assert_eq!(back, v);
332 }
333
334 #[test]
335 fn incomplete_serde_roundtrip() {
336 let v = Verdict::Incomplete {
337 reason: "pending approval".to_string(),
338 };
339 let json = serde_json::to_string(&v).expect("serializes");
340 let back: Verdict = serde_json::from_str(&json).expect("deserializes");
341 assert_eq!(back, v);
342 }
343
344 #[test]
345 fn deny_default_status_via_serde_default() {
346 let json = r#"{"verdict":"deny","reason":"blocked","guard":"TestGuard"}"#;
349 let v: Verdict = serde_json::from_str(json).expect("deserializes");
350 let (_, _, http_status, details) = expect_deny(v);
351 assert_eq!(http_status, 403);
352 assert!(details.is_empty());
355 }
356
357 #[test]
358 fn allow_roundtrip_through_decision() {
359 let v = Verdict::Allow;
360 let decision = v.to_decision();
361 assert!(matches!(
362 decision,
363 chio_core_types::receipt::decision::Decision::Allow
364 ));
365 let v2 = Verdict::from(decision);
366 assert!(v2.is_allowed());
367 }
368
369 #[test]
370 fn deny_detailed_carries_structured_fields() {
371 let details = DenyDetails {
372 tool_name: Some("write_file".into()),
373 tool_server: Some("filesystem".into()),
374 requested_action: Some("write_file(path=.env)".into()),
375 required_scope: Some("ToolGrant(server_id=filesystem, tool_name=write_file)".into()),
376 granted_scope: Some("ToolGrant(server_id=filesystem, tool_name=read_file)".into()),
377 reason_code: Some("scope.missing".into()),
378 receipt_id: Some("chio-receipt-7f3a9b2c".into()),
379 hint: Some("Request scope filesystem::write_file from the authority.".into()),
380 docs_url: Some("https://docs.chio-protocol.dev/errors/Chio-DENIED".into()),
381 };
382 let v = Verdict::deny_detailed("scope check failed", "ScopeGuard", details);
383 let (reason, guard, http_status, details) = expect_deny(v);
384 assert_eq!(reason, "scope check failed");
385 assert_eq!(guard, "ScopeGuard");
386 assert_eq!(http_status, 403);
387 assert_eq!(details.tool_name.as_deref(), Some("write_file"));
388 assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
389 }
390
391 #[test]
392 fn deny_details_empty_is_omitted_on_the_wire() {
393 let v = Verdict::deny("no capability", "CapabilityGuard");
396 let json = serde_json::to_string(&v).expect("serializes");
397 assert!(
398 !json.contains("details"),
399 "unexpected details in JSON: {json}"
400 );
401 assert!(json.contains("\"verdict\":\"deny\""));
402 assert!(json.contains("\"reason\":\"no capability\""));
403 assert!(json.contains("\"guard\":\"CapabilityGuard\""));
404 }
405
406 #[test]
407 fn with_deny_details_attaches_context() {
408 let details = DenyDetails {
409 tool_name: Some("read_file".into()),
410 reason_code: Some("scope.missing".into()),
411 ..DenyDetails::default()
412 };
413 let v = Verdict::deny("missing scope", "ScopeGuard").with_deny_details(details);
414 let (_, _, _, details) = expect_deny(v);
415 assert_eq!(details.tool_name.as_deref(), Some("read_file"));
416 assert_eq!(details.reason_code.as_deref(), Some("scope.missing"));
417 }
418
419 #[test]
420 fn with_deny_details_is_noop_for_non_deny() {
421 let v = Verdict::Allow.with_deny_details(DenyDetails {
422 tool_name: Some("should_be_ignored".into()),
423 ..DenyDetails::default()
424 });
425 assert!(v.is_allowed());
426 }
427}