Skip to main content

agent_core/memory/
store.rs

1use std::fs;
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct MemoryRecord {
10    /// Caller-supplied namespace, e.g. "session-notes" or "<plugin-id>".
11    pub namespace: String,
12    /// Unix epoch milliseconds.
13    pub timestamp_ms: u64,
14    /// Free-form text content.
15    pub content: String,
16    /// Optional tag list (e.g. ["@user", "preference"]).
17    #[serde(default)]
18    pub tags: Vec<String>,
19    /// Optional structured metadata. Validated as JSON on read.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub meta: Option<serde_json::Value>,
22    /// Stable record id (`mem-<uuid>`), assigned by [`store_record_in`].
23    /// Absent on pre-T32 legacy lines.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub id: Option<String>,
26    /// Canonical project-scope key. Absent on legacy lines — project-less
27    /// records never match a project scope (fail closed).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub project: Option<String>,
30    /// Who/what produced this record.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub provenance: Option<MemoryProvenance>,
33    /// Sensitivity class (spec §9.5).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub sensitivity: Option<MemorySensitivity>,
36    /// Retention class (consumed by the unified retention sweep).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub retention: Option<MemoryRetention>,
39}
40
41/// Provenance of a memory record (spec §9.5).
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct MemoryProvenance {
44    /// Producer: "user", "model", "tool:<name>", "extension:<id>", …
45    pub source: String,
46    /// Session the record was produced in, when known.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub session: Option<String>,
49}
50
51/// Sensitivity class of a memory record (spec §9.5). Enforcement at the
52/// model-visibility boundary lives in the memory tools: `Secret` bodies are
53/// never returned to model context.
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum MemorySensitivity {
57    Normal,
58    Sensitive,
59    Secret,
60}
61
62/// Retention class of a memory record (consumed by spec §9.7 retention).
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum MemoryRetention {
66    /// Kept until explicitly forgotten or swept by disk budget.
67    Standard,
68    /// Expires after the given number of days.
69    MaxAgeDays(u32),
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct MemoryQuery {
74    /// Optional substring match against `content` (case-insensitive).
75    pub content_contains: Option<String>,
76    /// Optional tag prefix; record matches if ANY of its tags has this prefix.
77    pub tag_prefix: Option<String>,
78    /// Inclusive lower bound on `timestamp_ms`.
79    pub since_ms: Option<u64>,
80    /// Inclusive upper bound on `timestamp_ms`.
81    pub until_ms: Option<u64>,
82    /// Maximum number of records to return (most recent first). Default: 50.
83    pub limit: Option<usize>,
84}
85
86/// Default per-query record cap.
87pub const DEFAULT_LIMIT: usize = 50;
88
89/// Maximum content length per record (UTF-8 byte length).
90pub const MAX_CONTENT_BYTES: usize = 16 * 1024;
91
92#[derive(Debug)]
93pub enum MemoryError {
94    InvalidNamespace(String),
95    ContentTooLarge {
96        len: usize,
97        max: usize,
98    },
99    Io(String),
100    Serde(String),
101    /// The record id does not exist IN THIS PROJECT SCOPE (also returned
102    /// for other projects' ids — fail closed without existence disclosure).
103    NotFound(String),
104    /// The project root could not be canonicalized.
105    InvalidProjectRoot(String),
106}
107
108impl std::fmt::Display for MemoryError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            MemoryError::InvalidNamespace(s) => write!(f, "invalid namespace: {s:?}"),
112            MemoryError::ContentTooLarge { len, max } => {
113                write!(f, "content too large: {len} bytes (max {max})")
114            }
115            MemoryError::Io(s) => write!(f, "memory io error: {s}"),
116            MemoryError::Serde(s) => write!(f, "memory serde error: {s}"),
117            MemoryError::NotFound(id) => {
118                write!(f, "memory record not found in this project scope: {id}")
119            }
120            MemoryError::InvalidProjectRoot(s) => write!(f, "invalid project root: {s}"),
121        }
122    }
123}
124
125impl std::error::Error for MemoryError {}
126
127impl From<std::io::Error> for MemoryError {
128    fn from(e: std::io::Error) -> Self {
129        MemoryError::Io(e.to_string())
130    }
131}
132
133impl From<serde_json::Error> for MemoryError {
134    fn from(e: serde_json::Error) -> Self {
135        MemoryError::Serde(e.to_string())
136    }
137}
138
139/// Current Unix epoch in milliseconds.
140pub fn now_ms() -> u64 {
141    SystemTime::now()
142        .duration_since(UNIX_EPOCH)
143        .map(|d| d.as_millis() as u64)
144        .unwrap_or(0)
145}
146
147fn validate_namespace(ns: &str) -> Result<(), MemoryError> {
148    if ns.is_empty()
149        || ns.len() > 64
150        || ns.contains('/')
151        || ns.contains('\\')
152        || ns.contains("..")
153        || ns.chars().any(|c| c.is_whitespace())
154    {
155        return Err(MemoryError::InvalidNamespace(ns.to_string()));
156    }
157    Ok(())
158}
159
160/// Path to the memory directory under base. Caller creates dirs lazily.
161pub fn memory_dir() -> PathBuf {
162    crate::config::base_dir().join("memory")
163}
164
165/// Memory directory under an explicit base (test/tool seam).
166pub fn memory_dir_in(base: &Path) -> PathBuf {
167    base.join("memory")
168}
169
170fn namespace_path(dir: &Path, ns: &str) -> PathBuf {
171    dir.join(format!("{ns}.jsonl"))
172}
173
174/// Append one record. Validates namespace, content size. Atomic per-line via O_APPEND.
175pub fn append(record: &MemoryRecord) -> Result<(), MemoryError> {
176    append_to(&crate::config::base_dir(), record)
177}
178
179pub fn append_to(base: &Path, record: &MemoryRecord) -> Result<(), MemoryError> {
180    validate_namespace(&record.namespace)?;
181    if record.content.len() > MAX_CONTENT_BYTES {
182        return Err(MemoryError::ContentTooLarge {
183            len: record.content.len(),
184            max: MAX_CONTENT_BYTES,
185        });
186    }
187    let dir = memory_dir_in(base);
188    crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
189    let path = namespace_path(&dir, &record.namespace);
190    let line = serde_json::to_string(record)?;
191    append_jsonl_line(&path, &line)
192}
193
194/// Append one JSONL line with torn-tail healing: if a previous writer
195/// crashed mid-line (file does not end in a newline), a leading newline
196/// isolates the torn fragment on its own line — lenient readers skip the
197/// fragment and every subsequent record stays parseable.
198fn append_jsonl_line(path: &Path, line: &str) -> Result<(), MemoryError> {
199    let needs_heal = match fs::metadata(path) {
200        Ok(meta) if meta.len() > 0 => {
201            use std::io::{Read, Seek, SeekFrom};
202            let mut f = fs::File::open(path)?;
203            f.seek(SeekFrom::End(-1))?;
204            let mut last = [0u8; 1];
205            f.read_exact(&mut last)?;
206            last[0] != b'\n'
207        }
208        _ => false,
209    };
210    let mut f = crate::core::private_fs::open_private_append(path).map_err(std::io::Error::from)?;
211    let mut out = String::with_capacity(line.len() + 2);
212    if needs_heal {
213        out.push('\n');
214    }
215    out.push_str(line);
216    out.push('\n');
217    f.write_all(out.as_bytes())?;
218    Ok(())
219}
220
221/// Query records in a namespace, applying filters, returning most-recent-first up to limit.
222pub fn query(namespace: &str, q: &MemoryQuery) -> Result<Vec<MemoryRecord>, MemoryError> {
223    query_in(&crate::config::base_dir(), namespace, q)
224}
225
226pub fn query_in(
227    base: &Path,
228    namespace: &str,
229    q: &MemoryQuery,
230) -> Result<Vec<MemoryRecord>, MemoryError> {
231    validate_namespace(namespace)?;
232    let path = namespace_path(&memory_dir_in(base), namespace);
233    if !path.exists() {
234        return Ok(Vec::new());
235    }
236    let f = fs::File::open(&path)?;
237    let reader = BufReader::new(f);
238    let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
239    let mut out: Vec<MemoryRecord> = Vec::new();
240    for line in reader.lines() {
241        let line = match line {
242            Ok(l) => l,
243            Err(_) => continue,
244        };
245        if line.trim().is_empty() {
246            continue;
247        }
248        let rec: MemoryRecord = match serde_json::from_str(&line) {
249            Ok(r) => r,
250            Err(_) => continue,
251        };
252        if let Some(since) = q.since_ms {
253            if rec.timestamp_ms < since {
254                continue;
255            }
256        }
257        if let Some(until) = q.until_ms {
258            if rec.timestamp_ms > until {
259                continue;
260            }
261        }
262        if let Some(needle) = &needle {
263            if !rec.content.to_lowercase().contains(needle) {
264                continue;
265            }
266        }
267        if let Some(prefix) = &q.tag_prefix {
268            if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
269                continue;
270            }
271        }
272        out.push(rec);
273    }
274    out.sort_by_key(|b| std::cmp::Reverse(b.timestamp_ms));
275    let limit = q.limit.unwrap_or(DEFAULT_LIMIT);
276    out.truncate(limit);
277    Ok(out)
278}
279
280/// List existing namespaces under the memory dir.
281pub fn list_namespaces() -> Result<Vec<String>, MemoryError> {
282    list_namespaces_in(&crate::config::base_dir())
283}
284
285pub(crate) fn list_namespaces_in(base: &Path) -> Result<Vec<String>, MemoryError> {
286    let dir = memory_dir_in(base);
287    if !dir.exists() {
288        return Ok(Vec::new());
289    }
290    let mut out = Vec::new();
291    for entry in fs::read_dir(&dir)? {
292        let entry = entry?;
293        let path = entry.path();
294        if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
295            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
296                out.push(stem.to_string());
297            }
298        }
299    }
300    out.sort();
301    Ok(out)
302}
303
304/// Build a record with `now_ms()` timestamp.
305pub fn new_record(
306    namespace: impl Into<String>,
307    content: impl Into<String>,
308    tags: Vec<String>,
309    meta: Option<serde_json::Value>,
310) -> MemoryRecord {
311    MemoryRecord {
312        namespace: namespace.into(),
313        timestamp_ms: now_ms(),
314        content: content.into(),
315        tags,
316        meta,
317        id: None,
318        project: None,
319        provenance: None,
320        sensitivity: None,
321        retention: None,
322    }
323}
324
325// ─── Task 32: project-scoped progressive memory (spec §9.5) ─────────────────
326
327/// Host-resolved project identity: the canonical workspace root, digested
328/// into a stable scope key. The HOST constructs this from trusted execution
329/// context — it is never accepted solely from model-authored arguments.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ProjectScope {
332    key: String,
333    root: PathBuf,
334}
335
336impl ProjectScope {
337    /// Resolve a scope from a workspace root. The path is canonicalized so
338    /// every spelling of the same directory yields one project identity.
339    pub fn for_root(root: &Path) -> Result<Self, MemoryError> {
340        let canonical = root
341            .canonicalize()
342            .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", root.display())))?;
343        Ok(Self::for_canonical_root(canonical))
344    }
345
346    // Internal seam for historical, already-verified canonical paths retained
347    // in the private repository marker. Unlike for_root this does not re-resolve
348    // an old path that may have moved or been reused by another repository.
349    pub(super) fn for_canonical_root(canonical: PathBuf) -> Self {
350        use sha2::{Digest, Sha256};
351        let mut hasher = Sha256::new();
352        hasher.update(canonical.as_os_str().as_encoded_bytes());
353        let digest = hasher.finalize();
354        let mut key = String::with_capacity(17);
355        key.push('p');
356        for byte in digest.iter().take(8) {
357            use std::fmt::Write;
358            let _ = write!(key, "{byte:02x}");
359        }
360        Self {
361            key,
362            root: canonical,
363        }
364    }
365
366    /// Construct an explicit user-wide scope at a canonical host base path.
367    /// This is the ONLY constructor accepting the reserved all-zero key.
368    /// Never derive this scope from model-authored project arguments.
369    pub fn user_scope(root: &Path) -> Result<Self, MemoryError> {
370        let mut scope = Self::for_root(root)?;
371        scope.require_directory()?;
372        scope.key = "p0000000000000000".into();
373        Ok(scope)
374    }
375
376    /// Restore a host-verified key (e.g. an operator migration manifest).
377    /// Syntax validation is NOT authorization: callers must establish the key's
378    /// trusted provenance, never accept a model-supplied key as a scope grant.
379    /// Accepts exactly `p[0-9a-f]{16}`, excluding the user-wide reserved key.
380    pub fn from_key(root: &Path, key: &str) -> Result<Self, MemoryError> {
381        Self::validate_repository_key(key)?;
382        let mut scope = Self::for_root(root)?;
383        scope.require_directory()?;
384        scope.key = key.to_owned();
385        Ok(scope)
386    }
387
388    fn require_directory(&self) -> Result<(), MemoryError> {
389        if !self.root.is_dir() {
390            return Err(MemoryError::InvalidProjectRoot(
391                "host scope root must be a directory".into(),
392            ));
393        }
394        Ok(())
395    }
396
397    pub(super) fn validate_repository_key(key: &str) -> Result<(), MemoryError> {
398        if key.len() != 17
399            || !key.starts_with('p')
400            || !key.as_bytes()[1..]
401                .iter()
402                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
403            || key == "p0000000000000000"
404        {
405            return Err(MemoryError::InvalidProjectRoot(
406                "invalid or reserved repository scope key".into(),
407            ));
408        }
409        Ok(())
410    }
411
412    /// Stable scope key (`p<16 hex>`).
413    pub fn key(&self) -> &str {
414        &self.key
415    }
416
417    /// Discover the stable project root for a starting directory (CP-13
418    /// fix1 I2): the `SYNAPS_PROJECT_ROOT` env override wins; otherwise a
419    /// bounded upward walk finds the nearest directory containing a `.git`
420    /// entry (DIRECTORY or FILE — worktree-safe, no process spawning, no
421    /// gitdir parsing) or a `.synaps-project` marker; with no marker the
422    /// start directory itself is the root.
423    pub fn discover(start: &Path) -> Result<Self, MemoryError> {
424        let override_root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(std::path::PathBuf::from);
425        Self::discover_with_override(start, override_root.as_deref())
426    }
427
428    /// [`Self::discover`] with an explicit override (env-free seam).
429    pub fn discover_with_override(
430        start: &Path,
431        override_root: Option<&Path>,
432    ) -> Result<Self, MemoryError> {
433        if let Some(root) = override_root {
434            return Self::for_root(root);
435        }
436        const MARKERS: [&str; 2] = [".git", ".synaps-project"];
437        const MAX_ASCENT: usize = 64;
438        let canonical_start = start
439            .canonicalize()
440            .map_err(|e| MemoryError::InvalidProjectRoot(format!("{}: {e}", start.display())))?;
441        let mut dir = canonical_start.as_path();
442        for _ in 0..MAX_ASCENT {
443            if MARKERS
444                .iter()
445                .any(|marker| dir.join(marker).symlink_metadata().is_ok())
446            {
447                return Self::for_root(dir);
448            }
449            match dir.parent() {
450                Some(parent) => dir = parent,
451                None => break,
452            }
453        }
454        Self::for_root(&canonical_start)
455    }
456
457    /// The canonical workspace root.
458    pub fn root(&self) -> &Path {
459        &self.root
460    }
461
462    /// The JSONL namespace holding this project's records.
463    pub fn namespace(&self) -> String {
464        format!("project-{}", self.key)
465    }
466}
467
468/// Input for [`store_record_in`] — explicit provenance, sensitivity, and
469/// retention (spec §9.5: nothing is stored with implicit metadata).
470#[derive(Debug, Clone)]
471pub struct NewMemoryRecord {
472    pub content: String,
473    pub tags: Vec<String>,
474    pub provenance: MemoryProvenance,
475    pub sensitivity: MemorySensitivity,
476    pub retention: MemoryRetention,
477}
478
479/// Bounded search result: descriptor + snippet, NEVER the full body (full
480/// content requires an exact [`fetch_exact_in`]).
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct MemoryDescriptor {
483    pub id: String,
484    pub project: String,
485    pub timestamp_ms: u64,
486    pub tags: Vec<String>,
487    /// UTF-8-boundary-bounded head of the content.
488    pub snippet: String,
489    /// Whether the snippet was cut.
490    pub truncated: bool,
491    /// Full content length in bytes (size disclosure only).
492    pub content_bytes: usize,
493    pub sensitivity: MemorySensitivity,
494    pub retention: MemoryRetention,
495}
496
497/// Hard cap on search results per call.
498pub const MAX_SEARCH_LIMIT: usize = 25;
499/// Default search result count.
500pub const DEFAULT_SEARCH_LIMIT: usize = 8;
501/// Hard cap on snippet bytes per descriptor.
502pub const MAX_SNIPPET_BYTES: usize = 400;
503/// Default snippet bytes per descriptor.
504pub const DEFAULT_SNIPPET_BYTES: usize = 160;
505
506/// Project-scoped search query. Limits clamp to the hard caps.
507#[derive(Debug, Clone, Default)]
508pub struct ProjectMemoryQuery {
509    pub content_contains: Option<String>,
510    pub tag_prefix: Option<String>,
511    pub since_ms: Option<u64>,
512    pub until_ms: Option<u64>,
513    pub limit: Option<usize>,
514    pub snippet_bytes: Option<usize>,
515}
516
517/// One parsed line of a project namespace file: a record or a tombstone.
518/// Tombstones are append-only (`{"tombstone":"<id>","timestamp_ms":…}`);
519/// physical deletion belongs to the retention sweep. Legacy readers skip
520/// tombstone lines as malformed records — forward compatible.
521#[derive(Debug, Serialize, Deserialize)]
522struct TombstoneLine {
523    tombstone: String,
524    timestamp_ms: u64,
525}
526
527/// Store one record in a project scope: assigns a stable `mem-<uuid>` id,
528/// binds the scope key, and appends to the project namespace.
529pub fn store_record_in(
530    base: &Path,
531    scope: &ProjectScope,
532    new: NewMemoryRecord,
533) -> Result<MemoryRecord, MemoryError> {
534    let record = MemoryRecord {
535        namespace: scope.namespace(),
536        timestamp_ms: now_ms(),
537        content: new.content,
538        tags: new.tags,
539        meta: None,
540        id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
541        project: Some(scope.key().to_string()),
542        provenance: Some(new.provenance),
543        sensitivity: Some(new.sensitivity),
544        retention: Some(new.retention),
545    };
546    append_to(base, &record)?;
547    Ok(record)
548}
549
550/// Load the LIVE (non-tombstoned, scope-matching) records of a project.
551/// Records whose inner `project` key does not match the scope are excluded
552/// — fail closed even if a foreign file was copied into the namespace.
553fn load_live_project_records(
554    base: &Path,
555    scope: &ProjectScope,
556) -> Result<Vec<MemoryRecord>, MemoryError> {
557    let path = namespace_path(&memory_dir_in(base), &scope.namespace());
558    if !path.exists() {
559        return Ok(Vec::new());
560    }
561    let f = fs::File::open(&path)?;
562    let reader = BufReader::new(f);
563    let mut live: Vec<MemoryRecord> = Vec::new();
564    let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
565    for line in reader.lines() {
566        let line = match line {
567            Ok(l) => l,
568            Err(_) => continue,
569        };
570        if line.trim().is_empty() {
571            continue;
572        }
573        if let Ok(tomb) = serde_json::from_str::<TombstoneLine>(&line) {
574            tombstoned.insert(tomb.tombstone);
575            continue;
576        }
577        let rec: MemoryRecord = match serde_json::from_str(&line) {
578            Ok(r) => r,
579            Err(_) => continue,
580        };
581        // Fail closed: only records carrying THIS scope's key participate.
582        if rec.project.as_deref() != Some(scope.key()) || rec.id.is_none() {
583            continue;
584        }
585        live.push(rec);
586    }
587    live.retain(|r| {
588        r.id.as_ref()
589            .map(|id| !tombstoned.contains(id))
590            .unwrap_or(false)
591    });
592    Ok(live)
593}
594
595/// Project-scoped bounded search: descriptors + snippets, most recent
596/// first, hard-capped result count and snippet bytes.
597pub fn search_project_in(
598    base: &Path,
599    scope: &ProjectScope,
600    q: &ProjectMemoryQuery,
601) -> Result<Vec<MemoryDescriptor>, MemoryError> {
602    let needle = q.content_contains.as_ref().map(|s| s.to_lowercase());
603    let snippet_bytes = q
604        .snippet_bytes
605        .unwrap_or(DEFAULT_SNIPPET_BYTES)
606        .min(MAX_SNIPPET_BYTES);
607    let limit = q
608        .limit
609        .unwrap_or(DEFAULT_SEARCH_LIMIT)
610        .min(MAX_SEARCH_LIMIT);
611
612    let mut records = load_live_project_records(base, scope)?;
613    records.retain(|rec| {
614        let secret = rec.sensitivity == Some(MemorySensitivity::Secret);
615        if let Some(since) = q.since_ms {
616            if rec.timestamp_ms < since {
617                return false;
618            }
619        }
620        if let Some(until) = q.until_ms {
621            if rec.timestamp_ms > until {
622                return false;
623            }
624        }
625        if let Some(needle) = &needle {
626            // Secret bodies never participate in content matching — a
627            // substring probe must not confirm secret content.
628            if secret || !rec.content.to_lowercase().contains(needle) {
629                return false;
630            }
631        }
632        if let Some(prefix) = &q.tag_prefix {
633            if !rec.tags.iter().any(|t| t.starts_with(prefix)) {
634                return false;
635            }
636        }
637        true
638    });
639    records.sort_by_key(|r| std::cmp::Reverse(r.timestamp_ms));
640    records.truncate(limit);
641
642    Ok(records
643        .into_iter()
644        .map(|rec| {
645            let sensitivity = rec.sensitivity.unwrap_or(MemorySensitivity::Normal);
646            // Secret descriptors carry NO body-derived snippet text (spec
647            // §9.5: secret bodies are visible locally only).
648            let (snippet, truncated) = if sensitivity == MemorySensitivity::Secret {
649                (String::new(), false)
650            } else {
651                let bounded = crate::text::BoundedText::new(&rec.content, snippet_bytes);
652                (bounded.text, bounded.truncated)
653            };
654            MemoryDescriptor {
655                id: rec.id.clone().unwrap_or_default(),
656                project: scope.key().to_string(),
657                timestamp_ms: rec.timestamp_ms,
658                tags: rec.tags,
659                snippet,
660                truncated,
661                content_bytes: rec.content.len(),
662                sensitivity,
663                retention: rec.retention.unwrap_or(MemoryRetention::Standard),
664            }
665        })
666        .collect())
667}
668
669/// Fetch exact record ids within a project scope. EVERY id must resolve in
670/// this scope or the whole call fails closed with [`MemoryError::NotFound`]
671/// — other projects' ids are indistinguishable from unknown ids.
672pub fn fetch_exact_in(
673    base: &Path,
674    scope: &ProjectScope,
675    ids: &[&str],
676) -> Result<Vec<MemoryRecord>, MemoryError> {
677    let live = load_live_project_records(base, scope)?;
678    let mut out = Vec::with_capacity(ids.len());
679    for wanted in ids {
680        match live.iter().find(|r| r.id.as_deref() == Some(*wanted)) {
681            Some(rec) => out.push(rec.clone()),
682            None => return Err(MemoryError::NotFound((*wanted).to_string())),
683        }
684    }
685    Ok(out)
686}
687
688/// Tombstone one record in a project scope (append-only forget). Fails
689/// closed with [`MemoryError::NotFound`] for unknown, already-tombstoned,
690/// or other-project ids.
691pub fn forget_in(base: &Path, scope: &ProjectScope, id: &str) -> Result<(), MemoryError> {
692    let live = load_live_project_records(base, scope)?;
693    if !live.iter().any(|r| r.id.as_deref() == Some(id)) {
694        return Err(MemoryError::NotFound(id.to_string()));
695    }
696    let dir = memory_dir_in(base);
697    crate::core::private_fs::ensure_private_dir(&dir).map_err(std::io::Error::from)?;
698    let path = namespace_path(&dir, &scope.namespace());
699    let line = serde_json::to_string(&TombstoneLine {
700        tombstone: id.to_string(),
701        timestamp_ms: now_ms(),
702    })?;
703    append_jsonl_line(&path, &line)
704}
705
706/// Path of a project namespace's JSONL store file (index-module seam).
707pub(crate) fn namespace_file(base: &Path, namespace: &str) -> PathBuf {
708    namespace_path(&memory_dir_in(base), namespace)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use tempfile::TempDir;
715
716    fn rec(ns: &str, ts: u64, content: &str, tags: Vec<&str>) -> MemoryRecord {
717        MemoryRecord {
718            namespace: ns.to_string(),
719            timestamp_ms: ts,
720            content: content.to_string(),
721            tags: tags.into_iter().map(String::from).collect(),
722            meta: None,
723            id: None,
724            project: None,
725            provenance: None,
726            sensitivity: None,
727            retention: None,
728        }
729    }
730
731    #[test]
732    fn append_then_query_returns_record() {
733        let tmp = TempDir::new().unwrap();
734        let r = rec("ns", 100, "hello world", vec!["@user"]);
735        append_to(tmp.path(), &r).unwrap();
736        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
737        assert_eq!(got, vec![r]);
738    }
739
740    #[test]
741    fn query_filters_by_content_contains() {
742        let tmp = TempDir::new().unwrap();
743        append_to(tmp.path(), &rec("ns", 100, "Hello World", vec![])).unwrap();
744        append_to(tmp.path(), &rec("ns", 200, "goodbye", vec![])).unwrap();
745        let q = MemoryQuery {
746            content_contains: Some("hello".to_string()),
747            ..Default::default()
748        };
749        let got = query_in(tmp.path(), "ns", &q).unwrap();
750        assert_eq!(got.len(), 1);
751        assert_eq!(got[0].content, "Hello World");
752    }
753
754    #[test]
755    fn query_filters_by_tag_prefix() {
756        let tmp = TempDir::new().unwrap();
757        append_to(
758            tmp.path(),
759            &rec("ns", 100, "x", vec!["@user", "preference"]),
760        )
761        .unwrap();
762        let got = query_in(
763            tmp.path(),
764            "ns",
765            &MemoryQuery {
766                tag_prefix: Some("@u".into()),
767                ..Default::default()
768            },
769        )
770        .unwrap();
771        assert_eq!(got.len(), 1);
772        let got = query_in(
773            tmp.path(),
774            "ns",
775            &MemoryQuery {
776                tag_prefix: Some("@x".into()),
777                ..Default::default()
778            },
779        )
780        .unwrap();
781        assert!(got.is_empty());
782    }
783
784    #[test]
785    fn query_filters_by_time_range() {
786        let tmp = TempDir::new().unwrap();
787        append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
788        append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
789        append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
790        let got = query_in(
791            tmp.path(),
792            "ns",
793            &MemoryQuery {
794                since_ms: Some(150),
795                until_ms: Some(250),
796                ..Default::default()
797            },
798        )
799        .unwrap();
800        assert_eq!(got.len(), 1);
801        assert_eq!(got[0].timestamp_ms, 200);
802    }
803
804    #[test]
805    fn query_returns_most_recent_first() {
806        let tmp = TempDir::new().unwrap();
807        append_to(tmp.path(), &rec("ns", 100, "a", vec![])).unwrap();
808        append_to(tmp.path(), &rec("ns", 300, "c", vec![])).unwrap();
809        append_to(tmp.path(), &rec("ns", 200, "b", vec![])).unwrap();
810        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
811        let ts: Vec<u64> = got.iter().map(|r| r.timestamp_ms).collect();
812        assert_eq!(ts, vec![300, 200, 100]);
813    }
814
815    #[test]
816    fn query_respects_limit() {
817        let tmp = TempDir::new().unwrap();
818        for i in 1..=5 {
819            append_to(tmp.path(), &rec("ns", i * 100, "x", vec![])).unwrap();
820        }
821        let got = query_in(
822            tmp.path(),
823            "ns",
824            &MemoryQuery {
825                limit: Some(2),
826                ..Default::default()
827            },
828        )
829        .unwrap();
830        assert_eq!(got.len(), 2);
831        assert_eq!(got[0].timestamp_ms, 500);
832        assert_eq!(got[1].timestamp_ms, 400);
833    }
834
835    #[test]
836    fn query_skips_malformed_lines() {
837        let tmp = TempDir::new().unwrap();
838        let dir = memory_dir_in(tmp.path());
839        fs::create_dir_all(&dir).unwrap();
840        let path = namespace_path(&dir, "ns");
841        let v1 = serde_json::to_string(&rec("ns", 100, "a", vec![])).unwrap();
842        let v2 = serde_json::to_string(&rec("ns", 200, "b", vec![])).unwrap();
843        let body = format!("invalid json\n{v1}\nnot json either\n{v2}\n");
844        fs::write(&path, body).unwrap();
845        let got = query_in(tmp.path(), "ns", &MemoryQuery::default()).unwrap();
846        assert_eq!(got.len(), 2);
847    }
848
849    #[test]
850    fn append_rejects_oversized_content() {
851        let tmp = TempDir::new().unwrap();
852        let big = "x".repeat(MAX_CONTENT_BYTES + 1);
853        let r = rec("ns", 1, &big, vec![]);
854        match append_to(tmp.path(), &r) {
855            Err(MemoryError::ContentTooLarge { .. }) => {}
856            other => panic!("expected ContentTooLarge, got {other:?}"),
857        }
858    }
859
860    #[test]
861    fn append_rejects_invalid_namespace() {
862        let tmp = TempDir::new().unwrap();
863        let cases = [
864            "",
865            "a/b",
866            "a\\b",
867            "..",
868            "a..b",
869            "has space",
870            "tab\there",
871            &"x".repeat(65),
872        ];
873        for ns in cases {
874            let r = rec(ns, 1, "x", vec![]);
875            match append_to(tmp.path(), &r) {
876                Err(MemoryError::InvalidNamespace(_)) => {}
877                other => panic!("expected InvalidNamespace for {ns:?}, got {other:?}"),
878            }
879        }
880    }
881
882    #[test]
883    fn list_namespaces_returns_existing_files() {
884        let tmp = TempDir::new().unwrap();
885        append_to(tmp.path(), &rec("alpha", 1, "x", vec![])).unwrap();
886        append_to(tmp.path(), &rec("beta", 1, "x", vec![])).unwrap();
887        let got = list_namespaces_in(tmp.path()).unwrap();
888        assert_eq!(got, vec!["alpha".to_string(), "beta".to_string()]);
889    }
890
891    #[test]
892    fn list_namespaces_on_missing_dir_returns_empty() {
893        let tmp = TempDir::new().unwrap();
894        let got = list_namespaces_in(tmp.path()).unwrap();
895        assert!(got.is_empty());
896    }
897
898    #[test]
899    fn query_on_missing_namespace_returns_empty() {
900        let tmp = TempDir::new().unwrap();
901        let got = query_in(tmp.path(), "nope", &MemoryQuery::default()).unwrap();
902        assert!(got.is_empty());
903    }
904
905    /// Private-mode tests (spec §5.4). Umask isolation: see
906    /// `private_fs::test_support::UmaskGuard` — `#[serial(umask)]` serializes
907    /// every umask-mutating test in the crate, and the guard restores the old
908    /// mask on drop (panic-safe).
909    #[cfg(unix)]
910    mod private_modes {
911        use super::*;
912        use crate::core::private_fs::test_support::UmaskGuard;
913        use serial_test::serial;
914        use std::os::unix::fs::PermissionsExt;
915
916        fn mode_of(path: &std::path::Path) -> u32 {
917            fs::metadata(path).unwrap().permissions().mode() & 0o777
918        }
919
920        #[test]
921        #[serial(umask)]
922        fn append_creates_0600_file_and_0700_dir_under_permissive_umask() {
923            let _umask = UmaskGuard::set(0);
924            let tmp = TempDir::new().unwrap();
925            append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
926            let dir = memory_dir_in(tmp.path());
927            assert_eq!(mode_of(&dir), 0o700, "memory dir must be 0700");
928            assert_eq!(
929                mode_of(&dir.join("ns.jsonl")),
930                0o600,
931                "memory file must be 0600"
932            );
933        }
934
935        #[test]
936        fn append_refuses_symlink_target() {
937            let tmp = TempDir::new().unwrap();
938            let dir = memory_dir_in(tmp.path());
939            fs::create_dir_all(&dir).unwrap();
940            let victim = tmp.path().join("victim.txt");
941            fs::write(&victim, "").unwrap();
942            std::os::unix::fs::symlink(&victim, dir.join("ns.jsonl")).unwrap();
943            let res = append_to(tmp.path(), &rec("ns", 1, "secret", vec![]));
944            assert!(res.is_err(), "append through a symlink must fail");
945            assert_eq!(
946                fs::read_to_string(&victim).unwrap(),
947                "",
948                "no bytes may be written through the planted symlink"
949            );
950        }
951
952        #[test]
953        fn append_repairs_preexisting_broad_mode() {
954            let tmp = TempDir::new().unwrap();
955            let dir = memory_dir_in(tmp.path());
956            fs::create_dir_all(&dir).unwrap();
957            let file = dir.join("ns.jsonl");
958            fs::write(&file, "").unwrap();
959            fs::set_permissions(&file, fs::Permissions::from_mode(0o666)).unwrap();
960            append_to(tmp.path(), &rec("ns", 1, "x", vec![])).unwrap();
961            assert_eq!(
962                mode_of(&file),
963                0o600,
964                "broad pre-existing mode must be repaired"
965            );
966        }
967    }
968}