1use crate::cli::CliOutput;
14use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
15use crate::cli::helpers::id_short;
16use crate::{db, identity, models, validate};
17use anyhow::Result;
18use clap::Args;
19use models::Tier;
20use std::path::Path;
21
22#[derive(Args)]
23pub struct PromoteArgs {
24 pub id: String,
25 #[arg(long)]
30 pub to_namespace: Option<String>,
31 #[arg(long)]
36 pub target_tier: Option<String>,
37}
38
39#[allow(clippy::too_many_lines)]
41pub fn cmd_promote(
42 db_path: &Path,
43 args: &PromoteArgs,
44 json_out: bool,
45 cli_agent_id: Option<&str>,
46 out: &mut CliOutput<'_>,
47) -> Result<()> {
48 validate::validate_id(&args.id)?;
49 if let Some(ref to_ns) = args.to_namespace {
50 validate::validate_namespace(to_ns)?;
51 }
52 let conn = db::open(db_path)?;
53 let target = if let Some(m) = db::get(&conn, &args.id)? {
54 m
55 } else if let Some(m) = db::get_by_prefix(&conn, &args.id)? {
56 m
57 } else {
58 writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
59 std::process::exit(1);
60 };
61 let resolved_id = target.id.clone();
62
63 {
64 use models::GovernedAction;
65 let caller_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
66 let mem_owner = target
67 .metadata
68 .get("agent_id")
69 .and_then(|v| v.as_str())
70 .map(str::to_string);
71 let payload = serde_json::json!({
72 "id": resolved_id,
73 (crate::models::field_names::TO_NAMESPACE): args.to_namespace,
74 });
75 match enforce_governance(
76 &conn,
77 GovernedAction::Promote,
78 &target.namespace,
79 &caller_agent_id,
80 Some(&resolved_id),
81 mem_owner.as_deref(),
82 &payload,
83 json_out,
84 out,
85 )? {
86 GovernanceOutcome::Allow => {}
87 GovernanceOutcome::Deny => {
88 std::process::exit(1);
89 }
90 GovernanceOutcome::Pending => {
91 return Ok(());
92 }
93 }
94 }
95
96 if let Some(ref to_ns) = args.to_namespace {
97 let clone_id = db::promote_to_namespace(&conn, &resolved_id, to_ns)?;
98 if json_out {
99 writeln!(
100 out.stdout,
101 "{}",
102 serde_json::to_string(&serde_json::json!({
103 "promoted": true,
104 "mode": "vertical",
105 "source_id": resolved_id,
106 "clone_id": clone_id,
107 (crate::models::field_names::TO_NAMESPACE): to_ns,
108 }))?
109 )?;
110 } else {
111 writeln!(
112 out.stdout,
113 "promoted (vertical): {} → {} (clone: {})",
114 id_short(&resolved_id),
115 to_ns,
116 id_short(&clone_id),
117 )?;
118 }
119 return Ok(());
120 }
121
122 let landing = match args.target_tier.as_deref() {
125 None => Tier::Long,
126 Some("short") => anyhow::bail!(
127 "target_tier 'short' is not a valid promote target (would be a downgrade)"
128 ),
129 Some(other) => Tier::from_str(other).ok_or_else(|| {
130 anyhow::anyhow!("target_tier must be one of 'mid' or 'long' (got '{other}')")
131 })?,
132 };
133 let expires_arg: Option<&str> = match landing {
136 Tier::Long => Some(""),
137 Tier::Mid | Tier::Short => None,
138 };
139 let (found, _) = db::update(
140 &conn,
141 &resolved_id,
142 None,
143 None,
144 Some(&landing),
145 None,
146 None,
147 None,
148 None,
149 expires_arg,
150 None,
151 )?;
152 if !found {
153 writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
154 std::process::exit(1);
155 }
156 if json_out {
157 writeln!(
158 out.stdout,
159 "{}",
160 serde_json::json!({"promoted": true, "id": resolved_id, "tier": landing.as_str()})
161 )?;
162 } else {
163 writeln!(
164 out.stdout,
165 "promoted to {}: {resolved_id}",
166 landing.as_str()
167 )?;
168 }
169 Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::cli::test_utils::{TestEnv, seed_memory};
176
177 fn pin_governance_enforce_for_test() -> std::sync::MutexGuard<'static, ()> {
183 let guard = crate::config::lock_permissions_mode_for_test();
184 crate::config::override_active_permissions_mode_for_test(
185 crate::config::PermissionsMode::Enforce,
186 );
187 guard
188 }
189
190 fn promote_args(id: &str) -> PromoteArgs {
191 PromoteArgs {
192 id: id.to_string(),
193 to_namespace: None,
194 target_tier: None,
195 }
196 }
197
198 fn seed_governance_policy(
199 db_path: &Path,
200 namespace: &str,
201 promote_level: models::GovernanceLevel,
202 owner_agent_id: &str,
203 ) {
204 use models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
205 let policy = GovernancePolicy {
206 core: CorePolicy {
207 write: GovernanceLevel::Any,
208 promote: promote_level,
209 delete: GovernanceLevel::Owner,
210 approver: ApproverType::Human,
211 inherit: true,
212 max_reflection_depth: None,
213 },
214 ..Default::default()
215 };
216 let conn = db::open(db_path).unwrap();
217 let now = chrono::Utc::now().to_rfc3339();
218 let mut metadata = models::default_metadata();
219 if let Some(obj) = metadata.as_object_mut() {
220 obj.insert(
221 "agent_id".to_string(),
222 serde_json::Value::String(owner_agent_id.to_string()),
223 );
224 obj.insert(
225 "governance".to_string(),
226 serde_json::to_value(&policy).unwrap(),
227 );
228 }
229 let standard = models::Memory {
230 id: uuid::Uuid::new_v4().to_string(),
231 tier: Tier::Long,
232 namespace: format!("_standards-{namespace}"),
233 title: format!("standard for {namespace}"),
234 content: "policy".to_string(),
235 tags: vec![],
236 priority: 9,
237 confidence: 1.0,
238 source: "test".to_string(),
239 access_count: 0,
240 created_at: now.clone(),
241 updated_at: now,
242 last_accessed_at: None,
243 expires_at: None,
244 metadata,
245 reflection_depth: 0,
246 memory_kind: crate::models::MemoryKind::Observation,
247 entity_id: None,
248 persona_version: None,
249 citations: Vec::new(),
250 source_uri: None,
251 source_span: None,
252 confidence_source: crate::models::ConfidenceSource::CallerProvided,
253 confidence_signals: None,
254 confidence_decayed_at: None,
255 version: 1,
256 };
257 let standard_id = db::insert(&conn, &standard).unwrap();
258 db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
259 }
260
261 #[test]
262 fn test_promote_horizontal_to_long() {
263 let mut env = TestEnv::fresh();
264 let db = env.db_path.clone();
265 let id = seed_memory(&db, "ns", "tt", "cc");
266 let args = promote_args(&id);
267 {
268 let mut out = env.output();
269 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
270 }
271 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
272 assert_eq!(v["promoted"].as_bool().unwrap(), true);
273 assert_eq!(v["tier"].as_str().unwrap(), Tier::Long.as_str());
274 let conn = db::open(&db).unwrap();
275 let mem = db::get(&conn, &id).unwrap().unwrap();
276 assert_eq!(mem.tier, Tier::Long);
277 }
278
279 #[test]
280 fn test_promote_by_prefix() {
281 let mut env = TestEnv::fresh();
282 let db = env.db_path.clone();
283 let id = seed_memory(&db, "ns", "tt", "cc");
284 let prefix = id[..8].to_string();
285 let args = promote_args(&prefix);
286 {
287 let mut out = env.output();
288 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
289 }
290 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
291 assert_eq!(v["id"].as_str().unwrap(), id);
292 }
293
294 #[test]
295 fn test_promote_vertical_with_to_namespace() {
296 let mut env = TestEnv::fresh();
297 let db = env.db_path.clone();
298 let id = seed_memory(&db, "parent/child", "tt", "cc");
301 let mut args = promote_args(&id);
302 args.to_namespace = Some("parent".to_string());
303 {
304 let mut out = env.output();
305 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
306 }
307 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
308 assert_eq!(v["mode"].as_str().unwrap(), "vertical");
309 assert!(v["clone_id"].is_string());
310 assert_eq!(v["to_namespace"].as_str().unwrap(), "parent");
311 }
312
313 #[test]
314 fn test_promote_vertical_invalid_namespace_validation_error() {
315 let mut env = TestEnv::fresh();
316 let db = env.db_path.clone();
317 let id = seed_memory(&db, "ns", "tt", "cc");
318 let mut args = promote_args(&id);
319 args.to_namespace = Some("has spaces".to_string());
320 let mut out = env.output();
321 let res = cmd_promote(&db, &args, false, Some("test-agent"), &mut out);
322 assert!(res.is_err());
323 }
324
325 #[test]
326 fn test_promote_governance_pending() {
327 let _gate = pin_governance_enforce_for_test();
328 let mut env = TestEnv::fresh();
329 let db = env.db_path.clone();
330 let id = seed_memory(&db, "gov-promote-ns", "tt", "cc");
331 seed_governance_policy(
332 &db,
333 "gov-promote-ns",
334 models::GovernanceLevel::Approve,
335 "alice",
336 );
337 let args = promote_args(&id);
338 {
339 let mut out = env.output();
340 cmd_promote(&db, &args, true, Some("bob"), &mut out).unwrap();
341 }
342 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
343 assert_eq!(v["status"].as_str().unwrap(), "pending");
344 assert_eq!(v["action"].as_str().unwrap(), "promote");
345 let conn = db::open(&db).unwrap();
347 let mem = db::get(&conn, &id).unwrap().unwrap();
348 assert_eq!(mem.tier, Tier::Mid);
349 }
350
351 #[test]
352 fn test_promote_governance_deny() {
353 let _gate = pin_governance_enforce_for_test();
354 use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
361 let mut env = TestEnv::fresh();
362 let db = env.db_path.clone();
363 let conn = db::open(&db).unwrap();
364 let now = chrono::Utc::now().to_rfc3339();
365 let mut metadata = models::default_metadata();
366 if let Some(obj) = metadata.as_object_mut() {
367 obj.insert(
368 "agent_id".to_string(),
369 serde_json::Value::String("alice".to_string()),
370 );
371 }
372 let mem = models::Memory {
373 id: uuid::Uuid::new_v4().to_string(),
374 tier: Tier::Mid,
375 namespace: "deny-ns".to_string(),
376 title: "tt".to_string(),
377 content: "cc".to_string(),
378 tags: vec![],
379 priority: 5,
380 confidence: 1.0,
381 source: "test".to_string(),
382 access_count: 0,
383 created_at: now.clone(),
384 updated_at: now,
385 last_accessed_at: None,
386 expires_at: None,
387 metadata,
388 reflection_depth: 0,
389 memory_kind: crate::models::MemoryKind::Observation,
390 entity_id: None,
391 persona_version: None,
392 citations: Vec::new(),
393 source_uri: None,
394 source_span: None,
395 confidence_source: crate::models::ConfidenceSource::CallerProvided,
396 confidence_signals: None,
397 confidence_decayed_at: None,
398 version: 1,
399 };
400 let id = db::insert(&conn, &mem).unwrap();
401 drop(conn);
402 seed_governance_policy(&db, "deny-ns", models::GovernanceLevel::Owner, "alice");
403
404 let conn = db::open(&db).unwrap();
405 let payload = serde_json::json!({"id": id, "to_namespace": serde_json::Value::Null});
406 let outcome = {
407 let mut out = env.output();
408 enforce_governance(
409 &conn,
410 models::GovernedAction::Promote,
411 "deny-ns",
412 "bob",
413 Some(&id),
414 Some("alice"),
415 &payload,
416 false,
417 &mut out,
418 )
419 .unwrap()
420 };
421 assert_eq!(outcome, GovernanceOutcome::Deny);
422 assert!(env.stderr_str().contains("promote denied by governance"));
423 }
424
425 #[test]
429 fn test_promote_nonexistent_exits_nonzero() {
430 let mut env = TestEnv::fresh();
431 let db = env.db_path.clone();
432 let bad = "bad\0id".to_string();
435 let args = promote_args(&bad);
436 let mut out = env.output();
437 let res = cmd_promote(&db, &args, false, Some("x"), &mut out);
438 assert!(res.is_err());
439 }
440 #[test]
441 fn promote_target_tier_mid_stops_at_mid_and_keeps_expiry_1623() {
442 let mut env = TestEnv::fresh();
445 let db = env.db_path.clone();
446 let id = seed_memory(&db, "ns", "tt-1623", "cc");
447 {
450 let conn = crate::db::open(&db).unwrap();
451 conn.execute(
452 "UPDATE memories SET tier='short', expires_at='2099-01-01T00:00:00+00:00' WHERE id=?1",
453 rusqlite::params![id],
454 )
455 .unwrap();
456 }
457 let mut args = promote_args(&id);
458 args.target_tier = Some("mid".to_string());
459 {
460 let mut out = env.output();
461 cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
462 }
463 let stdout = env.stdout_str();
464 assert!(stdout.contains("\"tier\":\"mid\""), "got: {stdout}");
465 let conn = crate::db::open(&db).unwrap();
466 let (tier, exp): (String, Option<String>) = conn
467 .query_row(
468 "SELECT tier, expires_at FROM memories WHERE id=?1",
469 rusqlite::params![id],
470 |r| Ok((r.get(0)?, r.get(1)?)),
471 )
472 .unwrap();
473 assert_eq!(tier, "mid", "#1623: must stop at mid");
474 assert!(exp.is_some(), "#1623: mid landing must keep the live TTL");
475 }
476
477 #[test]
478 fn promote_target_tier_short_rejected_1623() {
479 let mut env = TestEnv::fresh();
480 let db = env.db_path.clone();
481 let id = seed_memory(&db, "ns", "tt-1623b", "cc");
482 let mut args = promote_args(&id);
483 args.target_tier = Some("short".to_string());
484 let mut out = env.output();
485 let err = cmd_promote(&db, &args, false, Some("test-agent"), &mut out).unwrap_err();
486 assert!(err.to_string().contains("downgrade"), "got: {err}");
487 }
488}