//! [`RwFileGuard`] — the [`FileGuard`] adapter (lot C6). //! //! A reader/writer lock **per [`GuardedResource`]**, backed by one //! [`tokio::sync::RwLock`] per resource (created lazily and shared via `Arc`). N //! concurrent readers **or** one exclusive writer per resource; different resources //! are independent (their locks are distinct). //! //! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced //! **before** taking any lock: a `who` that is not the orchestrator //! ([`domain::may_write_directly`] returning `false`) is rejected with //! [`GuardError::Forbidden`] — it must *propose* instead. //! //! ## Cooperative scope (cadrage §9.5) //! //! This guard serialises access **inside the IdeA path** (the MCP tools / use cases). //! It is **cooperative**: it does not sandbox an agent that keeps a raw shell — real //! airtightness (revoking raw fs access to these paths) is an OS-sandbox concern //! (Landlock) and is **out of scope** here. //! //! ## Concurrency //! //! The resource → lock registry lives behind a **synchronous** [`Mutex`], held only //! for the O(1) get-or-insert of an `Arc>` and **never across an //! `.await`**. The actual wait happens on the tokio `RwLock`'s `owned` guard, whose //! lifetime is `'static` (it owns its `Arc`), so it can be boxed into the domain's //! RAII [`ReadLease`]/[`WriteLease`]. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use tokio::sync::RwLock; use domain::conversation::ConversationParty; use domain::fileguard::{ may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease, }; /// The [`FileGuard`] adapter: one reader/writer lock per guarded resource. /// /// Held at the composition root as `Arc`; cloneable bookkeeping is /// interior (the registry is a shared `Mutex>`). #[derive(Debug, Default)] pub struct RwFileGuard { /// Lazily-created per-resource locks. The unit `()` payload is irrelevant — the /// lock's *mode* (read vs write) is the whole point; the lease only proves the /// slot is held until drop. locks: Mutex>>>, } impl RwFileGuard { /// Builds an empty guard (no resource is contended until first acquired). #[must_use] pub fn new() -> Self { Self::default() } /// Returns the shared lock for `res`, creating it on first use. The registry /// mutex is held only for this O(1) get-or-insert, never across an `.await`. fn lock_for(&self, res: &GuardedResource) -> Arc> { let mut registry = self .locks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); registry .entry(res.clone()) .or_insert_with(|| Arc::new(RwLock::new(()))) .clone() } } #[async_trait] impl FileGuard for RwFileGuard { async fn acquire_read( &self, _who: ConversationParty, res: GuardedResource, ) -> Result { // Reading is never forbidden (cooperative contract): serialise behind any // in-flight writer of the same resource, then hand back the RAII lease. let lock = self.lock_for(&res); let guard = lock.read_owned().await; Ok(ReadLease::new(Box::new(guard))) } async fn acquire_write( &self, who: ConversationParty, res: GuardedResource, ) -> Result { // Single-writer rule for the global project context: refuse *before* taking // any lock so a forbidden writer never blocks readers. if !may_write_directly(who, &res) { return Err(GuardError::Forbidden); } let lock = self.lock_for(&res); let guard = lock.write_owned().await; Ok(WriteLease::new(Box::new(guard))) } } #[cfg(test)] mod tests { use super::*; use domain::ids::AgentId; use domain::memory::MemorySlug; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; fn agent_party(n: u128) -> ConversationParty { ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) } fn mem(slug: &str) -> GuardedResource { GuardedResource::Memory(MemorySlug::new(slug).unwrap()) } #[tokio::test] async fn many_readers_share_one_resource_concurrently() { let guard = RwFileGuard::new(); // Three read leases held *at once* on the same resource must all be granted. let a = guard .acquire_read(ConversationParty::User, mem("note")) .await .unwrap(); let b = guard .acquire_read(agent_party(1), mem("note")) .await .unwrap(); let c = guard .acquire_read(agent_party(2), mem("note")) .await .unwrap(); // If reads were exclusive this would have deadlocked above; reaching here is // the proof. Keep the leases alive until now. drop((a, b, c)); } #[tokio::test] async fn writer_excludes_other_writers_serialising_them() { let guard = Arc::new(RwFileGuard::new()); let counter = Arc::new(AtomicUsize::new(0)); let observed_max = Arc::new(AtomicUsize::new(0)); let mut handles = Vec::new(); for _ in 0..8 { let guard = Arc::clone(&guard); let counter = Arc::clone(&counter); let observed_max = Arc::clone(&observed_max); handles.push(tokio::spawn(async move { let _lease = guard .acquire_write(ConversationParty::User, mem("shared")) .await .unwrap(); // While holding the write lease, at most one task may be inside. let inside = counter.fetch_add(1, Ordering::SeqCst) + 1; observed_max.fetch_max(inside, Ordering::SeqCst); tokio::time::sleep(Duration::from_millis(5)).await; counter.fetch_sub(1, Ordering::SeqCst); })); } for h in handles { h.await.unwrap(); } assert_eq!( observed_max.load(Ordering::SeqCst), 1, "a write lease must be exclusive — never two writers inside at once" ); } #[tokio::test] async fn agent_writing_project_context_is_forbidden() { let guard = RwFileGuard::new(); let err = guard .acquire_write(agent_party(1), GuardedResource::ProjectContext) .await .unwrap_err(); assert_eq!(err, GuardError::Forbidden); } #[tokio::test] async fn orchestrator_may_write_project_context() { let guard = RwFileGuard::new(); let lease = guard .acquire_write(ConversationParty::User, GuardedResource::ProjectContext) .await; assert!(lease.is_ok()); } #[tokio::test] async fn write_lease_releases_on_drop_letting_the_next_writer_in() { let guard = Arc::new(RwFileGuard::new()); { let _lease = guard .acquire_write(ConversationParty::User, mem("r")) .await .unwrap(); // Held here; a concurrent writer would block. } // <- RAII release at end of scope. // After the scope, a fresh writer acquires immediately (bounded wait). let again = tokio::time::timeout( Duration::from_millis(200), guard.acquire_write(ConversationParty::User, mem("r")), ) .await .expect("lease must have released on drop") .unwrap(); drop(again); } #[tokio::test] async fn distinct_resources_do_not_block_each_other() { let guard = RwFileGuard::new(); let _w1 = guard .acquire_write(ConversationParty::User, mem("alpha")) .await .unwrap(); // A writer on a *different* resource is independent — must not block. let w2 = tokio::time::timeout( Duration::from_millis(200), guard.acquire_write(ConversationParty::User, mem("beta")), ) .await .expect("independent resources must not contend") .unwrap(); drop(w2); } }