Skip to main content

ai_memory/cli/
governance.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared governance enforcement helper. Wave 5b (v0.6.3) lifted the
5//! `match db::enforce_governance(...)` block out of every governed
6//! `cmd_*` so the printing-side of governance decisions has a single
7//! testable home and the call-sites collapse to a 3-arm match on the
8//! returned [`GovernanceOutcome`].
9//!
10//! ## Why a separate module
11//!
12//! Each governed command (`store`, `delete`, `promote`) used to repeat
13//! the same 25-line block:
14//!
15//! ```ignore
16//! match db::enforce_governance(...)? {
17//!     Allow => {}
18//!     Deny(r) => { eprintln!(...); std::process::exit(1); }
19//!     Pending(id) => { /* print + return */ }
20//! }
21//! ```
22//!
23//! That made the printing format (text vs JSON, the literal field names)
24//! invisible to unit tests because they couldn't run a process-exit
25//! branch in-process. Lifting it here lets us:
26//!
27//! 1. Test the **printing side** of Pending and Deny without crashing
28//!    the test runner (the helper writes the message and returns; the
29//!    caller decides whether to exit).
30//! 2. Keep one canonical JSON shape for `pending_actions` responses.
31//!
32//! ## Public surface
33//!
34//! ```ignore
35//! pub enum GovernanceOutcome { Allow, Pending, Deny }
36//!
37//! pub fn enforce(
38//!     conn: &Connection,
39//!     action: GovernedAction,
40//!     namespace: &str,
41//!     caller_agent_id: &str,
42//!     memory_id: Option<&str>,
43//!     memory_owner: Option<&str>,
44//!     payload: &serde_json::Value,
45//!     json_out: bool,
46//!     out: &mut CliOutput<'_>,
47//! ) -> Result<GovernanceOutcome>;
48//! ```
49//!
50//! - `Allow`: silent, caller proceeds.
51//! - `Pending`: helper writes a `pending_actions` record (text or JSON
52//!   shape, `out.stdout`) and returns `Pending`. Caller usually returns
53//!   `Ok(())` immediately.
54//! - `Deny`: helper writes the deny reason to `out.stderr` and returns
55//!   `Deny`. Caller is expected to `std::process::exit(1)` after the
56//!   helper returns — exiting stays inline so this module is testable.
57
58use crate::cli::CliOutput;
59use crate::{db, models};
60use anyhow::Result;
61use models::{GovernanceDecision, GovernedAction};
62use rusqlite::Connection;
63
64/// Outcome surfaced to the caller. Mirrors [`GovernanceDecision`] but
65/// erases the inner strings — the helper has already printed them.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum GovernanceOutcome {
68    /// Allow; caller proceeds with the action.
69    Allow,
70    /// Pending; helper printed the queued-for-approval message. Caller
71    /// usually returns `Ok(())` immediately.
72    Pending,
73    /// Deny; helper printed the reason to stderr. Caller is expected to
74    /// exit non-zero.
75    Deny,
76}
77
78/// Run `db::enforce_governance` and route the print-side of Pending/Deny
79/// through `out`. Returns a [`GovernanceOutcome`] so the caller can
80/// decide whether to continue, return, or exit.
81///
82/// Does **not** call `std::process::exit` on Deny — the exit stays at
83/// the call-site so this module is testable in-process.
84#[allow(clippy::too_many_arguments)]
85pub fn enforce(
86    conn: &Connection,
87    action: GovernedAction,
88    namespace: &str,
89    caller_agent_id: &str,
90    memory_id: Option<&str>,
91    memory_owner: Option<&str>,
92    payload: &serde_json::Value,
93    json_out: bool,
94    out: &mut CliOutput<'_>,
95) -> Result<GovernanceOutcome> {
96    match db::enforce_governance(
97        conn,
98        action,
99        namespace,
100        caller_agent_id,
101        memory_id,
102        memory_owner,
103        payload,
104    )? {
105        GovernanceDecision::Allow => Ok(GovernanceOutcome::Allow),
106        GovernanceDecision::Deny(reason) => {
107            writeln!(
108                out.stderr,
109                "{} denied by governance: {reason}",
110                action.as_str()
111            )?;
112            Ok(GovernanceOutcome::Deny)
113        }
114        GovernanceDecision::Pending(pending_id) => {
115            if json_out {
116                let mut payload_obj = serde_json::json!({
117                    "status": "pending",
118                    "pending_id": pending_id,
119                    "reason": "governance requires approval",
120                    "action": action.as_str(),
121                    "namespace": namespace,
122                });
123                if let Some(mid) = memory_id
124                    && let Some(obj) = payload_obj.as_object_mut()
125                {
126                    obj.insert(
127                        "memory_id".to_string(),
128                        serde_json::Value::String(mid.to_string()),
129                    );
130                }
131                writeln!(out.stdout, "{payload_obj}")?;
132            } else if let Some(mid) = memory_id {
133                writeln!(
134                    out.stdout,
135                    "{} queued for approval: pending_id={pending_id} id={mid}",
136                    action.as_str()
137                )?;
138            } else {
139                writeln!(
140                    out.stdout,
141                    "{} queued for approval: pending_id={pending_id} ns={namespace}",
142                    action.as_str()
143                )?;
144            }
145            Ok(GovernanceOutcome::Pending)
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::cli::test_utils::{TestEnv, seed_memory};
154    use crate::models::{ApproverType, GovernanceLevel, GovernancePolicy};
155
156    /// Seed a namespace standard with the supplied governance policy. The
157    /// standard memory is inserted in `_standards` and pinned via
158    /// `set_namespace_standard`.
159    fn seed_governance_policy(
160        db_path: &std::path::Path,
161        namespace: &str,
162        policy: GovernancePolicy,
163        owner_agent_id: &str,
164    ) {
165        let conn = db::open(db_path).unwrap();
166        let now = chrono::Utc::now().to_rfc3339();
167        let mut metadata = models::default_metadata();
168        if let Some(obj) = metadata.as_object_mut() {
169            obj.insert(
170                "agent_id".to_string(),
171                serde_json::Value::String(owner_agent_id.to_string()),
172            );
173            obj.insert(
174                "governance".to_string(),
175                serde_json::to_value(&policy).unwrap(),
176            );
177        }
178        let standard = models::Memory {
179            id: uuid::Uuid::new_v4().to_string(),
180            tier: models::Tier::Long,
181            namespace: format!("_standards-{namespace}"),
182            title: format!("standard for {namespace}"),
183            content: "policy".to_string(),
184            tags: vec![],
185            priority: 9,
186            confidence: 1.0,
187            source: "test".to_string(),
188            access_count: 0,
189            created_at: now.clone(),
190            updated_at: now,
191            last_accessed_at: None,
192            expires_at: None,
193            metadata,
194        };
195        let standard_id = db::insert(&conn, &standard).unwrap();
196        db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
197    }
198
199    #[test]
200    fn test_governance_allow_returns_allow_no_output() {
201        let mut env = TestEnv::fresh();
202        let db_path = env.db_path.clone();
203        // Touch DB to materialize schema
204        let _ = seed_memory(&db_path, "ns", "x", "y");
205        let conn = db::open(&db_path).unwrap();
206        let payload = serde_json::json!({});
207        let outcome = {
208            let mut out = env.output();
209            enforce(
210                &conn,
211                GovernedAction::Store,
212                "ns-without-policy",
213                "alice",
214                None,
215                None,
216                &payload,
217                false,
218                &mut out,
219            )
220            .unwrap()
221        };
222        assert_eq!(outcome, GovernanceOutcome::Allow);
223        assert!(env.stdout_str().is_empty());
224        assert!(env.stderr_str().is_empty());
225    }
226
227    #[test]
228    fn test_governance_pending_writes_pending_status_text() {
229        let mut env = TestEnv::fresh();
230        let db_path = env.db_path.clone();
231        let policy = GovernancePolicy {
232            write: GovernanceLevel::Approve,
233            promote: GovernanceLevel::Any,
234            delete: GovernanceLevel::Owner,
235            approver: ApproverType::Human,
236        };
237        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
238        let conn = db::open(&db_path).unwrap();
239        let payload = serde_json::json!({"title": "t"});
240        let outcome = {
241            let mut out = env.output();
242            enforce(
243                &conn,
244                GovernedAction::Store,
245                "gov-ns",
246                "bob",
247                None,
248                None,
249                &payload,
250                false,
251                &mut out,
252            )
253            .unwrap()
254        };
255        assert_eq!(outcome, GovernanceOutcome::Pending);
256        let stdout = env.stdout_str();
257        assert!(stdout.contains("queued for approval"), "got: {stdout}");
258        assert!(stdout.contains("pending_id="), "got: {stdout}");
259        assert!(stdout.contains("ns=gov-ns"), "got: {stdout}");
260    }
261
262    #[test]
263    fn test_governance_pending_writes_pending_status_json() {
264        let mut env = TestEnv::fresh();
265        let db_path = env.db_path.clone();
266        let policy = GovernancePolicy {
267            write: GovernanceLevel::Any,
268            promote: GovernanceLevel::Any,
269            delete: GovernanceLevel::Approve,
270            approver: ApproverType::Human,
271        };
272        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
273        let conn = db::open(&db_path).unwrap();
274        let payload = serde_json::json!({});
275        let outcome = {
276            let mut out = env.output();
277            enforce(
278                &conn,
279                GovernedAction::Delete,
280                "gov-ns",
281                "bob",
282                Some("00000000-0000-0000-0000-000000000abc"),
283                Some("alice"),
284                &payload,
285                true,
286                &mut out,
287            )
288            .unwrap()
289        };
290        assert_eq!(outcome, GovernanceOutcome::Pending);
291        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
292        assert_eq!(v["status"].as_str().unwrap(), "pending");
293        assert_eq!(v["action"].as_str().unwrap(), "delete");
294        assert_eq!(v["namespace"].as_str().unwrap(), "gov-ns");
295        assert!(v["pending_id"].is_string());
296        assert_eq!(
297            v["memory_id"].as_str().unwrap(),
298            "00000000-0000-0000-0000-000000000abc"
299        );
300    }
301
302    #[test]
303    fn test_governance_deny_writes_reason_to_stderr() {
304        let mut env = TestEnv::fresh();
305        let db_path = env.db_path.clone();
306        let policy = GovernancePolicy {
307            write: GovernanceLevel::Any,
308            promote: GovernanceLevel::Any,
309            delete: GovernanceLevel::Owner,
310            approver: ApproverType::Human,
311        };
312        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
313        let conn = db::open(&db_path).unwrap();
314        let payload = serde_json::json!({});
315        let outcome = {
316            let mut out = env.output();
317            enforce(
318                &conn,
319                GovernedAction::Delete,
320                "gov-ns",
321                "bob",
322                Some("00000000-0000-0000-0000-000000000def"),
323                Some("alice"),
324                &payload,
325                false,
326                &mut out,
327            )
328            .unwrap()
329        };
330        assert_eq!(outcome, GovernanceOutcome::Deny);
331        let stderr = env.stderr_str();
332        assert!(
333            stderr.contains("delete denied by governance"),
334            "got: {stderr}"
335        );
336        assert!(stderr.contains("not the owner"), "got: {stderr}");
337        // No stdout for Deny.
338        assert!(env.stdout_str().is_empty());
339    }
340
341    #[test]
342    fn test_governance_deny_returns_deny_outcome() {
343        let mut env = TestEnv::fresh();
344        let db_path = env.db_path.clone();
345        let policy = GovernancePolicy {
346            write: GovernanceLevel::Registered,
347            promote: GovernanceLevel::Any,
348            delete: GovernanceLevel::Owner,
349            approver: ApproverType::Human,
350        };
351        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
352        let conn = db::open(&db_path).unwrap();
353        let payload = serde_json::json!({});
354        let outcome = {
355            let mut out = env.output();
356            enforce(
357                &conn,
358                GovernedAction::Store,
359                "gov-ns",
360                "unregistered-caller",
361                None,
362                None,
363                &payload,
364                false,
365                &mut out,
366            )
367            .unwrap()
368        };
369        assert_eq!(outcome, GovernanceOutcome::Deny);
370        assert!(env.stderr_str().contains("not a registered agent"));
371    }
372
373    #[test]
374    fn test_governance_payload_serializes_correctly() {
375        // The payload arg is forwarded into queue_pending_action so a
376        // peer-side approver can replay the original request. Sanity
377        // check: the exact bytes we passed in are stored in the
378        // pending_actions row.
379        let mut env = TestEnv::fresh();
380        let db_path = env.db_path.clone();
381        let policy = GovernancePolicy {
382            write: GovernanceLevel::Approve,
383            promote: GovernanceLevel::Any,
384            delete: GovernanceLevel::Owner,
385            approver: ApproverType::Human,
386        };
387        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
388        let conn = db::open(&db_path).unwrap();
389        let payload = serde_json::json!({"title": "hello", "priority": 7});
390        let _ = {
391            let mut out = env.output();
392            enforce(
393                &conn,
394                GovernedAction::Store,
395                "gov-ns",
396                "carol",
397                None,
398                None,
399                &payload,
400                true,
401                &mut out,
402            )
403            .unwrap()
404        };
405        // Locate the row we just queued and verify the payload JSON
406        // round-trips byte-for-byte (modulo serialization order).
407        let stored_payload: String = conn
408            .query_row(
409                "SELECT payload FROM pending_actions WHERE namespace = 'gov-ns' AND requested_by = 'carol' ORDER BY requested_at DESC LIMIT 1",
410                [],
411                |r| r.get(0),
412            )
413            .unwrap();
414        let v: serde_json::Value = serde_json::from_str(&stored_payload).unwrap();
415        assert_eq!(v["title"].as_str().unwrap(), "hello");
416        assert_eq!(v["priority"].as_u64().unwrap(), 7);
417    }
418}