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            inherit: true,
237        };
238        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
239        let conn = db::open(&db_path).unwrap();
240        let payload = serde_json::json!({"title": "t"});
241        let outcome = {
242            let mut out = env.output();
243            enforce(
244                &conn,
245                GovernedAction::Store,
246                "gov-ns",
247                "bob",
248                None,
249                None,
250                &payload,
251                false,
252                &mut out,
253            )
254            .unwrap()
255        };
256        assert_eq!(outcome, GovernanceOutcome::Pending);
257        let stdout = env.stdout_str();
258        assert!(stdout.contains("queued for approval"), "got: {stdout}");
259        assert!(stdout.contains("pending_id="), "got: {stdout}");
260        assert!(stdout.contains("ns=gov-ns"), "got: {stdout}");
261    }
262
263    #[test]
264    fn test_governance_pending_writes_pending_status_json() {
265        let mut env = TestEnv::fresh();
266        let db_path = env.db_path.clone();
267        let policy = GovernancePolicy {
268            write: GovernanceLevel::Any,
269            promote: GovernanceLevel::Any,
270            delete: GovernanceLevel::Approve,
271            approver: ApproverType::Human,
272            inherit: true,
273        };
274        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
275        let conn = db::open(&db_path).unwrap();
276        let payload = serde_json::json!({});
277        let outcome = {
278            let mut out = env.output();
279            enforce(
280                &conn,
281                GovernedAction::Delete,
282                "gov-ns",
283                "bob",
284                Some("00000000-0000-0000-0000-000000000abc"),
285                Some("alice"),
286                &payload,
287                true,
288                &mut out,
289            )
290            .unwrap()
291        };
292        assert_eq!(outcome, GovernanceOutcome::Pending);
293        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
294        assert_eq!(v["status"].as_str().unwrap(), "pending");
295        assert_eq!(v["action"].as_str().unwrap(), "delete");
296        assert_eq!(v["namespace"].as_str().unwrap(), "gov-ns");
297        assert!(v["pending_id"].is_string());
298        assert_eq!(
299            v["memory_id"].as_str().unwrap(),
300            "00000000-0000-0000-0000-000000000abc"
301        );
302    }
303
304    #[test]
305    fn test_governance_deny_writes_reason_to_stderr() {
306        let mut env = TestEnv::fresh();
307        let db_path = env.db_path.clone();
308        let policy = GovernancePolicy {
309            write: GovernanceLevel::Any,
310            promote: GovernanceLevel::Any,
311            delete: GovernanceLevel::Owner,
312            approver: ApproverType::Human,
313            inherit: true,
314        };
315        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
316        let conn = db::open(&db_path).unwrap();
317        let payload = serde_json::json!({});
318        let outcome = {
319            let mut out = env.output();
320            enforce(
321                &conn,
322                GovernedAction::Delete,
323                "gov-ns",
324                "bob",
325                Some("00000000-0000-0000-0000-000000000def"),
326                Some("alice"),
327                &payload,
328                false,
329                &mut out,
330            )
331            .unwrap()
332        };
333        assert_eq!(outcome, GovernanceOutcome::Deny);
334        let stderr = env.stderr_str();
335        assert!(
336            stderr.contains("delete denied by governance"),
337            "got: {stderr}"
338        );
339        assert!(stderr.contains("not the owner"), "got: {stderr}");
340        // No stdout for Deny.
341        assert!(env.stdout_str().is_empty());
342    }
343
344    #[test]
345    fn test_governance_deny_returns_deny_outcome() {
346        let mut env = TestEnv::fresh();
347        let db_path = env.db_path.clone();
348        let policy = GovernancePolicy {
349            write: GovernanceLevel::Registered,
350            promote: GovernanceLevel::Any,
351            delete: GovernanceLevel::Owner,
352            approver: ApproverType::Human,
353            inherit: true,
354        };
355        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
356        let conn = db::open(&db_path).unwrap();
357        let payload = serde_json::json!({});
358        let outcome = {
359            let mut out = env.output();
360            enforce(
361                &conn,
362                GovernedAction::Store,
363                "gov-ns",
364                "unregistered-caller",
365                None,
366                None,
367                &payload,
368                false,
369                &mut out,
370            )
371            .unwrap()
372        };
373        assert_eq!(outcome, GovernanceOutcome::Deny);
374        assert!(env.stderr_str().contains("not a registered agent"));
375    }
376
377    #[test]
378    fn test_governance_payload_serializes_correctly() {
379        // The payload arg is forwarded into queue_pending_action so a
380        // peer-side approver can replay the original request. Sanity
381        // check: the exact bytes we passed in are stored in the
382        // pending_actions row.
383        let mut env = TestEnv::fresh();
384        let db_path = env.db_path.clone();
385        let policy = GovernancePolicy {
386            write: GovernanceLevel::Approve,
387            promote: GovernanceLevel::Any,
388            delete: GovernanceLevel::Owner,
389            approver: ApproverType::Human,
390            inherit: true,
391        };
392        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
393        let conn = db::open(&db_path).unwrap();
394        let payload = serde_json::json!({"title": "hello", "priority": 7});
395        let _ = {
396            let mut out = env.output();
397            enforce(
398                &conn,
399                GovernedAction::Store,
400                "gov-ns",
401                "carol",
402                None,
403                None,
404                &payload,
405                true,
406                &mut out,
407            )
408            .unwrap()
409        };
410        // Locate the row we just queued and verify the payload JSON
411        // round-trips byte-for-byte (modulo serialization order).
412        let stored_payload: String = conn
413            .query_row(
414                "SELECT payload FROM pending_actions WHERE namespace = 'gov-ns' AND requested_by = 'carol' ORDER BY requested_at DESC LIMIT 1",
415                [],
416                |r| r.get(0),
417            )
418            .unwrap();
419        let v: serde_json::Value = serde_json::from_str(&stored_payload).unwrap();
420        assert_eq!(v["title"].as_str().unwrap(), "hello");
421        assert_eq!(v["priority"].as_u64().unwrap(), 7);
422    }
423}