use crate::cli::CliOutput;
use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
use crate::models::ConfidenceSource;
use crate::{config, db, identity, models, validate};
use anyhow::Result;
use chrono::{Duration, Utc};
use clap::Args;
use models::Tier;
use std::path::Path;
#[derive(Args)]
pub struct StoreArgs {
#[arg(long, short, default_value = "mid")]
pub tier: String,
#[arg(long, short)]
pub namespace: Option<String>,
#[arg(long, short = 'T', allow_hyphen_values = true)]
pub title: String,
#[arg(long, short, allow_hyphen_values = true)]
pub content: String,
#[arg(long, default_value = "")]
pub tags: String,
#[arg(long, short, default_value_t = 5)]
pub priority: i32,
#[arg(long)]
pub confidence: Option<f64>,
#[arg(long, short = 'S', default_value = "cli")]
pub source: String,
#[arg(long)]
pub expires_at: Option<String>,
#[arg(long)]
pub ttl_secs: Option<i64>,
#[arg(long)]
pub scope: Option<String>,
#[arg(long)]
pub kind: Option<String>,
#[arg(long)]
pub citations: Option<String>,
#[arg(long)]
pub source_uri: Option<String>,
#[arg(long)]
pub source_span: Option<String>,
#[arg(long)]
pub entity_id: Option<String>,
#[arg(long)]
pub sign: bool,
}
pub(crate) fn resolve_content<F>(spec: &str, stdin_reader: F) -> Result<String>
where
F: FnOnce() -> Result<String>,
{
if spec == "-" {
stdin_reader()
} else {
Ok(spec.to_string())
}
}
fn read_stdin_to_string() -> Result<String> {
use std::io::Read as _;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
#[allow(clippy::too_many_lines)]
pub fn run(
db_path: &Path,
args: StoreArgs,
json_out: bool,
app_config: &config::AppConfig,
cli_agent_id: Option<&str>,
out: &mut CliOutput<'_>,
) -> Result<()> {
let conn = db::open(db_path)?;
let resolved_ttl = app_config.effective_ttl();
let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
let tier = Tier::from_str(&args.tier)
.ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
let namespace = crate::cli::helpers::resolve_namespace(args.namespace);
let confidence = args.confidence.unwrap_or(models::DEFAULT_CONFIDENCE);
let content = resolve_content(&args.content, read_stdin_to_string)?;
let tags: Vec<String> = args
.tags
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
validate::validate_title(&args.title)?;
validate::validate_content(&content)?;
validate::validate_namespace(&namespace)?;
validate::validate_source(&args.source)?;
validate::validate_tags(&tags)?;
validate::validate_priority(args.priority)?;
validate::validate_confidence(confidence)?;
validate::validate_expires_at(args.expires_at.as_deref())?;
validate::validate_ttl_secs(args.ttl_secs)?;
let now = Utc::now();
let expires_at = args.expires_at.or_else(|| {
args.ttl_secs
.or(resolved_ttl.ttl_for_tier(&tier))
.map(|s| (now + Duration::seconds(s)).to_rfc3339())
});
let agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
let mut metadata = models::default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(agent_id.clone()),
);
}
if let Some(ref s) = args.scope {
validate::validate_scope(s)?;
if let Some(obj) = metadata.as_object_mut() {
obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
}
}
let memory_kind = match args.kind.as_deref() {
None => crate::models::MemoryKind::Observation,
Some(s) => crate::models::MemoryKind::from_str(s).ok_or_else(|| {
anyhow::anyhow!(
"invalid --kind '{s}' (expected one of: observation, reflection, persona, \
concept, entity, claim, relation, event, conversation, decision)"
)
})?,
};
let citations: Vec<crate::models::Citation> = match args.citations.as_deref() {
None => Vec::new(),
Some(s) => {
let parsed: Vec<crate::models::Citation> = serde_json::from_str(s)
.map_err(|e| anyhow::anyhow!("invalid --citations JSON: {e}"))?;
for c in &parsed {
validate::validate_citation(c)
.map_err(|e| anyhow::anyhow!("invalid --citations entry: {e}"))?;
}
parsed
}
};
let source_uri = match args.source_uri.as_deref() {
None => None,
Some(s) => {
validate::validate_source_uri(s)
.map_err(|e| anyhow::anyhow!("invalid --source-uri: {e}"))?;
Some(s.to_string())
}
};
let source_span: Option<crate::models::SourceSpan> = match args.source_span.as_deref() {
None => None,
Some(s) => {
let parsed: crate::models::SourceSpan = serde_json::from_str(s)
.map_err(|e| anyhow::anyhow!("invalid --source-span JSON: {e}"))?;
validate::validate_source_span(&parsed)
.map_err(|e| anyhow::anyhow!("invalid --source-span: {e}"))?;
Some(parsed)
}
};
let mut mem = models::Memory {
id: uuid::Uuid::new_v4().to_string(),
tier,
namespace,
title: args.title,
content,
tags,
priority: args.priority.clamp(1, 10),
confidence: confidence.clamp(0.0, 1.0),
source: args.source,
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at,
metadata,
reflection_depth: 0,
memory_kind,
entity_id: args.entity_id,
persona_version: None,
citations,
source_uri,
source_span,
confidence_source: if args.confidence.is_some() {
ConfidenceSource::CallerProvided
} else {
ConfidenceSource::Default
},
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let signature: Option<Vec<u8>> = if args.sign {
let dir = identity::keypair::default_key_dir()?;
let kp = identity::keypair::load(&agent_id, &dir).map_err(|e| {
anyhow::anyhow!("--sign requires a local keypair for agent '{agent_id}': {e:#}")
})?;
Some(identity::attest::sign_memory_write(&kp, &mem, &agent_id)?)
} else {
None
};
if args.sign || identity::attest::require_agent_attestation_enabled() {
identity::attest::stamp_attestation_sync(&conn, &mut mem, &agent_id, signature.as_deref())?;
}
{
use models::GovernedAction;
let payload = serde_json::to_value(&mem).unwrap_or_default();
match enforce_governance(
&conn,
GovernedAction::Store,
&mem.namespace,
&agent_id,
None,
None,
&payload,
json_out,
out,
)? {
GovernanceOutcome::Allow => {}
GovernanceOutcome::Deny => {
std::process::exit(1);
}
GovernanceOutcome::Pending => {
return Ok(());
}
}
}
let contradictions =
db::find_contradictions(&conn, &mem.title, &mem.namespace).unwrap_or_default();
let actual_id = db::insert(&conn, &mem)?;
crate::audit::emit(crate::audit::EventBuilder::new(
crate::audit::AuditAction::Store,
crate::audit::actor(
agent_id.clone(),
cli_agent_id.map_or(crate::audit::synthesis_sources::DEFAULT_FALLBACK, |_| {
crate::audit::synthesis_sources::EXPLICIT
}),
args.scope.clone(),
),
crate::audit::target_memory(
actual_id.clone(),
mem.namespace.clone(),
Some(mem.title.clone()),
Some(mem.tier.to_string()),
args.scope.clone(),
),
));
let filtered: Vec<&String> = contradictions
.iter()
.filter(|c| c.id != mem.id && c.id != actual_id)
.map(|c| &c.id)
.collect();
if json_out {
let mut j = serde_json::to_value(&mem)?;
j["id"] = serde_json::json!(actual_id);
let filtered: Vec<&String> = contradictions
.iter()
.filter(|c| c.id != actual_id)
.map(|c| &c.id)
.collect();
if !filtered.is_empty() {
j["potential_contradictions"] = serde_json::json!(filtered);
}
writeln!(out.stdout, "{}", serde_json::to_string(&j)?)?;
} else {
writeln!(
out.stdout,
"stored: {} [{}] (ns={})",
actual_id, mem.tier, mem.namespace
)?;
if !filtered.is_empty() {
writeln!(
out.stderr,
"warning: {} similar memories found in same namespace (potential contradictions)",
filtered.len()
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::TestEnv;
fn default_args() -> StoreArgs {
StoreArgs {
tier: Tier::Mid.as_str().to_string(),
namespace: Some("test-ns".to_string()),
title: "test title".to_string(),
content: "test content".to_string(),
tags: String::new(),
priority: 5,
confidence: None,
source: "cli".to_string(),
expires_at: None,
ttl_secs: None,
scope: None,
kind: None,
citations: None,
source_uri: None,
source_span: None,
entity_id: None,
sign: false,
}
}
#[test]
fn test_resolve_content_literal() {
let out = resolve_content("hello", || panic!("should not call stdin"));
assert_eq!(out.unwrap(), "hello");
}
#[test]
fn test_resolve_content_stdin_dash() {
let out = resolve_content("-", || Ok("piped content".to_string()));
assert_eq!(out.unwrap(), "piped content");
}
#[test]
fn test_store_happy_path_text_output() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let args = default_args();
{
let mut out = env.output();
run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
}
let stdout = env.stdout_str();
assert!(stdout.starts_with("stored: "), "got: {stdout}");
assert!(stdout.contains("[mid]"));
assert!(stdout.contains("ns=test-ns"));
}
#[test]
fn test_store_json_output() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let args = default_args();
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let stdout = env.stdout_str();
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert!(v["id"].is_string());
assert_eq!(v["title"].as_str().unwrap(), "test title");
assert_eq!(v["tier"].as_str().unwrap(), Tier::Mid.as_str());
assert_eq!(v["namespace"].as_str().unwrap(), "test-ns");
}
#[test]
fn test_store_stdin_content() {
let payload = "from stdin reader";
let resolved = resolve_content("-", || Ok(payload.to_string())).unwrap();
assert_eq!(resolved, payload);
}
#[test]
fn test_store_explicit_expires_at_overrides_tier() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
let custom_expiry = "2099-01-01T00:00:00+00:00".to_string();
args.expires_at = Some(custom_expiry.clone());
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
let exp = v["expires_at"].as_str().unwrap();
assert!(exp.starts_with("2099-01-01"), "got: {exp}");
}
#[test]
fn test_store_ttl_secs_overrides_tier() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.ttl_secs = Some(60);
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert!(v["expires_at"].is_string());
}
#[test]
fn test_store_with_scope_in_metadata() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.scope = Some("team".to_string());
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
}
#[test]
fn test_store_invalid_tier_validation_error() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.tier = "ginormous".to_string();
let mut out = env.output();
let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
let err = res.unwrap_err();
assert!(err.to_string().contains("invalid tier"));
}
#[test]
fn test_store_invalid_priority_validation_error() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.priority = 99;
let mut out = env.output();
let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
assert!(res.is_err());
}
#[test]
fn test_store_contradiction_warning_in_stderr() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let _ = crate::cli::test_utils::seed_memory(
&db,
"test-ns",
"kubernetes deployment guide",
"first content",
);
let mut args = default_args();
args.title = "kubernetes deployment notes".to_string();
args.content = "second content".to_string();
{
let mut out = env.output();
run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
}
assert!(env.stdout_str().contains("stored: "));
let stderr = env.stderr_str();
assert!(
stderr.contains("potential contradictions"),
"expected contradiction warning on stderr, got: {stderr}"
);
}
#[test]
fn test_store_governance_pending_writes_pending_status() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let args = default_args();
let mut out = env.output();
let res = run(&db, args, true, &cfg, Some("test-agent"), &mut out);
drop(out);
assert!(res.is_ok());
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert!(v["id"].is_string());
}
#[test]
fn test_store_tag_parsing() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.tags = "a, b, , c".to_string();
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
let tags = v["tags"].as_array().unwrap();
let strs: Vec<&str> = tags.iter().map(|t| t.as_str().unwrap()).collect();
assert_eq!(strs, vec!["a", "b", "c"]);
}
#[test]
fn test_store_form4_form6_flags_valid_roundtrip() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.kind = Some("reflection".to_string());
args.citations = Some(
r#"[{"uri":"uri:https://example.com/a","accessed_at":"2026-05-31T00:00:00Z"}]"#
.to_string(),
);
args.source_uri = Some("uri:https://example.com/src".to_string());
args.source_span = Some(r#"{"start":0,"end":5}"#.to_string());
args.entity_id = Some("ent-123".to_string());
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(v["memory_kind"].as_str().unwrap(), "reflection");
assert_eq!(
v["source_uri"].as_str().unwrap(),
"uri:https://example.com/src"
);
assert_eq!(v["entity_id"].as_str().unwrap(), "ent-123");
assert_eq!(v["citations"].as_array().unwrap().len(), 1);
assert_eq!(v["source_span"]["end"].as_u64().unwrap(), 5);
}
#[test]
fn test_store_invalid_kind_errors() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.kind = Some("ginormous".to_string());
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(err.to_string().contains("invalid --kind"), "got: {err}");
}
#[test]
fn test_store_invalid_citations_json_errors() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.citations = Some("not-json".to_string());
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --citations JSON"),
"got: {err}"
);
}
#[test]
fn test_store_invalid_citations_entry_errors() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.citations =
Some(r#"[{"uri":"example.com","accessed_at":"2026-05-31T00:00:00Z"}]"#.to_string());
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --citations entry"),
"got: {err}"
);
}
#[test]
fn test_store_invalid_source_uri_errors() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.source_uri = Some("bareword-no-scheme".to_string());
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --source-uri"),
"got: {err}"
);
}
#[test]
fn test_store_invalid_source_span_json_errors() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.source_span = Some("not-json".to_string());
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --source-span JSON"),
"got: {err}"
);
}
#[test]
fn test_store_invalid_source_span_range_errors() {
let _lock = locked_env();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.source_span = Some(r#"{"start":5,"end":5}"#.to_string());
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(
err.to_string().contains("invalid --source-span"),
"got: {err}"
);
}
fn env_lock() -> &'static std::sync::Mutex<()> {
crate::identity::keypair::key_dir_env_lock()
}
fn locked_env() -> std::sync::MutexGuard<'static, ()> {
env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct EnvVarGuard {
key: &'static str,
prev: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, val: &std::ffi::OsStr) -> Self {
let prev = std::env::var_os(key);
unsafe { std::env::set_var(key, val) };
Self { key, prev }
}
fn clear(key: &'static str) -> Self {
let prev = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, prev }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
#[test]
fn test_store_sign_with_bound_key_stamps_agent_attested() {
let _lock = locked_env();
let key_dir = tempfile::tempdir().unwrap();
let _kd = EnvVarGuard::set("AI_MEMORY_KEY_DIR", key_dir.path().as_os_str());
let _req = EnvVarGuard::clear("AI_MEMORY_REQUIRE_AGENT_ATTESTATION");
let kp = crate::identity::keypair::generate("test-agent").unwrap();
crate::identity::keypair::save(&kp, key_dir.path()).unwrap();
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
{
let conn = db::open(&db).unwrap();
db::register_agent(&conn, "test-agent", "ai:claude-opus-4.7", &[]).unwrap();
db::bind_agent_pubkey(&conn, "test-agent", &kp.public_base64()).unwrap();
}
let cfg = config::AppConfig::default();
let mut args = default_args();
args.sign = true;
{
let mut out = env.output();
run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
}
let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
assert_eq!(
v["metadata"]["attest_level"].as_str().unwrap(),
"agent_attested"
);
}
#[test]
fn test_store_sign_without_local_keypair_errors() {
let _lock = locked_env();
let key_dir = tempfile::tempdir().unwrap();
let _kd = EnvVarGuard::set("AI_MEMORY_KEY_DIR", key_dir.path().as_os_str());
let _req = EnvVarGuard::clear("AI_MEMORY_REQUIRE_AGENT_ATTESTATION");
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let cfg = config::AppConfig::default();
let mut args = default_args();
args.sign = true;
let mut out = env.output();
let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
assert!(
err.to_string().contains("--sign requires a local keypair"),
"got: {err}"
);
}
#[test]
fn env_var_guard_restores_previous_value_on_drop() {
let _lock = locked_env();
let prior = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("AI_MEMORY_KEY_DIR", prior.path().as_os_str()) };
{
let other = tempfile::tempdir().unwrap();
let _g = EnvVarGuard::set("AI_MEMORY_KEY_DIR", other.path().as_os_str());
assert_eq!(
std::env::var_os("AI_MEMORY_KEY_DIR").as_deref(),
Some(other.path().as_os_str())
);
}
assert_eq!(
std::env::var_os("AI_MEMORY_KEY_DIR").as_deref(),
Some(prior.path().as_os_str())
);
unsafe { std::env::remove_var("AI_MEMORY_KEY_DIR") };
}
}