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