Skip to main content

cel_brief/
governance.rs

1//! [`Governance`] trait + [`GovernanceVerdict`] + [`NoOpGovernance`] default.
2//!
3//! After a [`crate::types::Brief`] draft is
4//! assembled and budget-pruned, the builder hands the draft to
5//! [`Governance::review`]. The hook decides one of three things:
6//!
7//! - **Allow** — let the brief through unchanged.
8//! - **Redacted** — the hook mutated `redactable` content; the returned
9//!   [`crate::receipt::RedactionRecord`]s describe what changed and which
10//!   rule did it, and the builder forwards them into the
11//!   [`crate::receipt::BriefReceipt`].
12//! - **Rejected** — the brief cannot go to the model; the builder returns
13//!   [`crate::error::BriefError::Rejected`] to the caller.
14//!
15//! `cel-brief` does **not** ship a rules implementation. The default
16//! [`NoOpGovernance`] always returns `Allow`. Concrete governance can live in a
17//! downstream runtime, service, or application, keeping this crate free of
18//! runtime-specific dependencies.
19
20use async_trait::async_trait;
21use thiserror::Error;
22
23use crate::receipt::RedactionRecord;
24use crate::types::{Brief, BriefContext};
25
26/// Verdict returned by [`Governance::review`].
27///
28/// Mutating variants (`Redacted`) implicitly trust that the hook has
29/// already updated the draft brief in place; the receipt entries are the
30/// audit trail, not the rewrite log.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GovernanceVerdict {
33    /// The brief is fine as-is.
34    Allow,
35    /// The hook mutated redactable content in the brief. The records
36    /// describe which sources were touched and why. The builder copies
37    /// these into [`crate::receipt::BriefReceipt::redactions`].
38    Redacted(Vec<RedactionRecord>),
39    /// The brief violates policy and must not reach the model. The string
40    /// becomes the error message in [`crate::error::BriefError::Rejected`].
41    Rejected(String),
42}
43
44impl GovernanceVerdict {
45    /// True if this verdict carries any [`RedactionRecord`]s.
46    pub fn is_redacted(&self) -> bool {
47        matches!(self, GovernanceVerdict::Redacted(_))
48    }
49
50    /// True if this verdict represents a rejection.
51    pub fn is_rejected(&self) -> bool {
52        matches!(self, GovernanceVerdict::Rejected(_))
53    }
54}
55
56/// Error returned by [`Governance::review`].
57///
58/// Use [`Self::Backend`] when the rules engine itself fails (database
59/// down, rule compile error) — that's distinct from a clean `Rejected`
60/// verdict. Bubble up via [`crate::error::BriefError`] at the builder
61/// layer.
62#[derive(Debug, Error)]
63pub enum GovernanceError {
64    /// Rules engine backend failed.
65    #[error("governance backend error: {0}")]
66    Backend(String),
67
68    /// Catch-all.
69    #[error("governance error: {0}")]
70    Other(String),
71}
72
73/// The pluggable per-turn governance hook.
74///
75/// One [`Governance`] implementation per [`crate::builder::BriefBuilder`].
76/// Implementations should:
77/// - Be cheap. The hook runs on every turn.
78/// - Only rewrite [`crate::source::Contribution`]s whose `redactable` flag
79///   the source set to `true`. Non-redactable content (system prompt,
80///   user message) should only ever trigger [`GovernanceVerdict::Allow`]
81///   or [`GovernanceVerdict::Rejected`].
82/// - Be deterministic given the same `(draft, ctx)` so receipts are
83///   reproducible.
84#[async_trait]
85pub trait Governance: Send + Sync {
86    /// Review (and optionally mutate) the draft brief.
87    async fn review(
88        &self,
89        draft: &mut Brief,
90        ctx: &BriefContext,
91    ) -> Result<GovernanceVerdict, GovernanceError>;
92}
93
94/// The default [`Governance`] — always returns
95/// [`GovernanceVerdict::Allow`] without inspecting the draft.
96///
97/// Use this when you've already decided you don't need governance, or
98/// when you're prototyping. Production callers should plug in a real
99/// implementation backed by their own policy/rules engine.
100#[derive(Debug, Default, Clone, Copy)]
101pub struct NoOpGovernance;
102
103impl NoOpGovernance {
104    /// Construct a new [`NoOpGovernance`].
105    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    /// Fixture: replaces any User text message containing "secret" with
145    /// "[REDACTED]" and records one [`RedactionRecord`] per rewrite.
146    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    /// Fixture: rejects unconditionally with a fixed reason string. Used to
223    /// exercise the `Rejected` path's helpers.
224    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}