1use async_trait::async_trait;
22use thiserror::Error;
23
24use crate::receipt::RedactionRecord;
25use crate::types::{Brief, BriefContext};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum GovernanceVerdict {
34 Allow,
36 Redacted(Vec<RedactionRecord>),
40 Rejected(String),
43}
44
45impl GovernanceVerdict {
46 pub fn is_redacted(&self) -> bool {
48 matches!(self, GovernanceVerdict::Redacted(_))
49 }
50
51 pub fn is_rejected(&self) -> bool {
53 matches!(self, GovernanceVerdict::Rejected(_))
54 }
55}
56
57#[derive(Debug, Error)]
64pub enum GovernanceError {
65 #[error("governance backend error: {0}")]
67 Backend(String),
68
69 #[error("governance error: {0}")]
71 Other(String),
72}
73
74#[async_trait]
86pub trait Governance: Send + Sync {
87 async fn review(
89 &self,
90 draft: &mut Brief,
91 ctx: &BriefContext,
92 ) -> Result<GovernanceVerdict, GovernanceError>;
93}
94
95#[derive(Debug, Default, Clone, Copy)]
102pub struct NoOpGovernance;
103
104impl NoOpGovernance {
105 pub fn new() -> Self {
107 NoOpGovernance
108 }
109}
110
111#[async_trait]
112impl Governance for NoOpGovernance {
113 async fn review(
114 &self,
115 _draft: &mut Brief,
116 _ctx: &BriefContext,
117 ) -> Result<GovernanceVerdict, GovernanceError> {
118 Ok(GovernanceVerdict::Allow)
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::receipt::BriefReceipt;
126 use crate::types::{BriefMessage, Role, SourceId, TokenBudget};
127
128 fn empty_draft() -> Brief {
129 Brief {
130 system: None,
131 messages: Vec::new(),
132 tools: Vec::new(),
133 receipt: BriefReceipt::empty(),
134 }
135 }
136
137 #[tokio::test]
138 async fn no_op_always_allows() {
139 let mut draft = empty_draft();
140 let ctx = BriefContext::new(TokenBudget::default());
141 let v = NoOpGovernance.review(&mut draft, &ctx).await.expect("ok");
142 assert_eq!(v, GovernanceVerdict::Allow);
143 }
144
145 struct SecretScrubber;
148
149 #[async_trait]
150 impl Governance for SecretScrubber {
151 async fn review(
152 &self,
153 draft: &mut Brief,
154 _ctx: &BriefContext,
155 ) -> Result<GovernanceVerdict, GovernanceError> {
156 let mut records = Vec::new();
157 for msg in &mut draft.messages {
158 if let BriefMessage::Text {
159 role: Role::User,
160 content,
161 source,
162 } = msg
163 {
164 if content.contains("secret") {
165 records.push(RedactionRecord {
166 source: source.clone(),
167 rule: "rule:no_user_secrets".into(),
168 });
169 *content = content.replace("secret", "[REDACTED]");
170 }
171 }
172 }
173 Ok(if records.is_empty() {
174 GovernanceVerdict::Allow
175 } else {
176 GovernanceVerdict::Redacted(records)
177 })
178 }
179 }
180
181 #[tokio::test]
182 async fn redacted_verdict_carries_records() {
183 let mut draft = empty_draft();
184 draft.messages.push(BriefMessage::Text {
185 role: Role::User,
186 content: "my secret is hunter2".into(),
187 source: SourceId::new("user_message"),
188 });
189 let ctx = BriefContext::new(TokenBudget::default());
190
191 let v = SecretScrubber.review(&mut draft, &ctx).await.expect("ok");
192 match v {
193 GovernanceVerdict::Redacted(records) => {
194 assert_eq!(records.len(), 1);
195 assert_eq!(records[0].source, SourceId::new("user_message"));
196 assert_eq!(records[0].rule, "rule:no_user_secrets");
197 }
198 other => panic!("expected Redacted, got {other:?}"),
199 }
200 match &draft.messages[0] {
201 BriefMessage::Text { content, .. } => {
202 assert!(content.contains("[REDACTED]"));
203 assert!(!content.contains("secret"));
204 }
205 _ => panic!(),
206 }
207 }
208
209 #[tokio::test]
210 async fn allow_returned_when_nothing_to_redact() {
211 let mut draft = empty_draft();
212 draft.messages.push(BriefMessage::Text {
213 role: Role::User,
214 content: "hi".into(),
215 source: SourceId::new("user_message"),
216 });
217 let ctx = BriefContext::new(TokenBudget::default());
218
219 let v = SecretScrubber.review(&mut draft, &ctx).await.expect("ok");
220 assert_eq!(v, GovernanceVerdict::Allow);
221 }
222
223 struct AlwaysReject;
226
227 #[async_trait]
228 impl Governance for AlwaysReject {
229 async fn review(
230 &self,
231 _draft: &mut Brief,
232 _ctx: &BriefContext,
233 ) -> Result<GovernanceVerdict, GovernanceError> {
234 Ok(GovernanceVerdict::Rejected("locked down".into()))
235 }
236 }
237
238 #[tokio::test]
239 async fn rejected_verdict_carries_reason() {
240 let mut draft = empty_draft();
241 let ctx = BriefContext::new(TokenBudget::default());
242 let v = AlwaysReject.review(&mut draft, &ctx).await.expect("ok");
243 match &v {
244 GovernanceVerdict::Rejected(reason) => assert_eq!(reason, "locked down"),
245 other => panic!("expected Rejected, got {other:?}"),
246 }
247 assert!(v.is_rejected());
248 assert!(!v.is_redacted());
249 }
250
251 #[tokio::test]
252 async fn backend_error_propagates() {
253 struct Broken;
254 #[async_trait]
255 impl Governance for Broken {
256 async fn review(
257 &self,
258 _draft: &mut Brief,
259 _ctx: &BriefContext,
260 ) -> Result<GovernanceVerdict, GovernanceError> {
261 Err(GovernanceError::Backend("policy db unreachable".into()))
262 }
263 }
264 let mut draft = empty_draft();
265 let ctx = BriefContext::new(TokenBudget::default());
266 let err = Broken.review(&mut draft, &ctx).await.expect_err("err");
267 match err {
268 GovernanceError::Backend(msg) => assert!(msg.contains("policy db")),
269 other => panic!("expected Backend, got {other:?}"),
270 }
271 }
272
273 #[test]
274 fn verdict_helpers() {
275 assert!(!GovernanceVerdict::Allow.is_redacted());
276 assert!(!GovernanceVerdict::Allow.is_rejected());
277 assert!(GovernanceVerdict::Redacted(Vec::new()).is_redacted());
278 assert!(GovernanceVerdict::Rejected("x".into()).is_rejected());
279 }
280}