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::models::field_names;
60use crate::{db, models};
61use anyhow::Result;
62use models::{GovernanceDecision, GovernedAction};
63use rusqlite::Connection;
64
65/// Outcome surfaced to the caller. Mirrors [`GovernanceDecision`] but
66/// erases the inner strings — the helper has already printed them.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum GovernanceOutcome {
69    /// Allow; caller proceeds with the action.
70    Allow,
71    /// Pending; helper printed the queued-for-approval message. Caller
72    /// usually returns `Ok(())` immediately.
73    Pending,
74    /// Deny; helper printed the reason to stderr. Caller is expected to
75    /// exit non-zero.
76    Deny,
77}
78
79/// Run `db::enforce_governance` and route the print-side of Pending/Deny
80/// through `out`. Returns a [`GovernanceOutcome`] so the caller can
81/// decide whether to continue, return, or exit.
82///
83/// Does **not** call `std::process::exit` on Deny — the exit stays at
84/// the call-site so this module is testable in-process.
85#[allow(clippy::too_many_arguments)]
86pub fn enforce(
87    conn: &Connection,
88    action: GovernedAction,
89    namespace: &str,
90    caller_agent_id: &str,
91    memory_id: Option<&str>,
92    memory_owner: Option<&str>,
93    payload: &serde_json::Value,
94    json_out: bool,
95    out: &mut CliOutput<'_>,
96) -> Result<GovernanceOutcome> {
97    match db::enforce_governance(
98        conn,
99        action,
100        namespace,
101        caller_agent_id,
102        memory_id,
103        memory_owner,
104        payload,
105    )? {
106        GovernanceDecision::Allow => Ok(GovernanceOutcome::Allow),
107        GovernanceDecision::Deny(refusal) => {
108            writeln!(
109                out.stderr,
110                "{} denied by governance: {reason}",
111                action.as_str(),
112                reason = refusal.reason,
113            )?;
114            Ok(GovernanceOutcome::Deny)
115        }
116        GovernanceDecision::Pending(pending_id) => {
117            // v0.7.0 K4 — the CLI path does NOT dispatch the
118            // `approval_requested` webhook event today. The HTTP and
119            // MCP enforce sites (handlers.rs, mcp.rs) do; the CLI is
120            // used for ops / scripted governance and the typical
121            // Approval-API consumer is the K10 HTTP+SSE handler, which
122            // mostly sees rows minted by HTTP/MCP traffic. Wiring the
123            // CLI path requires threading `db_path` through this
124            // function and its many callers — out of scope for K4.
125            // Tracked for a follow-up; the K10 surface remains correct
126            // because HTTP/MCP rows DO fire the event.
127            if json_out {
128                let mut payload_obj = serde_json::json!({
129                    "status": "pending",
130                    (field_names::PENDING_ID): pending_id,
131                    "reason": crate::errors::msg::GOVERNANCE_REQUIRES_APPROVAL,
132                    "action": action.as_str(),
133                    "namespace": namespace,
134                });
135                if let Some(mid) = memory_id
136                    && let Some(obj) = payload_obj.as_object_mut()
137                {
138                    obj.insert(
139                        "memory_id".to_string(),
140                        serde_json::Value::String(mid.to_string()),
141                    );
142                }
143                writeln!(out.stdout, "{payload_obj}")?;
144            } else if let Some(mid) = memory_id {
145                writeln!(
146                    out.stdout,
147                    "{} queued for approval: pending_id={pending_id} id={mid}",
148                    action.as_str()
149                )?;
150            } else {
151                writeln!(
152                    out.stdout,
153                    "{} queued for approval: pending_id={pending_id} ns={namespace}",
154                    action.as_str()
155                )?;
156            }
157            Ok(GovernanceOutcome::Pending)
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::cli::test_utils::{TestEnv, seed_memory};
166    use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
167
168    /// v0.7.0 K3 — pin the gate to Enforce so this suite's
169    /// historical Pending/Deny outcome assertions still drive the
170    /// strict path. Holds the central gate-mode Mutex from
171    /// [`crate::config::lock_permissions_mode_for_test`] so parallel
172    /// tests in other modules cannot race the atomic.
173    fn pin_governance_enforce_for_test() -> std::sync::MutexGuard<'static, ()> {
174        let guard = crate::config::lock_permissions_mode_for_test();
175        crate::config::override_active_permissions_mode_for_test(
176            crate::config::PermissionsMode::Enforce,
177        );
178        guard
179    }
180
181    /// Seed a namespace standard with the supplied governance policy. The
182    /// standard memory is inserted in `_standards` and pinned via
183    /// `set_namespace_standard`.
184    fn seed_governance_policy(
185        db_path: &std::path::Path,
186        namespace: &str,
187        policy: GovernancePolicy,
188        owner_agent_id: &str,
189    ) {
190        let conn = db::open(db_path).unwrap();
191        let now = chrono::Utc::now().to_rfc3339();
192        let mut metadata = models::default_metadata();
193        if let Some(obj) = metadata.as_object_mut() {
194            obj.insert(
195                "agent_id".to_string(),
196                serde_json::Value::String(owner_agent_id.to_string()),
197            );
198            obj.insert(
199                "governance".to_string(),
200                serde_json::to_value(&policy).unwrap(),
201            );
202        }
203        let standard = models::Memory {
204            id: uuid::Uuid::new_v4().to_string(),
205            tier: models::Tier::Long,
206            namespace: format!("_standards-{namespace}"),
207            title: format!("standard for {namespace}"),
208            content: "policy".to_string(),
209            tags: vec![],
210            priority: 9,
211            confidence: 1.0,
212            source: "test".to_string(),
213            access_count: 0,
214            created_at: now.clone(),
215            updated_at: now,
216            last_accessed_at: None,
217            expires_at: None,
218            metadata,
219            reflection_depth: 0,
220            memory_kind: crate::models::MemoryKind::Observation,
221            entity_id: None,
222            persona_version: None,
223            citations: Vec::new(),
224            source_uri: None,
225            source_span: None,
226            confidence_source: crate::models::ConfidenceSource::CallerProvided,
227            confidence_signals: None,
228            confidence_decayed_at: None,
229            version: 1,
230        };
231        let standard_id = db::insert(&conn, &standard).unwrap();
232        db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
233    }
234
235    #[test]
236    fn test_governance_allow_returns_allow_no_output() {
237        let mut env = TestEnv::fresh();
238        let db_path = env.db_path.clone();
239        // Touch DB to materialize schema
240        let _ = seed_memory(&db_path, "ns", "x", "y");
241        let conn = db::open(&db_path).unwrap();
242        let payload = serde_json::json!({});
243        let outcome = {
244            let mut out = env.output();
245            enforce(
246                &conn,
247                GovernedAction::Store,
248                "ns-without-policy",
249                "alice",
250                None,
251                None,
252                &payload,
253                false,
254                &mut out,
255            )
256            .unwrap()
257        };
258        assert_eq!(outcome, GovernanceOutcome::Allow);
259        assert!(env.stdout_str().is_empty());
260        assert!(env.stderr_str().is_empty());
261    }
262
263    #[test]
264    fn test_governance_pending_writes_pending_status_text() {
265        let _gate = pin_governance_enforce_for_test();
266        let mut env = TestEnv::fresh();
267        let db_path = env.db_path.clone();
268        let policy = GovernancePolicy {
269            core: CorePolicy {
270                write: GovernanceLevel::Approve,
271                promote: GovernanceLevel::Any,
272                delete: GovernanceLevel::Owner,
273                approver: ApproverType::Human,
274                inherit: true,
275                max_reflection_depth: None,
276            },
277            ..Default::default()
278        };
279        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
280        let conn = db::open(&db_path).unwrap();
281        let payload = serde_json::json!({"title": "t"});
282        let outcome = {
283            let mut out = env.output();
284            enforce(
285                &conn,
286                GovernedAction::Store,
287                "gov-ns",
288                "bob",
289                None,
290                None,
291                &payload,
292                false,
293                &mut out,
294            )
295            .unwrap()
296        };
297        assert_eq!(outcome, GovernanceOutcome::Pending);
298        let stdout = env.stdout_str();
299        assert!(stdout.contains("queued for approval"), "got: {stdout}");
300        assert!(stdout.contains("pending_id="), "got: {stdout}");
301        assert!(stdout.contains("ns=gov-ns"), "got: {stdout}");
302    }
303
304    #[test]
305    fn test_governance_pending_writes_pending_status_json() {
306        let _gate = pin_governance_enforce_for_test();
307        let mut env = TestEnv::fresh();
308        let db_path = env.db_path.clone();
309        let policy = GovernancePolicy {
310            core: CorePolicy {
311                write: GovernanceLevel::Any,
312                promote: GovernanceLevel::Any,
313                delete: GovernanceLevel::Approve,
314                approver: ApproverType::Human,
315                inherit: true,
316                max_reflection_depth: None,
317            },
318            ..Default::default()
319        };
320        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
321        let conn = db::open(&db_path).unwrap();
322        let payload = serde_json::json!({});
323        let outcome = {
324            let mut out = env.output();
325            enforce(
326                &conn,
327                GovernedAction::Delete,
328                "gov-ns",
329                "bob",
330                Some("00000000-0000-0000-0000-000000000abc"),
331                Some("alice"),
332                &payload,
333                true,
334                &mut out,
335            )
336            .unwrap()
337        };
338        assert_eq!(outcome, GovernanceOutcome::Pending);
339        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
340        assert_eq!(v["status"].as_str().unwrap(), "pending");
341        assert_eq!(v["action"].as_str().unwrap(), "delete");
342        assert_eq!(v["namespace"].as_str().unwrap(), "gov-ns");
343        assert!(v["pending_id"].is_string());
344        assert_eq!(
345            v["memory_id"].as_str().unwrap(),
346            "00000000-0000-0000-0000-000000000abc"
347        );
348    }
349
350    #[test]
351    fn test_governance_deny_writes_reason_to_stderr() {
352        let _gate = pin_governance_enforce_for_test();
353        let mut env = TestEnv::fresh();
354        let db_path = env.db_path.clone();
355        let policy = GovernancePolicy {
356            core: CorePolicy {
357                write: GovernanceLevel::Any,
358                promote: GovernanceLevel::Any,
359                delete: GovernanceLevel::Owner,
360                approver: ApproverType::Human,
361                inherit: true,
362                max_reflection_depth: None,
363            },
364            ..Default::default()
365        };
366        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
367        let conn = db::open(&db_path).unwrap();
368        let payload = serde_json::json!({});
369        let outcome = {
370            let mut out = env.output();
371            enforce(
372                &conn,
373                GovernedAction::Delete,
374                "gov-ns",
375                "bob",
376                Some("00000000-0000-0000-0000-000000000def"),
377                Some("alice"),
378                &payload,
379                false,
380                &mut out,
381            )
382            .unwrap()
383        };
384        assert_eq!(outcome, GovernanceOutcome::Deny);
385        let stderr = env.stderr_str();
386        assert!(
387            stderr.contains("delete denied by governance"),
388            "got: {stderr}"
389        );
390        assert!(stderr.contains("not the owner"), "got: {stderr}");
391        // No stdout for Deny.
392        assert!(env.stdout_str().is_empty());
393    }
394
395    #[test]
396    fn test_governance_deny_returns_deny_outcome() {
397        let _gate = pin_governance_enforce_for_test();
398        let mut env = TestEnv::fresh();
399        let db_path = env.db_path.clone();
400        let policy = GovernancePolicy {
401            core: CorePolicy {
402                write: GovernanceLevel::Registered,
403                promote: GovernanceLevel::Any,
404                delete: GovernanceLevel::Owner,
405                approver: ApproverType::Human,
406                inherit: true,
407                max_reflection_depth: None,
408            },
409            ..Default::default()
410        };
411        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
412        let conn = db::open(&db_path).unwrap();
413        let payload = serde_json::json!({});
414        let outcome = {
415            let mut out = env.output();
416            enforce(
417                &conn,
418                GovernedAction::Store,
419                "gov-ns",
420                "unregistered-caller",
421                None,
422                None,
423                &payload,
424                false,
425                &mut out,
426            )
427            .unwrap()
428        };
429        assert_eq!(outcome, GovernanceOutcome::Deny);
430        assert!(env.stderr_str().contains("not a registered agent"));
431    }
432
433    #[test]
434    fn test_governance_payload_serializes_correctly() {
435        let _gate = pin_governance_enforce_for_test();
436        // The payload arg is forwarded into queue_pending_action so a
437        // peer-side approver can replay the original request. Sanity
438        // check: the exact bytes we passed in are stored in the
439        // pending_actions row.
440        let mut env = TestEnv::fresh();
441        let db_path = env.db_path.clone();
442        let policy = GovernancePolicy {
443            core: CorePolicy {
444                write: GovernanceLevel::Approve,
445                promote: GovernanceLevel::Any,
446                delete: GovernanceLevel::Owner,
447                approver: ApproverType::Human,
448                inherit: true,
449                max_reflection_depth: None,
450            },
451            ..Default::default()
452        };
453        seed_governance_policy(&db_path, "gov-ns", policy, "alice");
454        let conn = db::open(&db_path).unwrap();
455        let payload = serde_json::json!({"title": "hello", "priority": 7});
456        let _ = {
457            let mut out = env.output();
458            enforce(
459                &conn,
460                GovernedAction::Store,
461                "gov-ns",
462                "carol",
463                None,
464                None,
465                &payload,
466                true,
467                &mut out,
468            )
469            .unwrap()
470        };
471        // Locate the row we just queued and verify the payload JSON
472        // round-trips byte-for-byte (modulo serialization order).
473        let stored_payload: String = conn
474            .query_row(
475                "SELECT payload FROM pending_actions WHERE namespace = 'gov-ns' AND requested_by = 'carol' ORDER BY requested_at DESC LIMIT 1",
476                [],
477                |r| r.get(0),
478            )
479            .unwrap();
480        let v: serde_json::Value = serde_json::from_str(&stored_payload).unwrap();
481        assert_eq!(v["title"].as_str().unwrap(), "hello");
482        assert_eq!(v["priority"].as_u64().unwrap(), 7);
483    }
484}