//! FileGuard-mediated context & memory use cases (cadrage C7). //! //! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`], //! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory //! through the domain [`FileGuard`] port **before** touching a store. Each acquires //! the right lease (shared read / exclusive write) for the requesting //! [`ConversationParty`], then delegates to the existing store ports. //! //! ## Single-writer global context //! //! The global project context is single-writer: only the orchestrator //! ([`ConversationParty::User`]) may write it directly. A project agent that //! *proposes* a change to the global context receives [`GuardError::Forbidden`] from //! the guard; [`ProposeContext`] catches that and **materialises a proposal** under //! `.ideai/proposals/-.md` for later validation by the orchestrator/UI — //! never overwriting the live context. An agent's *own* `.md` (and memory) is written //! directly under a write-lease. //! //! ## Cooperative scope (cadrage §9.5) //! //! The guard is **cooperative**: it serialises access inside the IdeA path (these use //! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell — //! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of //! scope here. use std::sync::Arc; use domain::conversation::ConversationParty; use domain::fileguard::{FileGuard, GuardError, GuardedResource}; use domain::markdown::MarkdownDoc; use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath}; use domain::{AgentId, Project}; use crate::error::AppError; /// Convention filename of the project's global context at the project root. const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md"; /// `.ideai/` subdirectory where a rejected global-context change is materialised. const PROPOSALS_DIR: &str = ".ideai/proposals"; /// Joins a project root with a POSIX-relative segment (valid on every target). fn join_root(project: &Project, rel: &str) -> RemotePath { let base = project.root.as_str().trim_end_matches(['/', '\\']); RemotePath::new(format!("{base}/{rel}")) } /// Resolves an agent display name to its [`AgentId`] via the project manifest /// (case-insensitive), or [`AppError::NotFound`]. async fn resolve_agent( contexts: &Arc, project: &Project, name: &str, ) -> Result { let manifest = contexts.load_manifest(project).await?; manifest .entries .into_iter() .find(|e| e.name.eq_ignore_ascii_case(name)) .map(|e| e.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent `{name}`"))) } /// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]). /// /// `target` absent ⇒ the global project context; otherwise the named agent's `.md`. pub struct ReadContext { guard: Arc, contexts: Arc, fs: Arc, } /// Input for [`ReadContext`]. pub struct ReadContextInput { /// The project to read within. pub project: Project, /// Target agent display name; `None` ⇒ the global project context. pub target: Option, /// The reading party (drives the read-lease holder identity). pub requester: ConversationParty, } impl ReadContext { /// Builds the use case from its ports. #[must_use] pub fn new( guard: Arc, contexts: Arc, fs: Arc, ) -> Self { Self { guard, contexts, fs, } } /// Reads the requested context, returning its Markdown body. /// /// # Errors /// [`AppError`] when the agent/context does not exist or the store/fs fails. pub async fn execute(&self, input: ReadContextInput) -> Result { let ReadContextInput { project, target, requester, } = input; match target { None => { // Global project context: shared read-lease, then read the root file. let _lease = self .guard .acquire_read(requester, GuardedResource::ProjectContext) .await .map_err(map_guard_err)?; let path = join_root(&project, PROJECT_CONTEXT_FILE); let bytes = self.fs.read(&path).await?; let text = String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?; Ok(MarkdownDoc::new(text)) } Some(name) => { let agent = resolve_agent(&self.contexts, &project, &name).await?; let _lease = self .guard .acquire_read(requester, GuardedResource::AgentContext(agent)) .await .map_err(map_guard_err)?; Ok(self.contexts.read_context(&project, &agent).await?) } } } } /// Proposes new content for an IdeA-owned context under the [`FileGuard`]. /// /// For an **agent** context: a direct write under an exclusive write-lease. For the /// **global** project context by a non-orchestrator: the guard returns /// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal* /// (a file under `.ideai/proposals/`) — never an overwrite of the live context. pub struct ProposeContext { guard: Arc, contexts: Arc, fs: Arc, clock: Arc, } /// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProposeOutcome { /// The content was written directly (agent context, or global by the orchestrator). Written, /// A proposal was materialised at the given `.ideai/proposals/…` path (a /// non-orchestrator targeting the single-writer global context). Proposed { /// The proposal file's path. path: String, }, } /// Input for [`ProposeContext`]. pub struct ProposeContextInput { /// The project to write within. pub project: Project, /// Target agent display name; `None` ⇒ the global project context. pub target: Option, /// The proposed Markdown body. pub content: String, /// The proposing party. pub requester: ConversationParty, } impl ProposeContext { /// Builds the use case from its ports. #[must_use] pub fn new( guard: Arc, contexts: Arc, fs: Arc, clock: Arc, ) -> Self { Self { guard, contexts, fs, clock, } } /// Applies the proposal: direct write under a write-lease, or a materialised /// proposal when the guard forbids a direct global-context write. /// /// # Errors /// [`AppError`] when the agent does not exist or the store/fs fails. pub async fn execute(&self, input: ProposeContextInput) -> Result { let ProposeContextInput { project, target, content, requester, } = input; match target { Some(name) => { // Per-agent context: direct write under an exclusive write-lease. let agent = resolve_agent(&self.contexts, &project, &name).await?; let _lease = self .guard .acquire_write(requester, GuardedResource::AgentContext(agent)) .await .map_err(map_guard_err)?; self.contexts .write_context(&project, &agent, &MarkdownDoc::new(content)) .await?; Ok(ProposeOutcome::Written) } None => { // Global project context: single-writer. Try to acquire the write // lease; Forbidden ⇒ materialise a proposal instead of overwriting. match self .guard .acquire_write(requester, GuardedResource::ProjectContext) .await { Ok(_lease) => { let path = join_root(&project, PROJECT_CONTEXT_FILE); self.fs.write(&path, content.as_bytes()).await?; Ok(ProposeOutcome::Written) } Err(GuardError::Forbidden) => { let path = self.file_proposal(&project, requester, &content).await?; Ok(ProposeOutcome::Proposed { path }) } Err(other) => Err(map_guard_err(other)), } } } } /// Materialises a rejected global-context change as a proposal file under /// `.ideai/proposals/-.md`, returning its path. async fn file_proposal( &self, project: &Project, who: ConversationParty, content: &str, ) -> Result { let who_label = match who { ConversationParty::User => "orchestrator".to_owned(), ConversationParty::Agent { agent_id } => agent_id.to_string(), }; let ts = self.clock.now_millis(); let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md"); let dir = join_root(project, PROPOSALS_DIR); self.fs.create_dir_all(&dir).await?; let path = join_root(project, &rel); self.fs.write(&path, content.as_bytes()).await?; Ok(path.as_str().to_owned()) } } /// Reads project memory under a shared read-lease. /// /// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body. pub struct ReadMemory { guard: Arc, memory: Arc, } /// Input for [`ReadMemory`]. pub struct ReadMemoryInput { /// The project to read within. pub project: Project, /// Target note slug; `None` ⇒ the aggregated index. pub slug: Option, /// The reading party. pub requester: ConversationParty, } impl ReadMemory { /// Builds the use case from its ports. #[must_use] pub fn new(guard: Arc, memory: Arc) -> Self { Self { guard, memory } } /// Reads the requested memory, returning its Markdown content. /// /// # Errors /// [`AppError`] when the note does not exist or the store fails. An invalid slug /// is [`AppError::Invalid`]. pub async fn execute(&self, input: ReadMemoryInput) -> Result { let ReadMemoryInput { project, slug, requester, } = input; match slug { Some(raw) => { let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?; let _lease = self .guard .acquire_read(requester, GuardedResource::Memory(slug.clone())) .await .map_err(map_guard_err)?; let note = self.memory.get(&project.root, &slug).await?; Ok(note.body.into_string()) } None => { // The aggregated index is project-shared; read it as a rendered list. let entries = self.memory.read_index(&project.root).await?; let lines: Vec = entries .into_iter() .map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook)) .collect(); Ok(lines.join("\n")) } } } } /// Writes (creates or replaces) a project memory note under an exclusive write-lease. pub struct WriteMemory { guard: Arc, memory: Arc, } /// Input for [`WriteMemory`]. pub struct WriteMemoryInput { /// The project to write within. pub project: Project, /// Target note slug. pub slug: String, /// The Markdown body to store. pub content: String, /// The writing party. pub requester: ConversationParty, } impl WriteMemory { /// Builds the use case from its ports. #[must_use] pub fn new(guard: Arc, memory: Arc) -> Self { Self { guard, memory } } /// Writes the note under a write-lease. /// /// # Errors /// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store /// failure. pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> { let WriteMemoryInput { project, slug, content, requester, } = input; let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?; let _lease = self .guard .acquire_write(requester, GuardedResource::Memory(slug.clone())) .await .map_err(map_guard_err)?; let frontmatter = MemoryFrontmatter { name: slug.clone(), description: format!("memory note {slug}"), r#type: MemoryType::Project, }; let note = Memory::new(frontmatter, MarkdownDoc::new(content)) .map_err(|e| AppError::Invalid(e.to_string()))?; self.memory.save(&project.root, ¬e).await?; Ok(()) } } /// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant /// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative /// blocking adapter never returns, but is mapped for completeness. fn map_guard_err(e: GuardError) -> AppError { match e { GuardError::Forbidden => AppError::Invalid( "writing the global project context is reserved to the orchestrator; propose instead" .to_owned(), ), GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()), } } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; use domain::conversation::ConversationParty; use domain::fileguard::{may_write_directly, ReadLease, WriteLease}; use domain::ports::{FsError, MemoryError, StoreError}; use domain::project::ProjectPath; use domain::{ProfileId, ProjectId, RemoteRef}; use std::collections::HashMap; use std::sync::{Arc as StdArc, Mutex}; use std::time::Duration; use tokio::sync::RwLock; fn project() -> Project { Project::new( ProjectId::from_uuid(uuid::Uuid::from_u128(1)), "demo", ProjectPath::new("/tmp/demo").unwrap(), RemoteRef::local(), 0, ) .unwrap() } /// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio /// `RwLock` per resource + the single-writer rule), so the application crate /// stays free of any dependency on infrastructure (no dependency cycle). #[derive(Default)] struct TestGuard { locks: Mutex>>>, } impl TestGuard { fn lock_for(&self, res: &GuardedResource) -> StdArc> { self.locks .lock() .unwrap() .entry(res.clone()) .or_insert_with(|| StdArc::new(RwLock::new(()))) .clone() } } #[async_trait] impl FileGuard for TestGuard { async fn acquire_read( &self, _who: ConversationParty, res: GuardedResource, ) -> Result { let lock = self.lock_for(&res); Ok(ReadLease::new(Box::new(lock.read_owned().await))) } async fn acquire_write( &self, who: ConversationParty, res: GuardedResource, ) -> Result { if !may_write_directly(who, &res) { return Err(GuardError::Forbidden); } let lock = self.lock_for(&res); Ok(WriteLease::new(Box::new(lock.write_owned().await))) } } fn agent_party(n: u128) -> ConversationParty { ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) } // ---- Fakes ----------------------------------------------------------- #[derive(Default)] struct FakeFs { files: Mutex>>, dirs: Mutex>, } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.files .lock() .unwrap() .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.files .lock() .unwrap() .insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { Ok(self.files.lock().unwrap().contains_key(path.as_str())) } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { self.dirs.lock().unwrap().push(path.as_str().to_owned()); Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } struct FakeContexts { manifest: AgentManifest, contexts: Mutex>, } #[async_trait] impl AgentContextStore for FakeContexts { async fn read_context( &self, _project: &Project, agent: &AgentId, ) -> Result { self.contexts .lock() .unwrap() .get(agent) .cloned() .map(MarkdownDoc::new) .ok_or(StoreError::NotFound) } async fn write_context( &self, _project: &Project, agent: &AgentId, md: &MarkdownDoc, ) -> Result<(), StoreError> { self.contexts .lock() .unwrap() .insert(*agent, md.as_str().to_owned()); Ok(()) } async fn load_manifest(&self, _project: &Project) -> Result { Ok(self.manifest.clone()) } async fn save_manifest( &self, _project: &Project, _manifest: &AgentManifest, ) -> Result<(), StoreError> { Ok(()) } } #[derive(Default)] struct FakeMemory { notes: Mutex>, } #[async_trait] impl MemoryStore for FakeMemory { async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { Ok(Vec::new()) } async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { let body = self .notes .lock() .unwrap() .get(slug.as_str()) .cloned() .ok_or(MemoryError::NotFound)?; Memory::new( MemoryFrontmatter { name: slug.clone(), description: "d".to_owned(), r#type: MemoryType::Project, }, MarkdownDoc::new(body), ) .map_err(|e| MemoryError::Frontmatter(e.to_string())) } async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { self.notes .lock() .unwrap() .insert(memory.slug().to_string(), memory.body.as_str().to_owned()); Ok(()) } async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { Ok(()) } async fn read_index( &self, _root: &ProjectPath, ) -> Result, MemoryError> { Ok(Vec::new()) } async fn resolve_links( &self, _root: &ProjectPath, _slug: &MemorySlug, ) -> Result, MemoryError> { Ok(Vec::new()) } } struct FixedClock; impl Clock for FixedClock { fn now_millis(&self) -> i64 { 42 } } fn guard() -> Arc { Arc::new(TestGuard::default()) } fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc { let mut contexts = HashMap::new(); contexts.insert(agent, body.to_owned()); Arc::new(FakeContexts { manifest: AgentManifest { version: 1, entries: vec![ManifestEntry { agent_id: agent, name: name.to_owned(), md_path: "agents/x.md".to_owned(), profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)), template_id: None, synchronized: false, synced_template_version: None, skills: Vec::new(), }], }, contexts: Mutex::new(contexts), }) } // ---- Tests ----------------------------------------------------------- #[tokio::test] async fn read_agent_context_returns_body() { let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); let uc = ReadContext::new( guard(), contexts_with("Dev", agent, "# hello"), Arc::new(FakeFs::default()), ); let md = uc .execute(ReadContextInput { project: project(), target: Some("dev".to_owned()), // case-insensitive requester: agent_party(1), }) .await .unwrap(); assert_eq!(md.as_str(), "# hello"); } #[tokio::test] async fn read_global_context_reads_root_file() { let fs = Arc::new(FakeFs::default()); fs.files .lock() .unwrap() .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec()); let uc = ReadContext::new( guard(), contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), fs, ); let md = uc .execute(ReadContextInput { project: project(), target: None, requester: ConversationParty::User, }) .await .unwrap(); assert_eq!(md.as_str(), "# project"); } #[tokio::test] async fn concurrent_reads_do_not_block_each_other() { // Two readers on the same global context, held at once. If the read-lease // were exclusive this would deadlock; a bounded timeout proves it does not. let guard = guard(); let r1 = guard .acquire_read(ConversationParty::User, GuardedResource::ProjectContext) .await .unwrap(); let r2 = tokio::time::timeout( Duration::from_millis(200), guard.acquire_read(agent_party(1), GuardedResource::ProjectContext), ) .await .expect("a second reader must not block") .unwrap(); drop((r1, r2)); } #[tokio::test] async fn agent_proposing_global_context_files_a_proposal_not_a_write() { let fs = Arc::new(FakeFs::default()); fs.files .lock() .unwrap() .insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec()); let uc = ProposeContext::new( guard(), contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), Arc::clone(&fs) as Arc, Arc::new(FixedClock), ); let outcome = uc .execute(ProposeContextInput { project: project(), target: None, content: "# hijack".to_owned(), requester: agent_party(3), }) .await .unwrap(); // It is a *proposal*, not a write: the live context is untouched. assert!(matches!(outcome, ProposeOutcome::Proposed { .. })); assert_eq!( fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), b"# original", "the live global context must NOT be overwritten by a proposal" ); // The proposal landed under .ideai/proposals/. let files = fs.files.lock().unwrap(); assert!(files .keys() .any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md"))); } #[tokio::test] async fn orchestrator_writes_global_context_directly() { let fs = Arc::new(FakeFs::default()); let uc = ProposeContext::new( guard(), contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"), Arc::clone(&fs) as Arc, Arc::new(FixedClock), ); let outcome = uc .execute(ProposeContextInput { project: project(), target: None, content: "# new".to_owned(), requester: ConversationParty::User, }) .await .unwrap(); assert_eq!(outcome, ProposeOutcome::Written); assert_eq!( fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(), b"# new" ); } #[tokio::test] async fn propose_agent_context_writes_directly() { let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7)); let contexts = contexts_with("Dev", agent, "# old"); let uc = ProposeContext::new( guard(), Arc::clone(&contexts), Arc::new(FakeFs::default()), Arc::new(FixedClock), ); let outcome = uc .execute(ProposeContextInput { project: project(), target: Some("Dev".to_owned()), content: "# new body".to_owned(), requester: agent_party(3), }) .await .unwrap(); assert_eq!(outcome, ProposeOutcome::Written); assert_eq!( contexts .read_context(&project(), &agent) .await .unwrap() .as_str(), "# new body" ); } #[tokio::test] async fn write_then_read_memory_round_trips_under_guard() { let memory = Arc::new(FakeMemory::default()); let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc); writer .execute(WriteMemoryInput { project: project(), slug: "note-a".to_owned(), content: "body".to_owned(), requester: agent_party(2), }) .await .unwrap(); let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc); let body = reader .execute(ReadMemoryInput { project: project(), slug: Some("note-a".to_owned()), requester: agent_party(2), }) .await .unwrap(); assert_eq!(body, "body"); } #[tokio::test] async fn writes_to_same_memory_note_serialise() { // Two write leases on the same note must not overlap (exclusive writer). let guard = guard(); let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap()); let w1 = guard .acquire_write(agent_party(1), slug.clone()) .await .unwrap(); // While w1 is held, a second writer blocks; it only succeeds after release. let blocked = tokio::time::timeout( Duration::from_millis(100), guard.acquire_write(agent_party(2), slug.clone()), ) .await; assert!( blocked.is_err(), "a second writer must block while w1 holds" ); drop(w1); let w2 = tokio::time::timeout( Duration::from_millis(200), guard.acquire_write(agent_party(2), slug), ) .await .expect("w2 acquires after w1 releases") .unwrap(); drop(w2); } }