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}