gemini_adk_fluent_rs/
policy.rs

1//! Policy aspects — reusable, cross-cutting governance attached to a whole
2//! conversation rather than scattered across stages.
3//!
4//! Compliance is where regulated voice flows live, and it should *feel* like
5//! attaching an aspect, not hand-wiring guards everywhere. A [`Policy`] is a
6//! serializable aspect applied with
7//! [`Conversation::policy`](crate::conversation::Conversation::policy); the
8//! compiler lowers it into concrete machinery (a safety digression, a redaction
9//! set, commit governance), always through the validated IR.
10//!
11//! ```no_run
12//! # use gemini_adk_fluent_rs::conversation::Conversation;
13//! # use gemini_adk_fluent_rs::policy::Policy;
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! Conversation::new("payment")
16//!     .policy(Policy::redact(["card_number", "cvv"]))
17//!     .policy(Policy::commit("charge_card").idempotency_key("{user_id}:{amount}").compensate_with("refund"))
18//!     .policy(Policy::safety_handoff(["self_harm", "abuse"]))
19//!     .stage("pay").terminal()   // … the real stages go here …
20//!     .require(["pay"])
21//!     .compile()?;
22//! # Ok(())
23//! # }
24//! ```
25
26use serde::{Deserialize, Serialize};
27
28/// A reusable, cross-cutting policy aspect.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
30#[serde(rename_all = "snake_case", tag = "kind")]
31pub enum Policy {
32    /// Hand off (terminate the conversation) when any of these intents is
33    /// detected (the `intent:{name}` flag becomes true). Lowered to a `safety`
34    /// digression with `Resume::Terminate`.
35    SafetyHandoff {
36        /// Intent names that trigger handoff.
37        intents: Vec<String>,
38    },
39    /// Redact these state keys in logs/transcripts. Recorded for the runtime's
40    /// logging layer; pairs with `#[slot(pii)]`.
41    Redact {
42        /// State keys to redact.
43        keys: Vec<String>,
44    },
45    /// Commit-tool governance: idempotency and compensation metadata for a
46    /// confirm-before-act tool.
47    Commit {
48        /// The committing tool.
49        tool: String,
50        /// Idempotency key template (`{key}` interpolated from `State`).
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        idempotency_key: Option<String>,
53        /// Tool that compensates (undoes) this commit on failure.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        compensate_with: Option<String>,
56    },
57}
58
59impl Policy {
60    /// Terminate/hand off when any of `intents` is detected.
61    pub fn safety_handoff<I, S>(intents: I) -> Self
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        Policy::SafetyHandoff {
67            intents: intents.into_iter().map(Into::into).collect(),
68        }
69    }
70
71    /// Redact these state keys in logs/transcripts.
72    pub fn redact<I, S>(keys: I) -> Self
73    where
74        I: IntoIterator<Item = S>,
75        S: Into<String>,
76    {
77        Policy::Redact {
78            keys: keys.into_iter().map(Into::into).collect(),
79        }
80    }
81
82    /// Begin a commit-governance policy for `tool`.
83    pub fn commit(tool: impl Into<String>) -> CommitPolicy {
84        CommitPolicy {
85            tool: tool.into(),
86            idempotency_key: None,
87            compensate_with: None,
88        }
89    }
90
91    /// The state keys this policy marks for redaction (empty for non-redact).
92    pub fn redacted_keys(&self) -> &[String] {
93        match self {
94            Policy::Redact { keys } => keys,
95            _ => &[],
96        }
97    }
98}
99
100/// Builder for a [`Policy::Commit`] governance aspect.
101#[derive(Debug, Clone)]
102pub struct CommitPolicy {
103    tool: String,
104    idempotency_key: Option<String>,
105    compensate_with: Option<String>,
106}
107
108impl CommitPolicy {
109    /// Set the idempotency key template (`{key}` interpolated from `State`).
110    pub fn idempotency_key(mut self, template: impl Into<String>) -> Self {
111        self.idempotency_key = Some(template.into());
112        self
113    }
114
115    /// Set the compensating tool (undoes the commit on failure).
116    pub fn compensate_with(mut self, tool: impl Into<String>) -> Self {
117        self.compensate_with = Some(tool.into());
118        self
119    }
120}
121
122impl From<CommitPolicy> for Policy {
123    fn from(c: CommitPolicy) -> Self {
124        Policy::Commit {
125            tool: c.tool,
126            idempotency_key: c.idempotency_key,
127            compensate_with: c.compensate_with,
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn commit_builder_into_policy() {
138        let p: Policy = Policy::commit("charge_card")
139            .idempotency_key("{user_id}:{amount}")
140            .compensate_with("refund")
141            .into();
142        assert_eq!(
143            p,
144            Policy::Commit {
145                tool: "charge_card".into(),
146                idempotency_key: Some("{user_id}:{amount}".into()),
147                compensate_with: Some("refund".into()),
148            }
149        );
150    }
151
152    #[test]
153    fn policies_round_trip_through_json() {
154        let policies = vec![
155            Policy::redact(["card_number", "cvv"]),
156            Policy::safety_handoff(["self_harm"]),
157            Policy::commit("book").idempotency_key("{id}").into(),
158        ];
159        let json = serde_json::to_string(&policies).unwrap();
160        let back: Vec<Policy> = serde_json::from_str(&json).unwrap();
161        assert_eq!(policies, back);
162        assert_eq!(back[0].redacted_keys(), &["card_number", "cvv"]);
163    }
164}