Skip to main content

everruns_core/
memory.rs

1// Memory domain types
2//
3// Design intent lives in `specs/memory.md`.
4//
5// A Memory is an org-scoped, named store that users can mount into session
6// workspaces through the `memory` capability. This module defines the Memory
7// entity, lifecycle status, file entries, and the capability mount config
8// shape.
9//
10// The dual-ID pattern matches every other building-block entity: external
11// `public_id: MemoryId` (mem_<32-hex>) is the API-facing identifier, internal
12// UUID `internal_id` is the FK target and is never exposed in API responses.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::typed_id::{AgentId, MemoryId};
19
20#[cfg(feature = "openapi")]
21use utoipa::ToSchema;
22
23/// Memory lifecycle status.
24///
25/// Mirrors the building-block lifecycle defined in `specs/models.md`:
26/// - `active`: assignable to mounts, editable, listed by default.
27/// - `archived`: hidden from default lists, not assignable to new mounts,
28///   read-only.
29/// - `deleted`: tombstone; detail/list APIs return 404 except for historical
30///   references (e.g. existing `session_memory_mounts` snapshots).
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[cfg_attr(feature = "openapi", derive(ToSchema))]
33#[serde(rename_all = "lowercase")]
34pub enum MemoryStatus {
35    Active,
36    Archived,
37    Deleted,
38}
39
40impl std::fmt::Display for MemoryStatus {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            MemoryStatus::Active => write!(f, "active"),
44            MemoryStatus::Archived => write!(f, "archived"),
45            MemoryStatus::Deleted => write!(f, "deleted"),
46        }
47    }
48}
49
50impl From<&str> for MemoryStatus {
51    fn from(s: &str) -> Self {
52        match s {
53            "archived" => MemoryStatus::Archived,
54            "deleted" => MemoryStatus::Deleted,
55            _ => MemoryStatus::Active,
56        }
57    }
58}
59
60/// Ownership scope for a Memory.
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
62#[cfg_attr(feature = "openapi", derive(ToSchema))]
63#[serde(rename_all = "lowercase")]
64pub enum MemoryScope {
65    /// Organization-managed Memory selected through explicit `memory.mounts[]`.
66    #[default]
67    Org,
68    /// Agent-owned Memory that follows the agent across sessions.
69    Agent,
70    /// User-owned Memory that follows the user across sessions.
71    User,
72}
73
74impl std::fmt::Display for MemoryScope {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            MemoryScope::Org => write!(f, "org"),
78            MemoryScope::Agent => write!(f, "agent"),
79            MemoryScope::User => write!(f, "user"),
80        }
81    }
82}
83
84impl From<&str> for MemoryScope {
85    fn from(s: &str) -> Self {
86        match s {
87            "agent" => MemoryScope::Agent,
88            "user" => MemoryScope::User,
89            _ => MemoryScope::Org,
90        }
91    }
92}
93
94/// A Memory — org-scoped named store.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[cfg_attr(feature = "openapi", derive(ToSchema))]
97pub struct Memory {
98    /// External identifier (`mem_<32-hex>`). Shown as `id` in API responses.
99    #[serde(rename = "id")]
100    #[cfg_attr(
101        feature = "openapi",
102        schema(value_type = String, example = "mem_01933b5a000070008000000000000001")
103    )]
104    pub public_id: MemoryId,
105    /// Internal UUID primary key. Used for FK references. Never exposed in API.
106    #[serde(skip, default = "Uuid::nil")]
107    pub internal_id: Uuid,
108    /// Human-readable name, unique per org while not deleted.
109    pub name: String,
110    /// Optional human-readable description.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub description: Option<String>,
113    /// Memory ownership scope.
114    #[serde(default)]
115    pub scope: MemoryScope,
116    /// Owning agent for agent-scoped memories.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub owner_agent_id: Option<AgentId>,
119    /// Owning user for user-scoped memories.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub owner_user_id: Option<Uuid>,
122    /// Principal that created the memory (free-form; resolved at the domain layer).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub owner_principal_id: Option<String>,
125    /// Resolved owner user, when known.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub resolved_owner_user_id: Option<Uuid>,
128    /// Lifecycle status.
129    pub status: MemoryStatus,
130    pub created_at: DateTime<Utc>,
131    pub updated_at: DateTime<Utc>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub archived_at: Option<DateTime<Utc>>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub deleted_at: Option<DateTime<Utc>>,
136}
137
138/// A file or directory inside a Memory.
139///
140/// Mirrors `SessionFile` shape; path validation is intentionally identical to
141/// `session_files` so existing client code can reuse path normalization
142/// helpers without bifurcating semantics.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(ToSchema))]
145pub struct MemoryFile {
146    pub id: Uuid,
147    /// Internal UUID of the parent memory.
148    pub memory_id: Uuid,
149    /// Absolute, normalized path starting with `/`.
150    pub path: String,
151    /// File content. None for directories. Encoded the same way as
152    /// `SessionFile::content` (text or base64).
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub content: Option<String>,
155    /// Encoding marker: "text" or "base64". Defaults to "text".
156    #[serde(default = "default_encoding")]
157    pub encoding: String,
158    pub is_directory: bool,
159    pub size_bytes: i64,
160    /// Optional `sha256:...` hash for stale-edit protection on read-write
161    /// mounts. Mirrors `session_files` freshness semantics.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub content_hash: Option<String>,
164    pub created_at: DateTime<Utc>,
165    pub updated_at: DateTime<Utc>,
166}
167
168fn default_encoding() -> String {
169    "text".to_string()
170}
171
172/// Mount access mode. Defaults to `ReadOnly` when omitted from config.
173#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
174#[cfg_attr(feature = "openapi", derive(ToSchema))]
175#[serde(rename_all = "lowercase")]
176pub enum MemoryMountAccess {
177    #[default]
178    ReadOnly,
179    ReadWrite,
180}
181
182impl std::fmt::Display for MemoryMountAccess {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        match self {
185            MemoryMountAccess::ReadOnly => write!(f, "readonly"),
186            MemoryMountAccess::ReadWrite => write!(f, "readwrite"),
187        }
188    }
189}
190
191impl From<&str> for MemoryMountAccess {
192    fn from(s: &str) -> Self {
193        match s {
194            "readwrite" => MemoryMountAccess::ReadWrite,
195            _ => MemoryMountAccess::ReadOnly,
196        }
197    }
198}
199
200/// Capability config entry for `memory`. One entry per mount.
201///
202/// Wire shape:
203///
204/// ```json
205/// { "memory": "mem_abc...", "path": "/workspace/research", "mode": "readonly" }
206/// ```
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[cfg_attr(feature = "openapi", derive(ToSchema))]
209pub struct MemoryMountConfig {
210    /// Public Memory ID (`mem_<32-hex>`).
211    pub memory: String,
212    /// Mount path under `/workspace`.
213    pub path: String,
214    /// Access mode. Defaults to `readonly` when omitted.
215    #[serde(default)]
216    pub mode: MemoryMountAccess,
217}
218
219/// Top-level config for the `memory` capability.
220#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[cfg_attr(feature = "openapi", derive(ToSchema))]
222pub struct MemoryConfig {
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub mounts: Vec<MemoryMountConfig>,
225}
226
227/// Validation outcome for a single mount config entry.
228///
229/// Domain-level cross-validation (cross-org references, archived/deleted
230/// memories, capability-mount overlaps) happens at the server layer. This
231/// helper covers the structural checks we can perform without DB access so
232/// that capability `validate_config` and any clientside form validation
233/// share semantics.
234pub fn validate_mount_config_shape(mount: &MemoryMountConfig) -> Result<(), String> {
235    // Memory reference must be a syntactically valid MemoryId
236    // (mem_<32-lowercase-hex>) — match the DB CHECK constraint exactly so
237    // structurally invalid IDs cannot pass capability validation and reach
238    // domain code or the database.
239    if MemoryId::parse(&mount.memory).is_err() {
240        return Err(format!(
241            "mount.memory must be a valid Memory ID of the form mem_<32-lowercase-hex>, got '{}'",
242            mount.memory
243        ));
244    }
245
246    // Mount path must be either exactly `/workspace` or descend from it via a
247    // `/workspace/` boundary (rejects lookalikes such as `/workspacefoo`).
248    // It must not contain `..`, null bytes, empty segments, or a trailing slash.
249    let path = &mount.path;
250    if path != "/workspace" && !path.starts_with("/workspace/") {
251        return Err(format!(
252            "mount.path must be /workspace or start with /workspace/, got '{path}'"
253        ));
254    }
255    if path.contains("//") {
256        return Err(format!("mount.path must not contain '//', got '{path}'"));
257    }
258    if path.contains('\0') {
259        return Err(format!(
260            "mount.path must not contain null bytes, got '{path}'"
261        ));
262    }
263    if path.split('/').any(|seg| seg == "..") {
264        return Err(format!("mount.path must not contain '..', got '{path}'"));
265    }
266    if path.len() > 1 && path.ends_with('/') {
267        return Err(format!(
268            "mount.path must not end with a trailing slash, got '{path}'"
269        ));
270    }
271    Ok(())
272}
273
274/// Validate a full `memory` capability config: per-entry shape + duplicate /
275/// overlapping path detection.
276pub fn validate_memory_config(config: &MemoryConfig) -> Result<(), String> {
277    for mount in &config.mounts {
278        validate_mount_config_shape(mount)?;
279    }
280    // Reject duplicate mount paths.
281    let mut seen: Vec<&str> = Vec::with_capacity(config.mounts.len());
282    for mount in &config.mounts {
283        if seen.iter().any(|p| *p == mount.path) {
284            return Err(format!(
285                "duplicate mount path '{}' in memory config",
286                mount.path
287            ));
288        }
289        seen.push(&mount.path);
290    }
291    // Reject overlapping mount paths (one being a prefix of another).
292    for (i, a) in config.mounts.iter().enumerate() {
293        for b in &config.mounts[i + 1..] {
294            if mount_paths_overlap(&a.path, &b.path) {
295                return Err(format!(
296                    "overlapping mount paths '{}' and '{}'",
297                    a.path, b.path
298                ));
299            }
300        }
301    }
302    Ok(())
303}
304
305fn mount_paths_overlap(a: &str, b: &str) -> bool {
306    if a == b {
307        return true;
308    }
309    let (shorter, longer) = if a.len() < b.len() { (a, b) } else { (b, a) };
310    longer.starts_with(shorter) && longer.as_bytes().get(shorter.len()) == Some(&b'/')
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn status_round_trip() {
319        assert_eq!(MemoryStatus::from("active").to_string(), "active");
320        assert_eq!(MemoryStatus::from("archived").to_string(), "archived");
321        assert_eq!(MemoryStatus::from("deleted").to_string(), "deleted");
322        assert_eq!(MemoryStatus::from("unknown").to_string(), "active");
323    }
324
325    #[test]
326    fn access_default_is_readonly() {
327        let cfg: MemoryMountConfig = serde_json::from_str(
328            r#"{ "memory": "mem_00000000000000000000000000000001", "path": "/workspace/r" }"#,
329        )
330        .unwrap();
331        assert_eq!(cfg.mode, MemoryMountAccess::ReadOnly);
332    }
333
334    #[test]
335    fn validate_rejects_non_mem_prefix() {
336        let cfg = MemoryMountConfig {
337            memory: "agent_x".into(),
338            path: "/workspace/r".into(),
339            mode: MemoryMountAccess::ReadOnly,
340        };
341        assert!(validate_mount_config_shape(&cfg).is_err());
342    }
343
344    #[test]
345    fn validate_rejects_path_outside_workspace() {
346        let cfg = MemoryMountConfig {
347            memory: "mem_00000000000000000000000000000001".into(),
348            path: "/etc/passwd".into(),
349            mode: MemoryMountAccess::ReadOnly,
350        };
351        assert!(validate_mount_config_shape(&cfg).is_err());
352    }
353
354    #[test]
355    fn validate_rejects_workspace_prefix_lookalike() {
356        // /workspacefoo must NOT pass the /workspace boundary check.
357        let cfg = MemoryMountConfig {
358            memory: "mem_00000000000000000000000000000001".into(),
359            path: "/workspacefoo".into(),
360            mode: MemoryMountAccess::ReadOnly,
361        };
362        assert!(validate_mount_config_shape(&cfg).is_err());
363    }
364
365    #[test]
366    fn validate_accepts_workspace_root() {
367        let cfg = MemoryMountConfig {
368            memory: "mem_00000000000000000000000000000001".into(),
369            path: "/workspace".into(),
370            mode: MemoryMountAccess::ReadOnly,
371        };
372        assert!(validate_mount_config_shape(&cfg).is_ok());
373    }
374
375    #[test]
376    fn validate_rejects_invalid_hex_in_memory_id() {
377        // mem_-prefixed but not 32 lowercase hex chars must be rejected so
378        // structurally invalid IDs cannot reach the database.
379        let cfg = MemoryMountConfig {
380            memory: "mem_not-hex".into(),
381            path: "/workspace/r".into(),
382            mode: MemoryMountAccess::ReadOnly,
383        };
384        assert!(validate_mount_config_shape(&cfg).is_err());
385    }
386
387    #[test]
388    fn validate_rejects_dotdot() {
389        let cfg = MemoryMountConfig {
390            memory: "mem_00000000000000000000000000000001".into(),
391            path: "/workspace/../etc".into(),
392            mode: MemoryMountAccess::ReadOnly,
393        };
394        assert!(validate_mount_config_shape(&cfg).is_err());
395    }
396
397    #[test]
398    fn validate_rejects_double_slash() {
399        let cfg = MemoryMountConfig {
400            memory: "mem_00000000000000000000000000000001".into(),
401            path: "/workspace//data".into(),
402            mode: MemoryMountAccess::ReadOnly,
403        };
404        assert!(validate_mount_config_shape(&cfg).is_err());
405    }
406
407    #[test]
408    fn validate_rejects_trailing_slash() {
409        let cfg = MemoryMountConfig {
410            memory: "mem_00000000000000000000000000000001".into(),
411            path: "/workspace/data/".into(),
412            mode: MemoryMountAccess::ReadOnly,
413        };
414        assert!(validate_mount_config_shape(&cfg).is_err());
415    }
416
417    #[test]
418    fn validate_accepts_valid_mount() {
419        let cfg = MemoryMountConfig {
420            memory: "mem_00000000000000000000000000000001".into(),
421            path: "/workspace/research".into(),
422            mode: MemoryMountAccess::ReadOnly,
423        };
424        assert!(validate_mount_config_shape(&cfg).is_ok());
425    }
426
427    #[test]
428    fn config_validate_rejects_duplicate_paths() {
429        let cfg = MemoryConfig {
430            mounts: vec![
431                MemoryMountConfig {
432                    memory: "mem_00000000000000000000000000000001".into(),
433                    path: "/workspace/data".into(),
434                    mode: MemoryMountAccess::ReadOnly,
435                },
436                MemoryMountConfig {
437                    memory: "mem_00000000000000000000000000000002".into(),
438                    path: "/workspace/data".into(),
439                    mode: MemoryMountAccess::ReadWrite,
440                },
441            ],
442        };
443        let err = validate_memory_config(&cfg).unwrap_err();
444        assert!(err.contains("duplicate"));
445    }
446
447    #[test]
448    fn config_validate_rejects_overlapping_paths() {
449        let cfg = MemoryConfig {
450            mounts: vec![
451                MemoryMountConfig {
452                    memory: "mem_00000000000000000000000000000001".into(),
453                    path: "/workspace/data".into(),
454                    mode: MemoryMountAccess::ReadOnly,
455                },
456                MemoryMountConfig {
457                    memory: "mem_00000000000000000000000000000002".into(),
458                    path: "/workspace/data/sub".into(),
459                    mode: MemoryMountAccess::ReadWrite,
460                },
461            ],
462        };
463        let err = validate_memory_config(&cfg).unwrap_err();
464        assert!(err.contains("overlapping"));
465    }
466
467    #[test]
468    fn config_validate_accepts_distinct_paths() {
469        let cfg = MemoryConfig {
470            mounts: vec![
471                MemoryMountConfig {
472                    memory: "mem_00000000000000000000000000000001".into(),
473                    path: "/workspace/data".into(),
474                    mode: MemoryMountAccess::ReadOnly,
475                },
476                MemoryMountConfig {
477                    memory: "mem_00000000000000000000000000000002".into(),
478                    path: "/workspace/notes".into(),
479                    mode: MemoryMountAccess::ReadWrite,
480                },
481            ],
482        };
483        assert!(validate_memory_config(&cfg).is_ok());
484    }
485
486    #[test]
487    fn overlap_helper_does_not_match_unrelated_prefix() {
488        // /workspace/data and /workspace/datasets must NOT overlap.
489        assert!(!mount_paths_overlap(
490            "/workspace/data",
491            "/workspace/datasets"
492        ));
493    }
494}