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