Skip to main content

ai_memory/cli/
promote.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_promote` migration. See `cli::store` for the design pattern.
5//!
6//! ## Two axes of promotion
7//!
8//! - **Horizontal (default):** bump the memory's tier to `long`. Sets
9//!   `expires_at = ""` to clear the inherited tier-default TTL.
10//! - **Vertical (`--to-namespace`):** clone the memory into an ancestor
11//!   namespace; the original is untouched, the tier is preserved.
12
13use 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    /// Task 1.7: clone this memory into a hierarchical-ancestor namespace
26    /// (the original is untouched). Must be an ancestor of the memory's
27    /// current namespace. Skips the tier bump — vertical promotion is a
28    /// separate axis from tier promotion.
29    #[arg(long)]
30    pub to_namespace: Option<String>,
31}
32
33/// `promote` handler.
34#[allow(clippy::too_many_lines)]
35pub fn cmd_promote(
36    db_path: &Path,
37    args: &PromoteArgs,
38    json_out: bool,
39    cli_agent_id: Option<&str>,
40    out: &mut CliOutput<'_>,
41) -> Result<()> {
42    validate::validate_id(&args.id)?;
43    if let Some(ref to_ns) = args.to_namespace {
44        validate::validate_namespace(to_ns)?;
45    }
46    let conn = db::open(db_path)?;
47    let target = if let Some(m) = db::get(&conn, &args.id)? {
48        m
49    } else if let Some(m) = db::get_by_prefix(&conn, &args.id)? {
50        m
51    } else {
52        writeln!(out.stderr, "not found: {}", args.id)?;
53        std::process::exit(1);
54    };
55    let resolved_id = target.id.clone();
56
57    {
58        use models::GovernedAction;
59        let caller_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
60        let mem_owner = target
61            .metadata
62            .get("agent_id")
63            .and_then(|v| v.as_str())
64            .map(str::to_string);
65        let payload = serde_json::json!({
66            "id": resolved_id,
67            "to_namespace": args.to_namespace,
68        });
69        match enforce_governance(
70            &conn,
71            GovernedAction::Promote,
72            &target.namespace,
73            &caller_agent_id,
74            Some(&resolved_id),
75            mem_owner.as_deref(),
76            &payload,
77            json_out,
78            out,
79        )? {
80            GovernanceOutcome::Allow => {}
81            GovernanceOutcome::Deny => {
82                std::process::exit(1);
83            }
84            GovernanceOutcome::Pending => {
85                return Ok(());
86            }
87        }
88    }
89
90    if let Some(ref to_ns) = args.to_namespace {
91        let clone_id = db::promote_to_namespace(&conn, &resolved_id, to_ns)?;
92        if json_out {
93            writeln!(
94                out.stdout,
95                "{}",
96                serde_json::to_string(&serde_json::json!({
97                    "promoted": true,
98                    "mode": "vertical",
99                    "source_id": resolved_id,
100                    "clone_id": clone_id,
101                    "to_namespace": to_ns,
102                }))?
103            )?;
104        } else {
105            writeln!(
106                out.stdout,
107                "promoted (vertical): {} → {} (clone: {})",
108                id_short(&resolved_id),
109                to_ns,
110                id_short(&clone_id),
111            )?;
112        }
113        return Ok(());
114    }
115
116    let (found, _) = db::update(
117        &conn,
118        &resolved_id,
119        None,
120        None,
121        Some(&Tier::Long),
122        None,
123        None,
124        None,
125        None,
126        Some(""),
127        None,
128    )?;
129    if !found {
130        writeln!(out.stderr, "not found: {}", args.id)?;
131        std::process::exit(1);
132    }
133    if json_out {
134        writeln!(
135            out.stdout,
136            "{}",
137            serde_json::json!({"promoted": true, "id": resolved_id, "tier": "long"})
138        )?;
139    } else {
140        writeln!(out.stdout, "promoted to long-term: {resolved_id}")?;
141    }
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::cli::test_utils::{TestEnv, seed_memory};
149
150    fn promote_args(id: &str) -> PromoteArgs {
151        PromoteArgs {
152            id: id.to_string(),
153            to_namespace: None,
154        }
155    }
156
157    fn seed_governance_policy(
158        db_path: &Path,
159        namespace: &str,
160        promote_level: models::GovernanceLevel,
161        owner_agent_id: &str,
162    ) {
163        use models::{ApproverType, GovernanceLevel, GovernancePolicy};
164        let policy = GovernancePolicy {
165            write: GovernanceLevel::Any,
166            promote: promote_level,
167            delete: GovernanceLevel::Owner,
168            approver: ApproverType::Human,
169        };
170        let conn = db::open(db_path).unwrap();
171        let now = chrono::Utc::now().to_rfc3339();
172        let mut metadata = models::default_metadata();
173        if let Some(obj) = metadata.as_object_mut() {
174            obj.insert(
175                "agent_id".to_string(),
176                serde_json::Value::String(owner_agent_id.to_string()),
177            );
178            obj.insert(
179                "governance".to_string(),
180                serde_json::to_value(&policy).unwrap(),
181            );
182        }
183        let standard = models::Memory {
184            id: uuid::Uuid::new_v4().to_string(),
185            tier: Tier::Long,
186            namespace: format!("_standards-{namespace}"),
187            title: format!("standard for {namespace}"),
188            content: "policy".to_string(),
189            tags: vec![],
190            priority: 9,
191            confidence: 1.0,
192            source: "test".to_string(),
193            access_count: 0,
194            created_at: now.clone(),
195            updated_at: now,
196            last_accessed_at: None,
197            expires_at: None,
198            metadata,
199        };
200        let standard_id = db::insert(&conn, &standard).unwrap();
201        db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
202    }
203
204    #[test]
205    fn test_promote_horizontal_to_long() {
206        let mut env = TestEnv::fresh();
207        let db = env.db_path.clone();
208        let id = seed_memory(&db, "ns", "tt", "cc");
209        let args = promote_args(&id);
210        {
211            let mut out = env.output();
212            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
213        }
214        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
215        assert_eq!(v["promoted"].as_bool().unwrap(), true);
216        assert_eq!(v["tier"].as_str().unwrap(), "long");
217        let conn = db::open(&db).unwrap();
218        let mem = db::get(&conn, &id).unwrap().unwrap();
219        assert_eq!(mem.tier, Tier::Long);
220    }
221
222    #[test]
223    fn test_promote_by_prefix() {
224        let mut env = TestEnv::fresh();
225        let db = env.db_path.clone();
226        let id = seed_memory(&db, "ns", "tt", "cc");
227        let prefix = id[..8].to_string();
228        let args = promote_args(&prefix);
229        {
230            let mut out = env.output();
231            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
232        }
233        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
234        assert_eq!(v["id"].as_str().unwrap(), id);
235    }
236
237    #[test]
238    fn test_promote_vertical_with_to_namespace() {
239        let mut env = TestEnv::fresh();
240        let db = env.db_path.clone();
241        // Hierarchical namespaces use `/`. The memory in `parent/child`
242        // can be promoted to ancestor `parent`.
243        let id = seed_memory(&db, "parent/child", "tt", "cc");
244        let mut args = promote_args(&id);
245        args.to_namespace = Some("parent".to_string());
246        {
247            let mut out = env.output();
248            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
249        }
250        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
251        assert_eq!(v["mode"].as_str().unwrap(), "vertical");
252        assert!(v["clone_id"].is_string());
253        assert_eq!(v["to_namespace"].as_str().unwrap(), "parent");
254    }
255
256    #[test]
257    fn test_promote_vertical_invalid_namespace_validation_error() {
258        let mut env = TestEnv::fresh();
259        let db = env.db_path.clone();
260        let id = seed_memory(&db, "ns", "tt", "cc");
261        let mut args = promote_args(&id);
262        args.to_namespace = Some("has spaces".to_string());
263        let mut out = env.output();
264        let res = cmd_promote(&db, &args, false, Some("test-agent"), &mut out);
265        assert!(res.is_err());
266    }
267
268    #[test]
269    fn test_promote_governance_pending() {
270        let mut env = TestEnv::fresh();
271        let db = env.db_path.clone();
272        let id = seed_memory(&db, "gov-promote-ns", "tt", "cc");
273        seed_governance_policy(
274            &db,
275            "gov-promote-ns",
276            models::GovernanceLevel::Approve,
277            "alice",
278        );
279        let args = promote_args(&id);
280        {
281            let mut out = env.output();
282            cmd_promote(&db, &args, true, Some("bob"), &mut out).unwrap();
283        }
284        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
285        assert_eq!(v["status"].as_str().unwrap(), "pending");
286        assert_eq!(v["action"].as_str().unwrap(), "promote");
287        // Memory must NOT be promoted on Pending — tier still mid.
288        let conn = db::open(&db).unwrap();
289        let mem = db::get(&conn, &id).unwrap().unwrap();
290        assert_eq!(mem.tier, Tier::Mid);
291    }
292
293    #[test]
294    fn test_promote_governance_deny() {
295        // The Deny branch in cmd_promote calls std::process::exit, which
296        // tears down the test runner. The print-side of Deny is covered
297        // by `cli::governance::tests::test_governance_deny_writes_reason_to_stderr`.
298        // Here we exercise the helper directly with a Promote action against
299        // an Owner-gated namespace and confirm the GovernanceOutcome::Deny
300        // wiring + the literal stderr line cmd_promote would print.
301        use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
302        let mut env = TestEnv::fresh();
303        let db = env.db_path.clone();
304        let conn = db::open(&db).unwrap();
305        let now = chrono::Utc::now().to_rfc3339();
306        let mut metadata = models::default_metadata();
307        if let Some(obj) = metadata.as_object_mut() {
308            obj.insert(
309                "agent_id".to_string(),
310                serde_json::Value::String("alice".to_string()),
311            );
312        }
313        let mem = models::Memory {
314            id: uuid::Uuid::new_v4().to_string(),
315            tier: Tier::Mid,
316            namespace: "deny-ns".to_string(),
317            title: "tt".to_string(),
318            content: "cc".to_string(),
319            tags: vec![],
320            priority: 5,
321            confidence: 1.0,
322            source: "test".to_string(),
323            access_count: 0,
324            created_at: now.clone(),
325            updated_at: now,
326            last_accessed_at: None,
327            expires_at: None,
328            metadata,
329        };
330        let id = db::insert(&conn, &mem).unwrap();
331        drop(conn);
332        seed_governance_policy(&db, "deny-ns", models::GovernanceLevel::Owner, "alice");
333
334        let conn = db::open(&db).unwrap();
335        let payload = serde_json::json!({"id": id, "to_namespace": serde_json::Value::Null});
336        let outcome = {
337            let mut out = env.output();
338            enforce_governance(
339                &conn,
340                models::GovernedAction::Promote,
341                "deny-ns",
342                "bob",
343                Some(&id),
344                Some("alice"),
345                &payload,
346                false,
347                &mut out,
348            )
349            .unwrap()
350        };
351        assert_eq!(outcome, GovernanceOutcome::Deny);
352        assert!(env.stderr_str().contains("promote denied by governance"));
353    }
354
355    // Nonexistent id triggers process::exit; covered by the integration
356    // suite that spawns the binary. In-process the validate_id branch
357    // proxies the not-found case for malformed inputs.
358    #[test]
359    fn test_promote_nonexistent_exits_nonzero() {
360        let mut env = TestEnv::fresh();
361        let db = env.db_path.clone();
362        // Malformed id with a null byte hits validate_id before the
363        // not-found exit branch — keeps the test in-process.
364        let bad = "bad\0id".to_string();
365        let args = promote_args(&bad);
366        let mut out = env.output();
367        let res = cmd_promote(&db, &args, false, Some("x"), &mut out);
368        assert!(res.is_err());
369    }
370}