1use crate::cli::CliOutput;
59use crate::{db, models};
60use anyhow::Result;
61use models::{GovernanceDecision, GovernedAction};
62use rusqlite::Connection;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum GovernanceOutcome {
68 Allow,
70 Pending,
73 Deny,
76}
77
78#[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 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 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 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 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 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}