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}