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 wherever state leaves the process: the durable
40    /// journal sink, persistence snapshots and extraction events carry
41    /// `[redacted]` instead (see `State::redact_keys`). In-process reads see
42    /// the real value. Pairs with `#[slot(pii)]`. Transcript text is a
43    /// separate concern: see `Live::redaction`.
44    Redact {
45        /// State keys to redact.
46        keys: Vec<String>,
47    },
48    /// Commit-tool governance for a confirm-before-act tool, enforced at
49    /// connect by a `CommitGuard`: a call whose idempotency key already
50    /// succeeded returns the first result without running again, and a failed
51    /// call runs the compensating tool with the same arguments.
52    Commit {
53        /// The committing tool.
54        tool: String,
55        /// Idempotency key template (`{key}` interpolated from `State`).
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        idempotency_key: Option<String>,
58        /// Tool that compensates (undoes) this commit on failure.
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        compensate_with: Option<String>,
61    },
62}
63
64impl Policy {
65    /// Terminate/hand off when any of `intents` is detected.
66    pub fn safety_handoff<I, S>(intents: I) -> Self
67    where
68        I: IntoIterator<Item = S>,
69        S: Into<String>,
70    {
71        Policy::SafetyHandoff {
72            intents: intents.into_iter().map(Into::into).collect(),
73        }
74    }
75
76    /// Redact these state keys in logs/transcripts.
77    pub fn redact<I, S>(keys: I) -> Self
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        Policy::Redact {
83            keys: keys.into_iter().map(Into::into).collect(),
84        }
85    }
86
87    /// Begin a commit-governance policy for `tool`.
88    pub fn commit(tool: impl Into<String>) -> CommitPolicy {
89        CommitPolicy {
90            tool: tool.into(),
91            idempotency_key: None,
92            compensate_with: None,
93        }
94    }
95
96    /// The state keys this policy marks for redaction (empty for non-redact).
97    pub fn redacted_keys(&self) -> &[String] {
98        match self {
99            Policy::Redact { keys } => keys,
100            _ => &[],
101        }
102    }
103}
104
105/// Builder for a [`Policy::Commit`] governance aspect.
106#[derive(Debug, Clone)]
107pub struct CommitPolicy {
108    tool: String,
109    idempotency_key: Option<String>,
110    compensate_with: Option<String>,
111}
112
113impl CommitPolicy {
114    /// Set the idempotency key template (`{key}` interpolated from `State`).
115    pub fn idempotency_key(mut self, template: impl Into<String>) -> Self {
116        self.idempotency_key = Some(template.into());
117        self
118    }
119
120    /// Set the compensating tool (undoes the commit on failure).
121    pub fn compensate_with(mut self, tool: impl Into<String>) -> Self {
122        self.compensate_with = Some(tool.into());
123        self
124    }
125}
126
127impl From<CommitPolicy> for Policy {
128    fn from(c: CommitPolicy) -> Self {
129        Policy::Commit {
130            tool: c.tool,
131            idempotency_key: c.idempotency_key,
132            compensate_with: c.compensate_with,
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn commit_builder_into_policy() {
143        let p: Policy = Policy::commit("charge_card")
144            .idempotency_key("{user_id}:{amount}")
145            .compensate_with("refund")
146            .into();
147        assert_eq!(
148            p,
149            Policy::Commit {
150                tool: "charge_card".into(),
151                idempotency_key: Some("{user_id}:{amount}".into()),
152                compensate_with: Some("refund".into()),
153            }
154        );
155    }
156
157    #[test]
158    fn policies_round_trip_through_json() {
159        let policies = vec![
160            Policy::redact(["card_number", "cvv"]),
161            Policy::safety_handoff(["self_harm"]),
162            Policy::commit("book").idempotency_key("{id}").into(),
163        ];
164        let json = serde_json::to_string(&policies).unwrap();
165        let back: Vec<Policy> = serde_json::from_str(&json).unwrap();
166        assert_eq!(policies, back);
167        assert_eq!(back[0].redacted_keys(), &["card_number", "cvv"]);
168    }
169}