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