Skip to main content

agent_engine/memory_backend/
mod.rs

1//! Host-owned exclusive memory routing. Backend selection is immutable per
2//! binding; an Axel failure never opens the legacy note store. Configuration
3//! selects a process, never a shell command or a model-controlled namespace.
4use crate::{Result, RuntimeError};
5use agent_core::config::{MemoryBackendConfig, MemoryBackendKind};
6use agent_core::memory::forum::Author;
7use agent_core::memory::store::{
8    self, MemoryDescriptor, MemoryRecord, MemorySensitivity, NewMemoryRecord, ProjectMemoryQuery,
9    ProjectScope,
10};
11use serde_json::{json, Value};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14mod competing;
15pub mod forum;
16mod history;
17pub mod migration;
18mod process;
19mod provisioning;
20pub mod repository_migration;
21
22pub fn competing_note_tool(name: &str) -> bool {
23    matches!(
24        name,
25        "memory_store"
26            | "memory_search"
27            | "memory_fetch"
28            | "memory_forget"
29            | "memory_capture"
30            | "memory_recall"
31    )
32}
33pub use competing::competing_memory_tool;
34
35pub const CONTRACT: &str = "synaps-axel/2";
36pub const USER_SCOPE: &str = "p0000000000000000";
37pub const AXEL_REVISION: &str = "edbdea401d66feedb87fcad28c879ece54e3ccd2";
38
39#[derive(Clone)]
40pub struct MemoryBinding(Arc<Binding>);
41impl std::fmt::Debug for MemoryBinding {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("MemoryBinding")
44            .field("exclusive", &self.exclusive())
45            .finish_non_exhaustive()
46    }
47}
48#[derive(Clone)]
49struct Binding {
50    base: PathBuf,
51    scope: std::result::Result<ProjectScope, String>,
52    author: Author,
53    operation_lock: Arc<tokio::sync::Mutex<()>>,
54    repository: Option<agent_core::memory::repository::RepositoryIdentity>,
55    user_scope_enabled: bool,
56    managed_parent: bool,
57    backend: Backend,
58}
59#[derive(Clone)]
60enum Backend {
61    Legacy,
62    Axel { executable: PathBuf, brain: PathBuf },
63    Unavailable,
64}
65fn error(message: impl Into<String>) -> RuntimeError {
66    RuntimeError::Tool(format!("memory: {}", message.into()))
67}
68
69impl MemoryBinding {
70    /// Production bootstrap without a Runtime capability (legacy extensions).
71    /// Selection is captured before any subprocess starts; never flips to legacy
72    /// when an explicitly selected Axel service is unavailable.
73    pub fn configured_current() -> Self {
74        Self::from_config(&agent_core::config::load_config().memory_backend)
75    }
76    pub fn legacy_current() -> Self {
77        Self::from_config(&MemoryBackendConfig::default())
78    }
79    pub fn from_config(config: &MemoryBackendConfig) -> Self {
80        Self::from_config_with_cwd(config, std::env::current_dir().ok())
81    }
82    /// Same as [`from_config`](Self::from_config) but with an explicit cwd
83    /// (daemon sessions run with a per-session working directory that may
84    /// differ from the process cwd).
85    pub fn from_config_with_cwd(
86        config: &MemoryBackendConfig,
87        cwd: Option<std::path::PathBuf>,
88    ) -> Self {
89        let base = agent_core::config::base_dir();
90        let repository = if config.kind == MemoryBackendKind::Axel {
91            cwd.as_ref()
92                .map(|cwd| ProjectScope::discover_repository(cwd))
93        } else {
94            None
95        };
96        let scope = match &repository {
97            Some(Ok(identity)) => Ok(identity.scope.clone()),
98            Some(Err(_)) => {
99                Err("repository memory identity unavailable; no path-scope fallback".into())
100            }
101            None => cwd
102                .ok_or_else(|| "workspace unavailable".to_owned())
103                .and_then(|cwd| {
104                    ProjectScope::discover(&cwd).map_err(|_| "project scope unavailable".to_owned())
105                }),
106        };
107        let mut binding = Self::new(base, scope, config);
108        Arc::get_mut(&mut binding.0)
109            .expect("new binding")
110            .repository = repository.and_then(std::result::Result::ok);
111        binding
112    }
113    fn new(
114        base: PathBuf,
115        scope: std::result::Result<ProjectScope, String>,
116        config: &MemoryBackendConfig,
117    ) -> Self {
118        let backend = match config.kind {
119            MemoryBackendKind::Legacy => Backend::Legacy,
120            MemoryBackendKind::Axel => {
121                let executable = config.executable.clone().or_else(|| {
122                    std::env::current_exe().ok().and_then(|path| {
123                        path.parent()
124                            .map(|parent| parent.join("synaps-axel-memory-service"))
125                    })
126                });
127                let brain = config
128                    .brain
129                    .clone()
130                    .unwrap_or_else(|| base.join("memory/axel/brain.r8"));
131                match executable {
132                    Some(executable) if executable.is_absolute() && brain.is_absolute() => {
133                        Backend::Axel { executable, brain }
134                    }
135                    _ => Backend::Unavailable,
136                }
137            }
138            MemoryBackendKind::Unavailable => Backend::Unavailable,
139        };
140        Self(Arc::new(Binding {
141            base,
142            scope,
143            author: Author::fresh(),
144            operation_lock: Arc::new(tokio::sync::Mutex::new(())),
145            repository: None,
146            user_scope_enabled: config.user_scope,
147            managed_parent: config.brain.is_none(),
148            backend,
149        }))
150    }
151    /// Captured host identity; metadata only. Never grants an alias merge.
152    pub fn repository_identity(
153        &self,
154    ) -> Result<agent_core::memory::repository::RepositoryIdentity> {
155        self.0
156            .repository
157            .clone()
158            .ok_or_else(|| error("repository identity unavailable"))
159    }
160    /// Immutable execution provenance, not a saved conversation identity or
161    /// an authorization grant. Ordinary clones retain this exact author.
162    pub fn forum_author(&self) -> &Author {
163        &self.0.author
164    }
165    /// New worker execution: fork only the author, retaining captured storage
166    /// authority (including scope errors) and the shared operation lock.
167    /// Resumed workers get a new actor too; no rediscovery or config reload.
168    pub fn fork_for_worker(&self) -> Self {
169        Self(Arc::new(Binding {
170            author: self.0.author.child(),
171            ..(*self.0).clone()
172        }))
173    }
174    /// Operator-only rebinding for exact verified source scopes during migration.
175    /// Never expose an arbitrary project selector through model tools.
176    pub fn with_scope(&self, scope: ProjectScope) -> Self {
177        Self(Arc::new(Binding {
178            scope: Ok(scope),
179            ..(*self.0).clone()
180        }))
181    }
182    /// Only explicit note tools may select the reserved user scope. Capture,
183    /// history, and automatic recall retain their repository binding.
184    pub fn for_user_notes(&self) -> Result<Self> {
185        if !self.is_axel() || !self.0.user_scope_enabled {
186            return Err(error("user-wide notes require Axel and explicit memory.user_scope = true; no automatic sharing"));
187        }
188        let scope =
189            ProjectScope::user_scope(&self.0.base).map_err(|_| error("user scope unavailable"))?;
190        Ok(self.with_scope(scope))
191    }
192    pub fn brain_path(&self) -> Option<&Path> {
193        match &self.0.backend {
194            Backend::Axel { brain, .. } => Some(brain),
195            _ => None,
196        }
197    }
198    pub fn is_axel(&self) -> bool {
199        matches!(self.0.backend, Backend::Axel { .. }) && self.0.scope.is_ok()
200    }
201    /// Pure configuration validation for consent grants; does not open storage
202    /// or start a process. Execution performs stronger filesystem checks again.
203    pub fn validate_axel_config(&self) -> Result<()> {
204        match &self.0.backend {
205            Backend::Axel { executable, brain } if self.0.scope.is_ok() => {
206                process::validate_configuration(executable, brain)
207            }
208            _ => Err(error("Axel backend is not configured; no fallback")),
209        }
210    }
211    pub fn exclusive(&self) -> bool {
212        !matches!(self.0.backend, Backend::Legacy)
213    }
214    pub fn scope(&self) -> Result<&ProjectScope> {
215        self.0
216            .scope
217            .as_ref()
218            .map_err(|_| error("host project scope unavailable"))
219    }
220    pub fn base(&self) -> &Path {
221        &self.0.base
222    }
223    /// Exact host-only RPC, never selected by model-authored paths or projects.
224    /// Operator migration/export calls use this same backend and transaction boundary.
225    pub async fn rpc(&self, operation: &str, payload: Value) -> Result<Value> {
226        self.call(operation, payload).await
227    }
228    /// Atomically import an operator-approved source manifest into this one backend.
229    /// The migration module owns preview/consent/source validation before this call.
230    pub async fn migration_apply(&self, payload: Value) -> Result<Value> {
231        self.rpc("migration_apply", payload).await
232    }
233    /// Query feature availability without touching any legacy memory surface.
234    pub async fn capabilities(&self) -> Result<Value> {
235        self.rpc("capabilities", json!({})).await
236    }
237    async fn call(&self, operation: &str, payload: Value) -> Result<Value> {
238        let _guard = self.0.operation_lock.lock().await;
239        let scope = self.scope()?;
240        if scope.key() == USER_SCOPE
241            && !matches!(
242                operation,
243                "store" | "search" | "fetch" | "forget" | "scope_info" | "capabilities"
244            )
245        {
246            return Err(error("user-wide scope supports explicit notes only"));
247        }
248        match &self.0.backend {
249            Backend::Axel { executable, brain } => {
250                // Invalid executable/configuration must not provision storage.
251                process::validate_configuration(executable, brain)?;
252                if self.0.managed_parent
253                    && !matches!(
254                        operation,
255                        "capabilities" | "legacy_export" | "native_export" | "upgrade_preview"
256                    )
257                {
258                    let base = self.0.base.clone();
259                    let brain = brain.clone();
260                    tokio::task::spawn_blocking(move || {
261                        provisioning::ensure_default_parent(&base, &brain)
262                    })
263                    .await
264                    .map_err(|_| error("Axel parent provisioning worker failed"))??;
265                }
266                process::call(executable, brain, scope.key(), operation, payload).await
267            }
268            _ => Err(error("selected backend unavailable; no legacy fallback")),
269        }
270    }
271    /// Validate original source scopes without rewriting their provenance. Only
272    /// the selected service's operator-established membership can widen reads.
273    async fn authorized_projects(&self, projects: &[&str]) -> Result<Vec<String>> {
274        let current = self.scope()?.key();
275        if projects.iter().all(|project| *project == current) {
276            return Ok(vec![current.to_owned()]);
277        }
278        let info = self.call("scope_info", json!({})).await?;
279        let members: Vec<String> = serde_json::from_value(info["members"].clone())
280            .map_err(|_| error("invalid backend scope membership"))?;
281        let canonical = info["canonical_project"]
282            .as_str()
283            .ok_or_else(|| error("missing backend canonical scope"))?;
284        let valid = |key: &str| {
285            key.len() == 17
286                && key.starts_with('p')
287                && key[1..]
288                    .bytes()
289                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
290        };
291        if members.is_empty()
292            || members.len() > 4096
293            || !valid(canonical)
294            || !members.iter().all(|key| valid(key))
295            || !members.iter().any(|key| key == current)
296            || !members.iter().any(|key| key == canonical)
297            || !projects
298                .iter()
299                .all(|project| members.iter().any(|member| member == project))
300            || (members.iter().any(|key| key == USER_SCOPE)
301                && (members.len() != 1 || current != USER_SCOPE))
302        {
303            return Err(error("backend scope membership violates isolation"));
304        }
305        Ok(members)
306    }
307    pub async fn search(&self, q: ProjectMemoryQuery) -> Result<Vec<MemoryDescriptor>> {
308        let scope = self.scope()?.clone();
309        if matches!(self.0.backend, Backend::Legacy) {
310            let base = self.0.base.clone();
311            return tokio::task::spawn_blocking(move || {
312                store::search_project_in(&base, &scope, &q)
313            })
314            .await
315            .map_err(|_| error("legacy search worker failed"))?
316            .map_err(|e| error(e.to_string()));
317        }
318        let limit = q
319            .limit
320            .unwrap_or(store::DEFAULT_SEARCH_LIMIT)
321            .min(store::MAX_SEARCH_LIMIT);
322        let snippet_bytes = q
323            .snippet_bytes
324            .unwrap_or(store::DEFAULT_SNIPPET_BYTES)
325            .min(store::MAX_SNIPPET_BYTES);
326        let value = self.call("search", json!({"content_contains":q.content_contains,"tag_prefix":q.tag_prefix,"since_ms":q.since_ms,"until_ms":q.until_ms,"limit":limit,"snippet_bytes":snippet_bytes})).await?;
327        let rows = value
328            .as_array()
329            .ok_or_else(|| error("invalid backend search response"))?;
330        if rows.len() > limit {
331            return Err(error("backend exceeded search bound"));
332        }
333        let source_projects = rows
334            .iter()
335            .map(|row| row["project"].as_str().unwrap_or(""))
336            .collect::<Vec<_>>();
337        let members = self.authorized_projects(&source_projects).await?;
338        rows.iter()
339            .map(|row| {
340                let d: Descriptor = serde_json::from_value(row.clone())
341                    .map_err(|_| error("invalid backend descriptor"))?;
342                if !members.contains(&d.project)
343                    || d.id.is_empty()
344                    || d.id.len() > 128
345                    || d.snippet.len() > snippet_bytes
346                    || (d.sensitivity == MemorySensitivity::Secret && !d.snippet.is_empty())
347                {
348                    return Err(error("backend descriptor violates scope/disclosure/bounds"));
349                }
350                Ok(MemoryDescriptor {
351                    id: d.id,
352                    project: d.project,
353                    timestamp_ms: d.timestamp_ms,
354                    tags: d.tags,
355                    snippet: d.snippet,
356                    truncated: d.truncated,
357                    content_bytes: d.content_bytes,
358                    sensitivity: d.sensitivity,
359                    retention: d.retention,
360                })
361            })
362            .collect()
363    }
364    pub async fn fetch(&self, ids: &[&str]) -> Result<Vec<MemoryRecord>> {
365        let scope = self.scope()?.clone();
366        if ids.is_empty() || ids.len() > 25 || ids.iter().any(|id| id.is_empty() || id.len() > 128)
367        {
368            return Err(error("fetch requires 1..=25 bounded exact IDs"));
369        }
370        if matches!(self.0.backend, Backend::Legacy) {
371            let base = self.0.base.clone();
372            let ids = ids.iter().map(|s| s.to_string()).collect::<Vec<_>>();
373            return tokio::task::spawn_blocking(move || {
374                store::fetch_exact_in(
375                    &base,
376                    &scope,
377                    &ids.iter().map(String::as_str).collect::<Vec<_>>(),
378                )
379            })
380            .await
381            .map_err(|_| error("legacy fetch worker failed"))?
382            .map_err(|e| error(e.to_string()));
383        }
384        let records: Vec<MemoryRecord> =
385            serde_json::from_value(self.call("fetch", json!({"ids":ids})).await?)
386                .map_err(|_| error("invalid backend fetch response"))?;
387        if records.len() != ids.len() {
388            return Err(error("backend fetch is incomplete"));
389        }
390        let source_projects = records
391            .iter()
392            .map(|record| record.project.as_deref().unwrap_or(""))
393            .collect::<Vec<_>>();
394        let members = self.authorized_projects(&source_projects).await?;
395        for (record, id) in records.iter().zip(ids) {
396            validate_record_members(record, &members, id)?;
397        }
398        Ok(records)
399    }
400    pub async fn store(&self, new: NewMemoryRecord) -> Result<MemoryRecord> {
401        let scope = self.scope()?.clone();
402        if new.content.len() > store::MAX_CONTENT_BYTES {
403            return Err(error("content exceeds 16384 bytes"));
404        }
405        if matches!(self.0.backend, Backend::Legacy) {
406            let base = self.0.base.clone();
407            return tokio::task::spawn_blocking(move || store::store_record_in(&base, &scope, new))
408                .await
409                .map_err(|_| error("legacy store worker failed"))?
410                .map_err(|e| error(e.to_string()));
411        }
412        let record = MemoryRecord {
413            namespace: scope.namespace(),
414            timestamp_ms: store::now_ms(),
415            content: new.content,
416            tags: new.tags,
417            meta: self
418                .0
419                .repository
420                .as_ref()
421                .filter(|_| scope.key() != USER_SCOPE)
422                .map(|identity| {
423                    json!({
424                        "_synaps_repository": {
425                            "repository_project": identity.scope.key(),
426                            "source_worktree_project": identity.legacy_scope.key(),
427                            "source_worktree_root": identity.worktree_root,
428                        }
429                    })
430                }),
431            id: Some(format!("mem-{}", uuid::Uuid::new_v4().simple())),
432            project: Some(scope.key().into()),
433            provenance: Some(new.provenance),
434            sensitivity: Some(new.sensitivity),
435            retention: Some(new.retention),
436        };
437        let saved: MemoryRecord = serde_json::from_value(
438            self.call(
439                "store",
440                serde_json::to_value(&record).map_err(|_| error("record serialization failed"))?,
441            )
442            .await.map_err(|cause| error(format!("store outcome unconfirmed for {}: {cause}; fetch this exact ID to reconcile, never automatically create a replacement", record.id.as_deref().unwrap())))?,
443        )
444        .map_err(|_| {
445            error(format!(
446                "invalid backend store acknowledgement; commit unknown for {}; do not automatically retry", record.id.as_deref().unwrap()
447            ))
448        })?;
449        validate_record(&saved, &scope, record.id.as_deref().unwrap())?;
450        let mut expected = record;
451        if expected.sensitivity == Some(MemorySensitivity::Secret) {
452            expected.content.clear();
453        }
454        if saved != expected {
455            return Err(error("backend store acknowledgement mismatch; commit unknown, do not automatically retry"));
456        }
457        Ok(saved)
458    }
459    pub async fn forget(&self, id: &str) -> Result<()> {
460        let scope = self.scope()?.clone();
461        if id.is_empty() || id.len() > 128 {
462            return Err(error("invalid exact ID"));
463        }
464        if matches!(self.0.backend, Backend::Legacy) {
465            let base = self.0.base.clone();
466            let id = id.to_owned();
467            return tokio::task::spawn_blocking(move || store::forget_in(&base, &scope, &id))
468                .await
469                .map_err(|_| error("legacy forget worker failed"))?
470                .map_err(|e| error(e.to_string()));
471        }
472        match self.call("forget", json!({"id":id})).await? {
473            Value::Bool(true) => Ok(()),
474            Value::Bool(false) => Err(error("record not found in this project scope")),
475            _ => Err(error("invalid forget acknowledgement; commit unknown")),
476        }
477    }
478}
479fn validate_record(record: &MemoryRecord, scope: &ProjectScope, id: &str) -> Result<()> {
480    validate_record_members(record, &[scope.key().to_owned()], id)
481}
482fn validate_record_members(record: &MemoryRecord, members: &[String], id: &str) -> Result<()> {
483    if record.id.as_deref() != Some(id)
484        || !record
485            .project
486            .as_ref()
487            .is_some_and(|project| members.contains(project))
488        || (record.namespace != format!("project-{}", record.project.as_deref().unwrap_or(""))
489            && !(record.namespace == "captures"
490                && id.starts_with("mem-cap-")
491                && record
492                    .provenance
493                    .as_ref()
494                    .is_some_and(|p| p.source == "capture")))
495        || record.content.len() > store::MAX_CONTENT_BYTES
496        || record.sensitivity.is_none()
497        || record.retention.is_none()
498        || (record.sensitivity == Some(MemorySensitivity::Secret) && !record.content.is_empty())
499    {
500        return Err(error("backend record violates scope/disclosure/bounds"));
501    }
502    Ok(())
503}
504#[derive(serde::Deserialize)]
505#[serde(deny_unknown_fields)]
506struct Descriptor {
507    id: String,
508    project: String,
509    timestamp_ms: u64,
510    tags: Vec<String>,
511    snippet: String,
512    truncated: bool,
513    content_bytes: usize,
514    sensitivity: MemorySensitivity,
515    retention: store::MemoryRetention,
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    fn assert_same_storage(parent: &MemoryBinding, child: &MemoryBinding) {
523        assert_eq!(child.0.base, parent.0.base);
524        assert_eq!(child.0.scope, parent.0.scope);
525        assert_eq!(child.0.repository, parent.0.repository);
526        assert_eq!(child.0.user_scope_enabled, parent.0.user_scope_enabled);
527        assert_eq!(child.0.managed_parent, parent.0.managed_parent);
528        assert_eq!(child.exclusive(), parent.exclusive());
529        assert_eq!(child.is_axel(), parent.is_axel());
530        assert!(Arc::ptr_eq(
531            &child.0.operation_lock,
532            &parent.0.operation_lock
533        ));
534        match (&parent.0.backend, &child.0.backend) {
535            (Backend::Legacy, Backend::Legacy) | (Backend::Unavailable, Backend::Unavailable) => {}
536            (
537                Backend::Axel {
538                    executable: a,
539                    brain: b,
540                },
541                Backend::Axel {
542                    executable: c,
543                    brain: d,
544                },
545            ) => {
546                assert_eq!(a, c);
547                assert_eq!(b, d);
548            }
549            _ => panic!("worker changed the selected backend"),
550        }
551    }
552
553    #[test]
554    fn forum_author_is_fresh_but_clones_and_scope_rebindings_keep_identity() {
555        let dir = tempfile::tempdir().unwrap();
556        let scope = ProjectScope::for_root(dir.path()).unwrap();
557        let parent = MemoryBinding::new(dir.path().into(), Ok(scope.clone()), &Default::default());
558        let fresh = MemoryBinding::new(dir.path().into(), Ok(scope), &Default::default());
559        parent.forum_author().validate().unwrap();
560        assert!(parent.forum_author().parent.is_none());
561        assert_ne!(parent.forum_author().actor, fresh.forum_author().actor);
562        assert_ne!(parent.forum_author().group, fresh.forum_author().group);
563        let clone = parent.clone();
564        assert!(Arc::ptr_eq(&parent.0, &clone.0));
565        assert_eq!(clone.forum_author(), parent.forum_author());
566
567        let other = tempfile::tempdir().unwrap();
568        let other_scope = ProjectScope::for_root(other.path()).unwrap();
569        let worker = parent.fork_for_worker();
570        for binding in [&parent, &worker] {
571            let scoped = binding.with_scope(other_scope.clone());
572            assert_eq!(scoped.scope().unwrap(), &other_scope);
573            assert_eq!(scoped.forum_author(), binding.forum_author());
574            // Restoring the scope must reveal otherwise identical storage state.
575            assert_same_storage(
576                binding,
577                &scoped.with_scope(binding.scope().unwrap().clone()),
578            );
579        }
580    }
581
582    #[test]
583    fn forum_worker_forks_only_author_even_with_bad_scope_or_unavailable_backend() {
584        let dir = tempfile::tempdir().unwrap();
585        let root = dir.path().canonicalize().unwrap();
586        let scope = ProjectScope::for_root(&root).unwrap();
587        let repository = agent_core::memory::repository::RepositoryIdentity {
588            scope: scope.clone(),
589            legacy_scope: scope.clone(),
590            aliases: vec![scope.clone()],
591            repository_root: root.clone(),
592            worktree_root: root.clone(),
593            common_dir: Some(root.join("synthetic-common")),
594        };
595        for kind in [
596            MemoryBackendKind::Legacy,
597            MemoryBackendKind::Axel,
598            MemoryBackendKind::Unavailable,
599        ] {
600            for bad_scope in [false, true] {
601                for user_scope in [false, true] {
602                    for managed_parent in [false, true] {
603                        let config = MemoryBackendConfig {
604                            kind,
605                            executable: Some(root.join("unused-service")),
606                            brain: (!managed_parent).then(|| root.join("custom-brain.r8")),
607                            user_scope,
608                        };
609                        let selected_scope = if bad_scope {
610                            Err("captured repository discovery failure".into())
611                        } else {
612                            Ok(scope.clone())
613                        };
614                        let mut parent = MemoryBinding::new(root.clone(), selected_scope, &config);
615                        Arc::get_mut(&mut parent.0).unwrap().repository = Some(repository.clone());
616                        let first = parent.fork_for_worker();
617                        let second = parent.fork_for_worker();
618                        for worker in [&first, &second] {
619                            assert_same_storage(&parent, worker);
620                            assert!(!Arc::ptr_eq(&parent.0, &worker.0));
621                            worker.forum_author().validate().unwrap();
622                            assert_ne!(worker.forum_author().actor, parent.forum_author().actor);
623                            assert_eq!(worker.forum_author().group, parent.forum_author().group);
624                            assert_eq!(
625                                worker.forum_author().parent.as_deref(),
626                                Some(parent.forum_author().actor.as_str())
627                            );
628                            assert_eq!(worker.clone().forum_author(), worker.forum_author());
629                        }
630                        assert_ne!(first.forum_author().actor, second.forum_author().actor);
631                        let grandchild = first.fork_for_worker();
632                        assert_same_storage(&first, &grandchild);
633                        assert_eq!(grandchild.forum_author().group, parent.forum_author().group);
634                        assert_eq!(
635                            grandchild.forum_author().parent.as_deref(),
636                            Some(first.forum_author().actor.as_str())
637                        );
638                        assert!(parent.forum_author().parent.is_none());
639                    }
640                }
641            }
642        }
643        assert!(!root.join("memory").exists());
644        assert!(!root.join("custom-brain.r8").exists());
645    }
646
647    #[test]
648    fn forum_worker_preserves_user_note_opt_in_and_user_scope_without_widening() {
649        let dir = tempfile::tempdir().unwrap();
650        let root = dir.path().canonicalize().unwrap();
651        let scope = ProjectScope::for_root(&root).unwrap();
652        for user_scope in [false, true] {
653            let parent = MemoryBinding::new(
654                root.clone(),
655                Ok(scope.clone()),
656                &MemoryBackendConfig {
657                    kind: MemoryBackendKind::Axel,
658                    executable: Some(root.join("unused-service")),
659                    brain: Some(root.join("brain.r8")),
660                    user_scope,
661                },
662            );
663            let worker = parent.fork_for_worker();
664            assert_eq!(worker.for_user_notes().is_ok(), user_scope);
665            if user_scope {
666                let user = parent.for_user_notes().unwrap();
667                assert_eq!(user.forum_author(), parent.forum_author());
668                let user_worker = user.fork_for_worker();
669                assert_eq!(user_worker.scope().unwrap().key(), USER_SCOPE);
670                assert_same_storage(&user, &user_worker);
671            }
672        }
673    }
674
675    #[test]
676    fn forum_workers_and_scope_rebindings_share_the_operation_lock() {
677        let dir = tempfile::tempdir().unwrap();
678        let scope = ProjectScope::for_root(dir.path()).unwrap();
679        let parent = MemoryBinding::new(dir.path().into(), Ok(scope.clone()), &Default::default());
680        let worker = parent.fork_for_worker();
681        let sibling = parent.fork_for_worker();
682        let scoped = worker.with_scope(scope);
683        let guard = parent.0.operation_lock.try_lock().unwrap();
684        for binding in [&worker, &sibling, &scoped] {
685            assert!(Arc::ptr_eq(
686                &parent.0.operation_lock,
687                &binding.0.operation_lock
688            ));
689            assert!(binding.0.operation_lock.try_lock().is_err());
690        }
691        drop(guard);
692        let worker_guard = worker.0.operation_lock.try_lock().unwrap();
693        assert!(parent.0.operation_lock.try_lock().is_err());
694        assert!(sibling.0.operation_lock.try_lock().is_err());
695        drop(worker_guard);
696        assert!(scoped.0.operation_lock.try_lock().is_ok());
697    }
698
699    /// Explicit opt-in integration test uses only a fresh temp brain and scope.
700    /// Run with SYNAPS_AXEL_TEST_BIN=/absolute/service cargo test ... --ignored.
701    #[tokio::test]
702    #[ignore = "requires separately built pinned Axel service; synthetic temp storage only"]
703    async fn real_axel_service_roundtrip_without_legacy_store() {
704        let exe =
705            PathBuf::from(std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit test binary"));
706        let tmp = tempfile::tempdir().unwrap();
707        let root = tmp.path().canonicalize().unwrap();
708        #[cfg(unix)]
709        {
710            use std::os::unix::fs::PermissionsExt;
711            std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
712        }
713        let scope = ProjectScope::for_root(&root).unwrap();
714        let binding = MemoryBinding::new(
715            root.clone(),
716            Ok(scope.clone()),
717            &MemoryBackendConfig {
718                kind: MemoryBackendKind::Axel,
719                executable: Some(exe.clone()),
720                brain: Some(root.join("brain.r8")),
721                user_scope: false,
722            },
723        );
724        for sensitivity in [
725            MemorySensitivity::Normal,
726            MemorySensitivity::Sensitive,
727            MemorySensitivity::Secret,
728        ] {
729            let saved = binding
730                .store(NewMemoryRecord {
731                    content: "Ünicode cobalt_27".into(),
732                    tags: vec!["tag_%literal".into()],
733                    provenance: store::MemoryProvenance {
734                        source: "model".into(),
735                        session: Some(format!("synthetic-{sensitivity:?}")),
736                    },
737                    sensitivity,
738                    retention: store::MemoryRetention::MaxAgeDays(365),
739                })
740                .await
741                .unwrap();
742            let id = saved.id.unwrap();
743            let rows = binding
744                .search(ProjectMemoryQuery {
745                    content_contains: Some("ÜNICODE".into()),
746                    tag_prefix: Some("tag_%".into()),
747                    ..Default::default()
748                })
749                .await
750                .unwrap();
751            assert_eq!(
752                rows.iter().any(|row| row.id == id),
753                sensitivity != MemorySensitivity::Secret
754            );
755            let fetched = binding.fetch(&[&id]).await.unwrap();
756            assert_eq!(
757                fetched[0].content,
758                if sensitivity == MemorySensitivity::Secret {
759                    ""
760                } else {
761                    "Ünicode cobalt_27"
762                }
763            );
764            assert_eq!(fetched[0].sensitivity, Some(sensitivity));
765            binding.forget(&id).await.unwrap();
766            assert!(binding.fetch(&[&id]).await.is_err());
767        }
768        // Exercise the public short tool surface with the same captured binding.
769        use crate::tools::Tool;
770        let context = || {
771            let mut ctx = crate::tools::test_helpers::create_tool_context();
772            ctx.capabilities.memory_backend = Some(binding.clone());
773            ctx
774        };
775        let stored = crate::tools::memory::MemoryStoreTool
776            .execute(
777                json!({"content":"SHORT_TOOL_AXEL_CANARY","sensitivity":"normal"}),
778                context(),
779            )
780            .await
781            .unwrap();
782        let tool_id = stored.split_whitespace().nth(2).unwrap();
783        let searched = crate::tools::memory::MemorySearchTool
784            .execute(json!({"query":"SHORT_TOOL_AXEL_CANARY"}), context())
785            .await
786            .unwrap();
787        assert!(searched.contains(tool_id));
788        let fetched = crate::tools::memory::MemoryFetchTool
789            .execute(json!({"ids":[tool_id]}), context())
790            .await
791            .unwrap();
792        assert!(fetched.contains("SHORT_TOOL_AXEL_CANARY"));
793        crate::tools::memory::MemoryForgetTool
794            .execute(json!({"id":tool_id}), context())
795            .await
796            .unwrap();
797        assert!(!root.join("memory").exists());
798        assert!(binding.history_search("", 8).await.is_ok());
799        assert!(!root.join("context-archives").exists());
800        let other = tempfile::tempdir().unwrap();
801        let foreign = MemoryBinding::new(
802            root.clone(),
803            Ok(ProjectScope::for_root(other.path()).unwrap()),
804            &MemoryBackendConfig {
805                kind: MemoryBackendKind::Axel,
806                executable: Some(exe),
807                brain: Some(root.join("brain.r8")),
808                user_scope: false,
809            },
810        );
811        assert!(foreign.search(Default::default()).await.unwrap().is_empty());
812    }
813
814    #[tokio::test]
815    async fn unavailable_axel_never_opens_legacy_store() {
816        let dir = tempfile::tempdir().unwrap();
817        let root = dir.path().canonicalize().unwrap();
818        let scope = ProjectScope::for_root(&root).unwrap();
819        let cfg = MemoryBackendConfig {
820            kind: MemoryBackendKind::Axel,
821            executable: None,
822            brain: None,
823            user_scope: false,
824        };
825        let binding = MemoryBinding::new(root.clone(), Ok(scope), &cfg);
826        assert!(binding.exclusive());
827        assert!(binding.search(Default::default()).await.is_err());
828        assert!(binding.fetch(&["mem-unknown"]).await.is_err());
829        assert!(binding.forget("mem-unknown").await.is_err());
830        assert!(binding
831            .store(NewMemoryRecord {
832                content: "note".into(),
833                tags: vec![],
834                provenance: store::MemoryProvenance {
835                    source: "model".into(),
836                    session: None
837                },
838                sensitivity: MemorySensitivity::Normal,
839                retention: store::MemoryRetention::Standard
840            })
841            .await
842            .is_err());
843        assert!(!root.join("memory").exists());
844    }
845}
846
847#[cfg(test)]
848mod shared_tests;