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 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 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 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 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}