Skip to main content

ai_memory/cli/
store.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_store` migration. Handler writes through `CliOutput` so unit
5//! tests can capture stdout/stderr into `Vec<u8>` buffers.
6
7use crate::cli::CliOutput;
8use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
9use crate::models::ConfidenceSource;
10use crate::{config, db, identity, models, validate};
11use anyhow::Result;
12use chrono::{Duration, Utc};
13use clap::Args;
14use models::Tier;
15use std::path::Path;
16
17/// Clap-derived arg shape for the `store` subcommand. Definition moved
18/// from main.rs verbatim in W5a — fields and attrs unchanged.
19#[derive(Args)]
20pub struct StoreArgs {
21    /// Memory tier. `default_value` must be a literal at attribute-parse
22    /// time, so the wire string is kept here verbatim; it is byte-equal
23    /// to `crate::models::Tier::Mid.as_str()` (pm-v3.1 PR6 #1174 sweep
24    /// — raw tier literals are confined to the deserializer + clap
25    /// `default_value` attrs that cannot accept const expressions).
26    #[arg(long, short, default_value = "mid")]
27    pub tier: String,
28    #[arg(long, short)]
29    pub namespace: Option<String>,
30    #[arg(long, short = 'T', allow_hyphen_values = true)]
31    pub title: String,
32    /// Content (use - to read from stdin)
33    #[arg(long, short, allow_hyphen_values = true)]
34    pub content: String,
35    #[arg(long, default_value = "")]
36    pub tags: String,
37    #[arg(long, short, default_value_t = 5)]
38    pub priority: i32,
39    /// Confidence 0.0-1.0. When omitted (#1591) the compiled default is
40    /// stamped with truthful `confidence_source = "default"` provenance
41    /// instead of falsely claiming `caller_provided`.
42    #[arg(long)]
43    pub confidence: Option<f64>,
44    /// Source: user, claude, hook, api
45    #[arg(long, short = 'S', default_value = "cli")]
46    pub source: String,
47    /// Explicit expiry timestamp (RFC3339). Overrides tier default.
48    #[arg(long)]
49    pub expires_at: Option<String>,
50    /// TTL in seconds. Overrides tier default.
51    #[arg(long)]
52    pub ttl_secs: Option<i64>,
53    /// Task 1.5 visibility scope: private (default) / team / unit / org / collective.
54    /// Stored as `metadata.scope`; affects which agents can recall this memory
55    /// when queries use `--as-agent`.
56    #[arg(long)]
57    pub scope: Option<String>,
58    /// v0.7.0 F2.3 (#1427) — Form-6 typed memory kind. One of:
59    /// observation (default), reflection, persona, concept, entity,
60    /// claim, relation, event, conversation, decision. Maps to
61    /// `Memory::memory_kind` (canonical: `crate::models::MemoryKind`).
62    #[arg(long)]
63    pub kind: Option<String>,
64    /// v0.7.0 F2.3 (#1427) — Form-4 fact-provenance citations array.
65    /// JSON array of `{uri, accessed_at, hash?, span?}` entries. Maps
66    /// to `Memory::citations` (validated via `validate::validate_citation`).
67    /// Pass `--citations '[{"uri":"https://example.com","accessed_at":"2026-05-31T00:00:00Z"}]'`.
68    #[arg(long)]
69    pub citations: Option<String>,
70    /// v0.7.0 F2.3 (#1427) — Form-4 first-class source URI pointer.
71    /// Accepted schemes: `uri:` / `doc:` / `file:`. Maps to
72    /// `Memory::source_uri` (validated via `validate::validate_source_uri`).
73    #[arg(long)]
74    pub source_uri: Option<String>,
75    /// v0.7.0 F2.3 (#1427) — Form-4 byte-range pin into the source body.
76    /// JSON `{start: <usize>, end: <usize>}`. Maps to `Memory::source_span`
77    /// (validated via `validate::validate_source_span`).
78    #[arg(long)]
79    pub source_span: Option<String>,
80    /// v0.7.0 F2.3 (#1427) — QW-2 persona artefact entity binding.
81    /// Required when `--kind persona`. Maps to `Memory::entity_id`.
82    #[arg(long)]
83    pub entity_id: Option<String>,
84    /// #626 Layer-3 (Task 1.3 / C5) — sign this write with the resolved
85    /// agent's local Ed25519 keypair so the stored row is *attested*
86    /// rather than merely *claimed*. Requires a `<agent_id>.priv` under
87    /// the key directory (`AI_MEMORY_KEY_DIR` or the platform default);
88    /// the bound public key must match (see `ai-memory agents bind-key`).
89    /// When unset, the write is *claimed* unless
90    /// `AI_MEMORY_REQUIRE_AGENT_ATTESTATION` is on, which rejects it.
91    #[arg(long)]
92    pub sign: bool,
93}
94
95/// Resolve the content payload: literal `-` means read stdin via the
96/// supplied callback, anything else is a literal string.
97///
98/// Extracted as a free fn so unit tests can supply a fake stdin reader
99/// without touching the process's actual stdin.
100pub(crate) fn resolve_content<F>(spec: &str, stdin_reader: F) -> Result<String>
101where
102    F: FnOnce() -> Result<String>,
103{
104    if spec == "-" {
105        stdin_reader()
106    } else {
107        Ok(spec.to_string())
108    }
109}
110
111/// Read all of stdin to a `String`. Default reader for `resolve_content`.
112fn read_stdin_to_string() -> Result<String> {
113    use std::io::Read as _;
114    let mut buf = String::new();
115    std::io::stdin().read_to_string(&mut buf)?;
116    Ok(buf)
117}
118
119/// `store` handler. Mirrors `cmd_store` from main.rs verbatim except
120/// every emit routes through `out.stdout` / `out.stderr` instead of
121/// `println!` / `eprintln!`.
122#[allow(clippy::too_many_lines)]
123pub fn run(
124    db_path: &Path,
125    args: StoreArgs,
126    json_out: bool,
127    app_config: &config::AppConfig,
128    cli_agent_id: Option<&str>,
129    out: &mut CliOutput<'_>,
130) -> Result<()> {
131    let conn = db::open(db_path)?;
132    let resolved_ttl = app_config.effective_ttl();
133    let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
134    let tier = Tier::from_str(&args.tier)
135        .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
136    // #1590 — explicit --namespace > configured [storage].default_namespace
137    // > git remote > cwd basename > "global" (see `cli::helpers`).
138    let namespace = crate::cli::helpers::resolve_namespace(args.namespace);
139    // #1591 — keep caller-omission observable for truthful provenance.
140    let confidence = args.confidence.unwrap_or(models::DEFAULT_CONFIDENCE);
141    let content = resolve_content(&args.content, read_stdin_to_string)?;
142    let tags: Vec<String> = args
143        .tags
144        .split(',')
145        .map(|s| s.trim().to_string())
146        .filter(|s| !s.is_empty())
147        .collect();
148
149    // Validate all fields before touching the DB
150    validate::validate_title(&args.title)?;
151    validate::validate_content(&content)?;
152    validate::validate_namespace(&namespace)?;
153    validate::validate_source(&args.source)?;
154    validate::validate_tags(&tags)?;
155    validate::validate_priority(args.priority)?;
156    validate::validate_confidence(confidence)?;
157    validate::validate_expires_at(args.expires_at.as_deref())?;
158    validate::validate_ttl_secs(args.ttl_secs)?;
159
160    let now = Utc::now();
161    let expires_at = args.expires_at.or_else(|| {
162        args.ttl_secs
163            .or(resolved_ttl.ttl_for_tier(&tier))
164            .map(|s| (now + Duration::seconds(s)).to_rfc3339())
165    });
166    let agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
167    let mut metadata = models::default_metadata();
168    if let Some(obj) = metadata.as_object_mut() {
169        obj.insert(
170            "agent_id".to_string(),
171            serde_json::Value::String(agent_id.clone()),
172        );
173    }
174    if let Some(ref s) = args.scope {
175        validate::validate_scope(s)?;
176        if let Some(obj) = metadata.as_object_mut() {
177            obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
178        }
179    }
180
181    // v0.7.0 F2.3 (#1427) — Form-4 + Form-6 caller-supplied fields.
182    // Validate each before constructing the Memory; clap-side validation
183    // is permissive (Option<String>) and the validator carries the
184    // canonical wire-shape error messages (see validate::validate_*).
185    let memory_kind = match args.kind.as_deref() {
186        None => crate::models::MemoryKind::Observation,
187        Some(s) => crate::models::MemoryKind::from_str(s).ok_or_else(|| {
188            anyhow::anyhow!(
189                "invalid --kind '{s}' (expected one of: observation, reflection, persona, \
190                 concept, entity, claim, relation, event, conversation, decision)"
191            )
192        })?,
193    };
194    let citations: Vec<crate::models::Citation> = match args.citations.as_deref() {
195        None => Vec::new(),
196        Some(s) => {
197            let parsed: Vec<crate::models::Citation> = serde_json::from_str(s)
198                .map_err(|e| anyhow::anyhow!("invalid --citations JSON: {e}"))?;
199            for c in &parsed {
200                validate::validate_citation(c)
201                    .map_err(|e| anyhow::anyhow!("invalid --citations entry: {e}"))?;
202            }
203            parsed
204        }
205    };
206    let source_uri = match args.source_uri.as_deref() {
207        None => None,
208        Some(s) => {
209            validate::validate_source_uri(s)
210                .map_err(|e| anyhow::anyhow!("invalid --source-uri: {e}"))?;
211            Some(s.to_string())
212        }
213    };
214    let source_span: Option<crate::models::SourceSpan> = match args.source_span.as_deref() {
215        None => None,
216        Some(s) => {
217            let parsed: crate::models::SourceSpan = serde_json::from_str(s)
218                .map_err(|e| anyhow::anyhow!("invalid --source-span JSON: {e}"))?;
219            validate::validate_source_span(&parsed)
220                .map_err(|e| anyhow::anyhow!("invalid --source-span: {e}"))?;
221            Some(parsed)
222        }
223    };
224
225    let mut mem = models::Memory {
226        id: uuid::Uuid::new_v4().to_string(),
227        tier,
228        namespace,
229        title: args.title,
230        content,
231        tags,
232        priority: args.priority.clamp(1, 10),
233        confidence: confidence.clamp(0.0, 1.0),
234        source: args.source,
235        access_count: 0,
236        created_at: now.to_rfc3339(),
237        updated_at: now.to_rfc3339(),
238        last_accessed_at: None,
239        expires_at,
240        metadata,
241        reflection_depth: 0,
242        memory_kind,
243        entity_id: args.entity_id,
244        persona_version: None,
245        citations,
246        source_uri,
247        source_span,
248        // #1591 — truthful provenance: only an explicit --confidence
249        // is `caller_provided`; the compiled fallback is `default`.
250        confidence_source: if args.confidence.is_some() {
251            ConfidenceSource::CallerProvided
252        } else {
253            ConfidenceSource::Default
254        },
255        confidence_signals: None,
256        confidence_decayed_at: None,
257        version: 1,
258    };
259
260    // #626 Layer-3 (Task 1.3 / C5) — agent attestation gate. When
261    // `--sign` is set, load the agent's local keypair and sign the
262    // attestable surface; the gate then stamps `metadata.attest_level =
263    // "agent_attested"`. The gate is also invoked (with no signature) when
264    // `AI_MEMORY_REQUIRE_AGENT_ATTESTATION` is on, so an unsigned write is
265    // rejected under the strict posture. When neither applies the write
266    // path is byte-equal to the pre-Layer-3 behavior (no stamp).
267    let signature: Option<Vec<u8>> = if args.sign {
268        let dir = identity::keypair::default_key_dir()?;
269        let kp = identity::keypair::load(&agent_id, &dir).map_err(|e| {
270            anyhow::anyhow!("--sign requires a local keypair for agent '{agent_id}': {e:#}")
271        })?;
272        Some(identity::attest::sign_memory_write(&kp, &mem, &agent_id)?)
273    } else {
274        None
275    };
276    if args.sign || identity::attest::require_agent_attestation_enabled() {
277        identity::attest::stamp_attestation_sync(&conn, &mut mem, &agent_id, signature.as_deref())?;
278    }
279
280    // W5b/C5: governance enforcement routes through `cli::governance::enforce`
281    // so the print-side of Pending/Deny is covered by `cli::governance::tests`.
282    // Caller still owns the `process::exit(1)` on Deny.
283    {
284        use models::GovernedAction;
285        let payload = serde_json::to_value(&mem).unwrap_or_default();
286        match enforce_governance(
287            &conn,
288            GovernedAction::Store,
289            &mem.namespace,
290            &agent_id,
291            None,
292            None,
293            &payload,
294            json_out,
295            out,
296        )? {
297            GovernanceOutcome::Allow => {}
298            GovernanceOutcome::Deny => {
299                std::process::exit(1);
300            }
301            GovernanceOutcome::Pending => {
302                return Ok(());
303            }
304        }
305    }
306    let contradictions =
307        db::find_contradictions(&conn, &mem.title, &mem.namespace).unwrap_or_default();
308    let actual_id = db::insert(&conn, &mem)?;
309
310    // PR-5 (issue #487): security audit trail. No-op when disabled.
311    crate::audit::emit(crate::audit::EventBuilder::new(
312        crate::audit::AuditAction::Store,
313        crate::audit::actor(
314            agent_id.clone(),
315            cli_agent_id.map_or(crate::audit::synthesis_sources::DEFAULT_FALLBACK, |_| {
316                crate::audit::synthesis_sources::EXPLICIT
317            }),
318            args.scope.clone(),
319        ),
320        crate::audit::target_memory(
321            actual_id.clone(),
322            mem.namespace.clone(),
323            Some(mem.title.clone()),
324            Some(mem.tier.to_string()),
325            args.scope.clone(),
326        ),
327    ));
328    let filtered: Vec<&String> = contradictions
329        .iter()
330        .filter(|c| c.id != mem.id && c.id != actual_id)
331        .map(|c| &c.id)
332        .collect();
333    if json_out {
334        let mut j = serde_json::to_value(&mem)?;
335        j["id"] = serde_json::json!(actual_id);
336        let filtered: Vec<&String> = contradictions
337            .iter()
338            .filter(|c| c.id != actual_id)
339            .map(|c| &c.id)
340            .collect();
341        if !filtered.is_empty() {
342            j["potential_contradictions"] = serde_json::json!(filtered);
343        }
344        writeln!(out.stdout, "{}", serde_json::to_string(&j)?)?;
345    } else {
346        writeln!(
347            out.stdout,
348            "stored: {} [{}] (ns={})",
349            actual_id, mem.tier, mem.namespace
350        )?;
351        if !filtered.is_empty() {
352            writeln!(
353                out.stderr,
354                "warning: {} similar memories found in same namespace (potential contradictions)",
355                filtered.len()
356            )?;
357        }
358    }
359    Ok(())
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::cli::test_utils::TestEnv;
366
367    fn default_args() -> StoreArgs {
368        StoreArgs {
369            tier: Tier::Mid.as_str().to_string(),
370            namespace: Some("test-ns".to_string()),
371            title: "test title".to_string(),
372            content: "test content".to_string(),
373            tags: String::new(),
374            priority: 5,
375            confidence: None,
376            source: "cli".to_string(),
377            expires_at: None,
378            ttl_secs: None,
379            scope: None,
380            // v0.7.0 F2.3 (#1427) — Form-4 + Form-6 CLI flag additions.
381            kind: None,
382            citations: None,
383            source_uri: None,
384            source_span: None,
385            entity_id: None,
386            sign: false,
387        }
388    }
389
390    #[test]
391    fn test_resolve_content_literal() {
392        let out = resolve_content("hello", || panic!("should not call stdin"));
393        assert_eq!(out.unwrap(), "hello");
394    }
395
396    #[test]
397    fn test_resolve_content_stdin_dash() {
398        let out = resolve_content("-", || Ok("piped content".to_string()));
399        assert_eq!(out.unwrap(), "piped content");
400    }
401
402    #[test]
403    fn test_store_happy_path_text_output() {
404        let _lock = locked_env();
405        let mut env = TestEnv::fresh();
406        let db = env.db_path.clone();
407        let cfg = config::AppConfig::default();
408        let args = default_args();
409        {
410            let mut out = env.output();
411            run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
412        }
413        let stdout = env.stdout_str();
414        assert!(stdout.starts_with("stored: "), "got: {stdout}");
415        assert!(stdout.contains("[mid]"));
416        assert!(stdout.contains("ns=test-ns"));
417    }
418
419    #[test]
420    fn test_store_json_output() {
421        let _lock = locked_env();
422        let mut env = TestEnv::fresh();
423        let db = env.db_path.clone();
424        let cfg = config::AppConfig::default();
425        let args = default_args();
426        {
427            let mut out = env.output();
428            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
429        }
430        let stdout = env.stdout_str();
431        let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
432        assert!(v["id"].is_string());
433        assert_eq!(v["title"].as_str().unwrap(), "test title");
434        assert_eq!(v["tier"].as_str().unwrap(), Tier::Mid.as_str());
435        assert_eq!(v["namespace"].as_str().unwrap(), "test-ns");
436    }
437
438    #[test]
439    fn test_store_stdin_content() {
440        // Direct test on resolve_content covers the dash-stdin branch
441        // without spawning a subprocess.
442        let payload = "from stdin reader";
443        let resolved = resolve_content("-", || Ok(payload.to_string())).unwrap();
444        assert_eq!(resolved, payload);
445    }
446
447    #[test]
448    fn test_store_explicit_expires_at_overrides_tier() {
449        let _lock = locked_env();
450        let mut env = TestEnv::fresh();
451        let db = env.db_path.clone();
452        let cfg = config::AppConfig::default();
453        let mut args = default_args();
454        let custom_expiry = "2099-01-01T00:00:00+00:00".to_string();
455        args.expires_at = Some(custom_expiry.clone());
456        {
457            let mut out = env.output();
458            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
459        }
460        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
461        let exp = v["expires_at"].as_str().unwrap();
462        assert!(exp.starts_with("2099-01-01"), "got: {exp}");
463    }
464
465    #[test]
466    fn test_store_ttl_secs_overrides_tier() {
467        let _lock = locked_env();
468        let mut env = TestEnv::fresh();
469        let db = env.db_path.clone();
470        let cfg = config::AppConfig::default();
471        let mut args = default_args();
472        args.ttl_secs = Some(60);
473        {
474            let mut out = env.output();
475            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
476        }
477        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
478        // expires_at must be set (non-null) and roughly within the next minute.
479        assert!(v["expires_at"].is_string());
480    }
481
482    #[test]
483    fn test_store_with_scope_in_metadata() {
484        let _lock = locked_env();
485        let mut env = TestEnv::fresh();
486        let db = env.db_path.clone();
487        let cfg = config::AppConfig::default();
488        let mut args = default_args();
489        args.scope = Some("team".to_string());
490        {
491            let mut out = env.output();
492            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
493        }
494        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
495        assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
496    }
497
498    #[test]
499    fn test_store_invalid_tier_validation_error() {
500        let _lock = locked_env();
501        let mut env = TestEnv::fresh();
502        let db = env.db_path.clone();
503        let cfg = config::AppConfig::default();
504        let mut args = default_args();
505        args.tier = "ginormous".to_string();
506        let mut out = env.output();
507        let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
508        let err = res.unwrap_err();
509        assert!(err.to_string().contains("invalid tier"));
510    }
511
512    #[test]
513    fn test_store_invalid_priority_validation_error() {
514        let _lock = locked_env();
515        let mut env = TestEnv::fresh();
516        let db = env.db_path.clone();
517        let cfg = config::AppConfig::default();
518        let mut args = default_args();
519        args.priority = 99;
520        let mut out = env.output();
521        let res = run(&db, args, false, &cfg, Some("test-agent"), &mut out);
522        // validate_priority rejects out-of-range values.
523        assert!(res.is_err());
524    }
525
526    #[test]
527    fn test_store_contradiction_warning_in_stderr() {
528        let _lock = locked_env();
529        let mut env = TestEnv::fresh();
530        let db = env.db_path.clone();
531        let cfg = config::AppConfig::default();
532        // Seed a memory with a SIMILAR (not identical) title in the same
533        // namespace. A distinct title avoids the `(title, namespace)`
534        // upsert — if the titles matched exactly, `db::insert` would merge
535        // onto the seeded row, making `actual_id == seeded.id`, and the
536        // contradiction would be filtered out (line: `c.id != actual_id`)
537        // so the warning would never fire. The two titles share
538        // `{kubernetes, deployment}` of `{kubernetes, deployment, guide}` /
539        // `{kubernetes, deployment, notes}` → Jaccard 2/4 = 0.5 ≥ 0.30
540        // floor, so the seeded row surfaces as a potential contradiction.
541        let _ = crate::cli::test_utils::seed_memory(
542            &db,
543            "test-ns",
544            "kubernetes deployment guide",
545            "first content",
546        );
547        let mut args = default_args();
548        args.title = "kubernetes deployment notes".to_string();
549        args.content = "second content".to_string();
550        {
551            let mut out = env.output();
552            run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap();
553        }
554        // Happy path stored the new (distinct-title) row on stdout.
555        assert!(env.stdout_str().contains("stored: "));
556        // And the similar seeded row fired the contradiction warning on
557        // stderr (exercises the non-json `if !filtered.is_empty()` branch).
558        let stderr = env.stderr_str();
559        assert!(
560            stderr.contains("potential contradictions"),
561            "expected contradiction warning on stderr, got: {stderr}"
562        );
563    }
564
565    #[test]
566    fn test_store_governance_pending_writes_pending_status() {
567        let _lock = locked_env();
568        // Covered indirectly by the happy-path test (no governance rules
569        // configured -> Allow branch). The Pending/Deny branches require
570        // governance-rule rows that aren't part of the default schema; a
571        // dedicated unit test would need to seed the governance_rules
572        // table directly. Hardened in integration suite.
573        let mut env = TestEnv::fresh();
574        let db = env.db_path.clone();
575        let cfg = config::AppConfig::default();
576        let args = default_args();
577        let mut out = env.output();
578        let res = run(&db, args, true, &cfg, Some("test-agent"), &mut out);
579        drop(out);
580        assert!(res.is_ok());
581        // JSON shape on the Allow branch must include a stored id.
582        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
583        assert!(v["id"].is_string());
584    }
585
586    #[test]
587    fn test_store_tag_parsing() {
588        let _lock = locked_env();
589        let mut env = TestEnv::fresh();
590        let db = env.db_path.clone();
591        let cfg = config::AppConfig::default();
592        let mut args = default_args();
593        args.tags = "a, b, , c".to_string();
594        {
595            let mut out = env.output();
596            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
597        }
598        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
599        let tags = v["tags"].as_array().unwrap();
600        let strs: Vec<&str> = tags.iter().map(|t| t.as_str().unwrap()).collect();
601        assert_eq!(strs, vec!["a", "b", "c"]);
602    }
603
604    // v0.7.0 F2.3 (#1427) — coverage for the Form-4 / Form-6 flag arms.
605
606    #[test]
607    fn test_store_form4_form6_flags_valid_roundtrip() {
608        let _lock = locked_env();
609        // Exercises every Some(_) success arm (kind/citations/source_uri/
610        // source_span/entity_id) in a single store call.
611        let mut env = TestEnv::fresh();
612        let db = env.db_path.clone();
613        let cfg = config::AppConfig::default();
614        let mut args = default_args();
615        args.kind = Some("reflection".to_string());
616        args.citations = Some(
617            r#"[{"uri":"uri:https://example.com/a","accessed_at":"2026-05-31T00:00:00Z"}]"#
618                .to_string(),
619        );
620        args.source_uri = Some("uri:https://example.com/src".to_string());
621        args.source_span = Some(r#"{"start":0,"end":5}"#.to_string());
622        args.entity_id = Some("ent-123".to_string());
623        {
624            let mut out = env.output();
625            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
626        }
627        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
628        assert_eq!(v["memory_kind"].as_str().unwrap(), "reflection");
629        assert_eq!(
630            v["source_uri"].as_str().unwrap(),
631            "uri:https://example.com/src"
632        );
633        assert_eq!(v["entity_id"].as_str().unwrap(), "ent-123");
634        assert_eq!(v["citations"].as_array().unwrap().len(), 1);
635        assert_eq!(v["source_span"]["end"].as_u64().unwrap(), 5);
636    }
637
638    #[test]
639    fn test_store_invalid_kind_errors() {
640        let _lock = locked_env();
641        let mut env = TestEnv::fresh();
642        let db = env.db_path.clone();
643        let cfg = config::AppConfig::default();
644        let mut args = default_args();
645        args.kind = Some("ginormous".to_string());
646        let mut out = env.output();
647        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
648        assert!(err.to_string().contains("invalid --kind"), "got: {err}");
649    }
650
651    #[test]
652    fn test_store_invalid_citations_json_errors() {
653        let _lock = locked_env();
654        let mut env = TestEnv::fresh();
655        let db = env.db_path.clone();
656        let cfg = config::AppConfig::default();
657        let mut args = default_args();
658        args.citations = Some("not-json".to_string());
659        let mut out = env.output();
660        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
661        assert!(
662            err.to_string().contains("invalid --citations JSON"),
663            "got: {err}"
664        );
665    }
666
667    #[test]
668    fn test_store_invalid_citations_entry_errors() {
669        let _lock = locked_env();
670        // Well-formed JSON, but the entry fails validate_citation
671        // (bare URI without a uri:/doc:/file: scheme).
672        let mut env = TestEnv::fresh();
673        let db = env.db_path.clone();
674        let cfg = config::AppConfig::default();
675        let mut args = default_args();
676        args.citations =
677            Some(r#"[{"uri":"example.com","accessed_at":"2026-05-31T00:00:00Z"}]"#.to_string());
678        let mut out = env.output();
679        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
680        assert!(
681            err.to_string().contains("invalid --citations entry"),
682            "got: {err}"
683        );
684    }
685
686    #[test]
687    fn test_store_invalid_source_uri_errors() {
688        let _lock = locked_env();
689        let mut env = TestEnv::fresh();
690        let db = env.db_path.clone();
691        let cfg = config::AppConfig::default();
692        let mut args = default_args();
693        args.source_uri = Some("bareword-no-scheme".to_string());
694        let mut out = env.output();
695        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
696        assert!(
697            err.to_string().contains("invalid --source-uri"),
698            "got: {err}"
699        );
700    }
701
702    #[test]
703    fn test_store_invalid_source_span_json_errors() {
704        let _lock = locked_env();
705        let mut env = TestEnv::fresh();
706        let db = env.db_path.clone();
707        let cfg = config::AppConfig::default();
708        let mut args = default_args();
709        args.source_span = Some("not-json".to_string());
710        let mut out = env.output();
711        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
712        assert!(
713            err.to_string().contains("invalid --source-span JSON"),
714            "got: {err}"
715        );
716    }
717
718    #[test]
719    fn test_store_invalid_source_span_range_errors() {
720        let _lock = locked_env();
721        // Valid JSON, but start >= end fails validate_source_span.
722        let mut env = TestEnv::fresh();
723        let db = env.db_path.clone();
724        let cfg = config::AppConfig::default();
725        let mut args = default_args();
726        args.source_span = Some(r#"{"start":5,"end":5}"#.to_string());
727        let mut out = env.output();
728        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
729        assert!(
730            err.to_string().contains("invalid --source-span"),
731            "got: {err}"
732        );
733    }
734
735    // #626 Layer-3 (Task 1.3 / C5) — `--sign` attestation gate coverage.
736    //
737    // These three tests mutate process env (`AI_MEMORY_KEY_DIR`,
738    // `AI_MEMORY_REQUIRE_AGENT_ATTESTATION`) so they serialize on
739    // `ENV_LOCK` and restore the prior values on exit, per the
740    // env-test discipline. Key material lives under a `tempfile::tempdir()`
741    // (never `/tmp` directly — the OS temp root is fine for the OS-created
742    // dir; the project no-/tmp rule covers agent-AUTHORED scratch paths).
743
744    /// Process-global env lock shared with
745    /// [`crate::identity::keypair::key_dir_env_lock`]. Every test across the
746    /// crate that mutates `AI_MEMORY_KEY_DIR` (keypair, mcp, governance::audit,
747    /// cli::verify) serialises on this ONE mutex; a module-local lock would let
748    /// those suites race this one on the shared `AI_MEMORY_KEY_DIR` /
749    /// `AI_MEMORY_REQUIRE_AGENT_ATTESTATION` process env and flake. #626 Layer-3.
750    fn env_lock() -> &'static std::sync::Mutex<()> {
751        crate::identity::keypair::key_dir_env_lock()
752    }
753
754    /// Poison-resilient acquire of the shared env lock. Centralises the
755    /// `into_inner` recovery in one place (via the `PoisonError::into_inner`
756    /// fn-pointer, not a per-call-site closure) so the never-firing-in-green
757    /// poison branch is a single covered instantiation rather than one
758    /// uncovered closure per test.
759    fn locked_env() -> std::sync::MutexGuard<'static, ()> {
760        env_lock()
761            .lock()
762            .unwrap_or_else(std::sync::PoisonError::into_inner)
763    }
764
765    /// RAII restore of an env var to its pre-test value.
766    struct EnvVarGuard {
767        key: &'static str,
768        prev: Option<std::ffi::OsString>,
769    }
770    impl EnvVarGuard {
771        fn set(key: &'static str, val: &std::ffi::OsStr) -> Self {
772            let prev = std::env::var_os(key);
773            unsafe { std::env::set_var(key, val) };
774            Self { key, prev }
775        }
776        fn clear(key: &'static str) -> Self {
777            let prev = std::env::var_os(key);
778            unsafe { std::env::remove_var(key) };
779            Self { key, prev }
780        }
781    }
782    impl Drop for EnvVarGuard {
783        fn drop(&mut self) {
784            match &self.prev {
785                Some(v) => unsafe { std::env::set_var(self.key, v) },
786                None => unsafe { std::env::remove_var(self.key) },
787            }
788        }
789    }
790
791    #[test]
792    fn test_store_sign_with_bound_key_stamps_agent_attested() {
793        let _lock = locked_env();
794        let key_dir = tempfile::tempdir().unwrap();
795        let _kd = EnvVarGuard::set("AI_MEMORY_KEY_DIR", key_dir.path().as_os_str());
796        let _req = EnvVarGuard::clear("AI_MEMORY_REQUIRE_AGENT_ATTESTATION");
797
798        // Persist the agent's keypair on disk so `--sign` can load + sign.
799        let kp = crate::identity::keypair::generate("test-agent").unwrap();
800        crate::identity::keypair::save(&kp, key_dir.path()).unwrap();
801
802        let mut env = TestEnv::fresh();
803        let db = env.db_path.clone();
804        // Register the agent + bind its pubkey so the gate resolves a bound
805        // key matching the presented signature → AgentAttested.
806        {
807            let conn = db::open(&db).unwrap();
808            db::register_agent(&conn, "test-agent", "ai:claude-opus-4.7", &[]).unwrap();
809            db::bind_agent_pubkey(&conn, "test-agent", &kp.public_base64()).unwrap();
810        }
811
812        let cfg = config::AppConfig::default();
813        let mut args = default_args();
814        args.sign = true;
815        {
816            let mut out = env.output();
817            run(&db, args, true, &cfg, Some("test-agent"), &mut out).unwrap();
818        }
819        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
820        assert_eq!(
821            v["metadata"]["attest_level"].as_str().unwrap(),
822            "agent_attested"
823        );
824    }
825
826    #[test]
827    fn test_store_sign_without_local_keypair_errors() {
828        let _lock = locked_env();
829        // Empty key dir — no `<agent_id>.priv` to load.
830        let key_dir = tempfile::tempdir().unwrap();
831        let _kd = EnvVarGuard::set("AI_MEMORY_KEY_DIR", key_dir.path().as_os_str());
832        let _req = EnvVarGuard::clear("AI_MEMORY_REQUIRE_AGENT_ATTESTATION");
833
834        let mut env = TestEnv::fresh();
835        let db = env.db_path.clone();
836        let cfg = config::AppConfig::default();
837        let mut args = default_args();
838        args.sign = true;
839        let mut out = env.output();
840        let err = run(&db, args, false, &cfg, Some("test-agent"), &mut out).unwrap_err();
841        assert!(
842            err.to_string().contains("--sign requires a local keypair"),
843            "got: {err}"
844        );
845    }
846
847    // #1609 — the strict-require rejection case (`test_store_require_
848    // attestation_rejects_unsigned`) used to live here, SETTING the
849    // process-global `AI_MEMORY_REQUIRE_AGENT_ATTESTATION` under
850    // `locked_env()`. The lock covers fellow MUTATORS, but the gate's
851    // READERS (`require_agent_attestation_enabled` callers in
852    // `mcp::tools::store` / `handlers::create` tests) run lock-free in
853    // the same parallel lib-test process, so the set-window leaked into
854    // any sibling store test scheduled concurrently (narrow-filter
855    // repro: `cargo test --lib 'store::tests'`). The case now drives
856    // the compiled binary with child-process env in
857    // `tests/agent_attestation_integrity.rs::
858    // cli_require_attestation_rejects_unsigned_store` — same coverage,
859    // zero process-global mutation. Per the design rule documented in
860    // `src/mcp/tools/store/tests.rs` (#626 section header): the
861    // parallel lib-test binary must NEVER set the require flag.
862
863    // EnvVarGuard Drop with a pre-existing value → Some-arm restore (set_var)
864    // rather than the None-arm remove. Pins the RAII restore contract.
865    #[test]
866    fn env_var_guard_restores_previous_value_on_drop() {
867        let _lock = locked_env();
868        let prior = tempfile::tempdir().unwrap();
869        unsafe { std::env::set_var("AI_MEMORY_KEY_DIR", prior.path().as_os_str()) };
870        {
871            let other = tempfile::tempdir().unwrap();
872            let _g = EnvVarGuard::set("AI_MEMORY_KEY_DIR", other.path().as_os_str());
873            assert_eq!(
874                std::env::var_os("AI_MEMORY_KEY_DIR").as_deref(),
875                Some(other.path().as_os_str())
876            );
877            // _g drops here → Some-arm restore of `prior`.
878        }
879        assert_eq!(
880            std::env::var_os("AI_MEMORY_KEY_DIR").as_deref(),
881            Some(prior.path().as_os_str())
882        );
883        unsafe { std::env::remove_var("AI_MEMORY_KEY_DIR") };
884    }
885}