Skip to main content

ferrin_policy/
decision.rs

1//! Decision documents and their normalization.
2//!
3//! Derived from the Vercel AI SDK decision normalization (Apache-2.0,
4//! Copyright 2023 Vercel, Inc.), reimplemented in Rust.
5
6use ferrin_core::generate_text::ApprovalStatus;
7use ferrin_spec::JsonValue;
8use serde::Deserialize;
9use serde::Serialize;
10
11/// Reason attached to denials of documents that are not recognized.
12pub const UNRECOGNIZED_DECISION: &str = "unrecognized OPA policy decision";
13
14/// A normalized policy decision.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "decision", rename_all = "kebab-case")]
17#[non_exhaustive]
18pub enum PolicyDecision {
19    /// The call may proceed without user approval.
20    Allow {
21        /// Reason recorded with the decision.
22        #[serde(default, skip_serializing_if = "Option::is_none")]
23        reason: Option<String>,
24    },
25    /// The call must not execute.
26    Deny {
27        /// Reason recorded with the decision.
28        #[serde(default, skip_serializing_if = "Option::is_none")]
29        reason: Option<String>,
30    },
31    /// An external approver must decide.
32    RequiresApproval {
33        /// Reason shown to the approver.
34        #[serde(default, skip_serializing_if = "Option::is_none")]
35        reason: Option<String>,
36    },
37    /// No approval is required, overriding the tool's own `needs_approval`.
38    NotApplicable,
39}
40
41impl PolicyDecision {
42    /// Allow without reason.
43    #[must_use]
44    pub fn allow() -> Self {
45        Self::Allow { reason: None }
46    }
47
48    /// Deny without reason.
49    #[must_use]
50    pub fn deny() -> Self {
51        Self::Deny { reason: None }
52    }
53
54    /// Requires approval without reason.
55    #[must_use]
56    pub fn requires_approval() -> Self {
57        Self::RequiresApproval { reason: None }
58    }
59
60    /// Attaches a reason (ignored for `NotApplicable`).
61    #[must_use]
62    pub fn with_reason(self, reason: impl Into<String>) -> Self {
63        let reason = Some(reason.into());
64        match self {
65            Self::NotApplicable => Self::NotApplicable,
66            Self::Allow { .. } => Self::Allow { reason },
67            Self::Deny { .. } => Self::Deny { reason },
68            Self::RequiresApproval { .. } => Self::RequiresApproval { reason },
69        }
70    }
71
72    /// The reason, if any.
73    #[must_use]
74    pub fn reason(&self) -> Option<&str> {
75        match self {
76            Self::NotApplicable => None,
77            Self::Allow { reason } | Self::Deny { reason } | Self::RequiresApproval { reason } => {
78                reason.as_deref()
79            }
80        }
81    }
82
83    /// Normalizes a raw decision document.
84    ///
85    /// Recognized forms:
86    ///
87    /// - `null`: not applicable (an undefined rule or a missing result).
88    /// - `{ "decision": "allow" | "deny" | "requires-approval" | "not-applicable", "reason"?: string }`.
89    /// - `{ "allow": bool, "reason"?: string }` (legacy form).
90    ///
91    /// A missing or unknown `decision` falls back to a valid legacy `allow`
92    /// field. Anything else is a denial with the reason
93    /// [`UNRECOGNIZED_DECISION`], so that a broken policy fails closed.
94    #[must_use]
95    pub fn normalize(raw: &JsonValue) -> Self {
96        let unrecognized = || Self::Deny {
97            reason: Some(UNRECOGNIZED_DECISION.to_owned()),
98        };
99        match raw {
100            JsonValue::Null => Self::NotApplicable,
101            JsonValue::Object(object) => {
102                let reason = object
103                    .get("reason")
104                    .and_then(JsonValue::as_str)
105                    .filter(|reason| !reason.is_empty())
106                    .map(str::to_owned);
107                match object.get("decision").and_then(JsonValue::as_str) {
108                    Some("allow") => return Self::Allow { reason },
109                    Some("deny") => return Self::Deny { reason },
110                    Some("requires-approval") => return Self::RequiresApproval { reason },
111                    Some("not-applicable") => return Self::NotApplicable,
112                    _ => {}
113                }
114                match object.get("allow").and_then(JsonValue::as_bool) {
115                    Some(true) => Self::Allow { reason },
116                    Some(false) => Self::Deny { reason },
117                    None => unrecognized(),
118                }
119            }
120            _ => unrecognized(),
121        }
122    }
123
124    /// Converts the decision into an approval status.
125    ///
126    /// `NotApplicable` is an explicit status and overrides tool-defined approval.
127    /// Use [`crate::with_default`] to gate calls without a policy decision.
128    #[must_use]
129    pub fn into_approval(self) -> Option<ApprovalStatus> {
130        match self {
131            Self::Allow { reason } => Some(ApprovalStatus::Approved { reason }),
132            Self::Deny { reason } => Some(ApprovalStatus::Denied { reason }),
133            Self::RequiresApproval { reason } => Some(ApprovalStatus::UserApproval { reason }),
134            Self::NotApplicable => Some(ApprovalStatus::NotApplicable),
135        }
136    }
137}