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            inherit: true,
170        };
171        let conn = db::open(db_path).unwrap();
172        let now = chrono::Utc::now().to_rfc3339();
173        let mut metadata = models::default_metadata();
174        if let Some(obj) = metadata.as_object_mut() {
175            obj.insert(
176                "agent_id".to_string(),
177                serde_json::Value::String(owner_agent_id.to_string()),
178            );
179            obj.insert(
180                "governance".to_string(),
181                serde_json::to_value(&policy).unwrap(),
182            );
183        }
184        let standard = models::Memory {
185            id: uuid::Uuid::new_v4().to_string(),
186            tier: Tier::Long,
187            namespace: format!("_standards-{namespace}"),
188            title: format!("standard for {namespace}"),
189            content: "policy".to_string(),
190            tags: vec![],
191            priority: 9,
192            confidence: 1.0,
193            source: "test".to_string(),
194            access_count: 0,
195            created_at: now.clone(),
196            updated_at: now,
197            last_accessed_at: None,
198            expires_at: None,
199            metadata,
200        };
201        let standard_id = db::insert(&conn, &standard).unwrap();
202        db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
203    }
204
205    #[test]
206    fn test_promote_horizontal_to_long() {
207        let mut env = TestEnv::fresh();
208        let db = env.db_path.clone();
209        let id = seed_memory(&db, "ns", "tt", "cc");
210        let args = promote_args(&id);
211        {
212            let mut out = env.output();
213            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
214        }
215        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
216        assert_eq!(v["promoted"].as_bool().unwrap(), true);
217        assert_eq!(v["tier"].as_str().unwrap(), "long");
218        let conn = db::open(&db).unwrap();
219        let mem = db::get(&conn, &id).unwrap().unwrap();
220        assert_eq!(mem.tier, Tier::Long);
221    }
222
223    #[test]
224    fn test_promote_by_prefix() {
225        let mut env = TestEnv::fresh();
226        let db = env.db_path.clone();
227        let id = seed_memory(&db, "ns", "tt", "cc");
228        let prefix = id[..8].to_string();
229        let args = promote_args(&prefix);
230        {
231            let mut out = env.output();
232            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
233        }
234        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
235        assert_eq!(v["id"].as_str().unwrap(), id);
236    }
237
238    #[test]
239    fn test_promote_vertical_with_to_namespace() {
240        let mut env = TestEnv::fresh();
241        let db = env.db_path.clone();
242        // Hierarchical namespaces use `/`. The memory in `parent/child`
243        // can be promoted to ancestor `parent`.
244        let id = seed_memory(&db, "parent/child", "tt", "cc");
245        let mut args = promote_args(&id);
246        args.to_namespace = Some("parent".to_string());
247        {
248            let mut out = env.output();
249            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
250        }
251        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
252        assert_eq!(v["mode"].as_str().unwrap(), "vertical");
253        assert!(v["clone_id"].is_string());
254        assert_eq!(v["to_namespace"].as_str().unwrap(), "parent");
255    }
256
257    #[test]
258    fn test_promote_vertical_invalid_namespace_validation_error() {
259        let mut env = TestEnv::fresh();
260        let db = env.db_path.clone();
261        let id = seed_memory(&db, "ns", "tt", "cc");
262        let mut args = promote_args(&id);
263        args.to_namespace = Some("has spaces".to_string());
264        let mut out = env.output();
265        let res = cmd_promote(&db, &args, false, Some("test-agent"), &mut out);
266        assert!(res.is_err());
267    }
268
269    #[test]
270    fn test_promote_governance_pending() {
271        let mut env = TestEnv::fresh();
272        let db = env.db_path.clone();
273        let id = seed_memory(&db, "gov-promote-ns", "tt", "cc");
274        seed_governance_policy(
275            &db,
276            "gov-promote-ns",
277            models::GovernanceLevel::Approve,
278            "alice",
279        );
280        let args = promote_args(&id);
281        {
282            let mut out = env.output();
283            cmd_promote(&db, &args, true, Some("bob"), &mut out).unwrap();
284        }
285        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
286        assert_eq!(v["status"].as_str().unwrap(), "pending");
287        assert_eq!(v["action"].as_str().unwrap(), "promote");
288        // Memory must NOT be promoted on Pending — tier still mid.
289        let conn = db::open(&db).unwrap();
290        let mem = db::get(&conn, &id).unwrap().unwrap();
291        assert_eq!(mem.tier, Tier::Mid);
292    }
293
294    #[test]
295    fn test_promote_governance_deny() {
296        // The Deny branch in cmd_promote calls std::process::exit, which
297        // tears down the test runner. The print-side of Deny is covered
298        // by `cli::governance::tests::test_governance_deny_writes_reason_to_stderr`.
299        // Here we exercise the helper directly with a Promote action against
300        // an Owner-gated namespace and confirm the GovernanceOutcome::Deny
301        // wiring + the literal stderr line cmd_promote would print.
302        use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
303        let mut env = TestEnv::fresh();
304        let db = env.db_path.clone();
305        let conn = db::open(&db).unwrap();
306        let now = chrono::Utc::now().to_rfc3339();
307        let mut metadata = models::default_metadata();
308        if let Some(obj) = metadata.as_object_mut() {
309            obj.insert(
310                "agent_id".to_string(),
311                serde_json::Value::String("alice".to_string()),
312            );
313        }
314        let mem = models::Memory {
315            id: uuid::Uuid::new_v4().to_string(),
316            tier: Tier::Mid,
317            namespace: "deny-ns".to_string(),
318            title: "tt".to_string(),
319            content: "cc".to_string(),
320            tags: vec![],
321            priority: 5,
322            confidence: 1.0,
323            source: "test".to_string(),
324            access_count: 0,
325            created_at: now.clone(),
326            updated_at: now,
327            last_accessed_at: None,
328            expires_at: None,
329            metadata,
330        };
331        let id = db::insert(&conn, &mem).unwrap();
332        drop(conn);
333        seed_governance_policy(&db, "deny-ns", models::GovernanceLevel::Owner, "alice");
334
335        let conn = db::open(&db).unwrap();
336        let payload = serde_json::json!({"id": id, "to_namespace": serde_json::Value::Null});
337        let outcome = {
338            let mut out = env.output();
339            enforce_governance(
340                &conn,
341                models::GovernedAction::Promote,
342                "deny-ns",
343                "bob",
344                Some(&id),
345                Some("alice"),
346                &payload,
347                false,
348                &mut out,
349            )
350            .unwrap()
351        };
352        assert_eq!(outcome, GovernanceOutcome::Deny);
353        assert!(env.stderr_str().contains("promote denied by governance"));
354    }
355
356    // Nonexistent id triggers process::exit; covered by the integration
357    // suite that spawns the binary. In-process the validate_id branch
358    // proxies the not-found case for malformed inputs.
359    #[test]
360    fn test_promote_nonexistent_exits_nonzero() {
361        let mut env = TestEnv::fresh();
362        let db = env.db_path.clone();
363        // Malformed id with a null byte hits validate_id before the
364        // not-found exit branch — keeps the test in-process.
365        let bad = "bad\0id".to_string();
366        let args = promote_args(&bad);
367        let mut out = env.output();
368        let res = cmd_promote(&db, &args, false, Some("x"), &mut out);
369        assert!(res.is_err());
370    }
371}