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    /// #1623 (#831 parity): stop the tier bump at an intermediate tier
32    /// ('mid' or 'long'). Omitting preserves the historical jump to
33    /// long. 'short' is rejected (would be a downgrade). Mid landings
34    /// keep the row's live TTL; long landings clear it.
35    #[arg(long)]
36    pub target_tier: Option<String>,
37}
38
39/// `promote` handler.
40#[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    // #1623 — resolve the landing tier (mirrors the MCP handler's
123    // validation wording; 'short' refused as a downgrade).
124    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    // Long is permanent → clear expiry (Some("")); mid keeps the live
134    // TTL (None preserves), matching MCP semantics.
135    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    /// v0.7.0 K3 — pin Enforce so promote-Pending / promote-Deny
178    /// scenarios still hit the strict path (Advisory is the new
179    /// process default and would Allow). Holds the central
180    /// gate-mode Mutex; see `cli::governance::tests` for the full
181    /// rationale.
182    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        // Hierarchical namespaces use `/`. The memory in `parent/child`
299        // can be promoted to ancestor `parent`.
300        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        // Memory must NOT be promoted on Pending — tier still mid.
346        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        // The Deny branch in cmd_promote calls std::process::exit, which
355        // tears down the test runner. The print-side of Deny is covered
356        // by `cli::governance::tests::test_governance_deny_writes_reason_to_stderr`.
357        // Here we exercise the helper directly with a Promote action against
358        // an Owner-gated namespace and confirm the GovernanceOutcome::Deny
359        // wiring + the literal stderr line cmd_promote would print.
360        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    // Nonexistent id triggers process::exit; covered by the integration
426    // suite that spawns the binary. In-process the validate_id branch
427    // proxies the not-found case for malformed inputs.
428    #[test]
429    fn test_promote_nonexistent_exits_nonzero() {
430        let mut env = TestEnv::fresh();
431        let db = env.db_path.clone();
432        // Malformed id with a null byte hits validate_id before the
433        // not-found exit branch — keeps the test in-process.
434        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        // #1623 — CLI parity with the MCP target_tier param (#831):
443        // a mid landing stops at mid and PRESERVES the live TTL.
444        let mut env = TestEnv::fresh();
445        let db = env.db_path.clone();
446        let id = seed_memory(&db, "ns", "tt-1623", "cc");
447        // seed_memory rows are tier=mid; downgrade to short first so the
448        // mid landing is a genuine promotion.
449        {
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}