//! File-access guard (cadrage « FileGuard », lot C6). //! //! A **reader/writer lock per guarded resource**, bounded to the files IdeA owns: //! an agent's `.md` context, the project's global context, and a memory note. //! N concurrent readers **or** a single exclusive writer per resource; the global //! [`GuardedResource::ProjectContext`] is additionally **single-writer**: only the //! orchestrator may `acquire_write` it — any other party must *propose* instead and //! receives [`GuardError::Forbidden`]. //! //! ## Cooperative scope (cadrage §9.5) //! //! This guard corrects collisions **inside the IdeA path** (access through the MCP //! tools and the UI). It is **cooperative**: an agent that keeps a raw shell can //! still write these `.md`/memory files directly through the filesystem, bypassing //! the lock. Real airtightness (revoking raw fs access to these paths) is an **OS //! sandbox** concern (Landlock — `agent-permissions-architecture`) and is **out of //! scope** here. The FileGuard fixes IdeA↔IdeA races; it does not sandbox an agent. //! //! This module is **pure** (no `tokio`, no `std::fs`): it owns the value objects //! ([`GuardedResource`], [`GuardError`]), the RAII leases ([`ReadLease`], //! [`WriteLease`]) and the [`FileGuard`] port. The infra adapter (`RwFileGuard`) //! provides the actual reader/writer locks. use async_trait::async_trait; use crate::conversation::ConversationParty; use crate::ids::AgentId; use crate::memory::MemorySlug; /// The **bounded, closed** set of resources the [`FileGuard`] arbitrates. /// /// Closed by construction: only an agent's context, the global project context, and /// a memory note are guardable. Anything outside this enum is **not** guarded (and /// must not be routed through the guard). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum GuardedResource { /// One agent's `.md` context file. AgentContext(AgentId), /// The project's global context (`CLAUDE.md`/root context). /// /// **Single-writer**: only the orchestrator may write it; every other party is /// limited to *proposing* a change (cf. [`GuardError::Forbidden`]). ProjectContext, /// A single memory note, keyed by its slug. Memory(MemorySlug), } impl GuardedResource { /// Whether this resource is the single-writer global project context. #[must_use] pub const fn is_project_context(&self) -> bool { matches!(self, Self::ProjectContext) } } /// Errors raised when acquiring a guard lease. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum GuardError { /// The lease could not be taken because the resource is held incompatibly /// (a writer wanted while readers/a writer hold it, or vice-versa) and the /// caller asked for a **non-blocking** acquire. Blocking acquires serialize /// instead of returning this. #[error("guarded resource is busy")] Busy, /// A party that is **not** the orchestrator attempted to `acquire_write` the /// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the /// change for validation rather than writing directly. #[error("writing the global project context is reserved to the orchestrator; propose instead")] Forbidden, } /// A RAII **read** lease: holding it guarantees shared read access to the resource; /// dropping it releases the reader slot. N read leases may coexist on one resource. /// /// The lease owns an opaque release guard (a boxed handle the adapter fills with its /// lock guard). The domain only knows the lease releases on drop. #[must_use = "a read lease releases the guard as soon as it is dropped"] pub struct ReadLease { _guard: Box, } impl ReadLease { /// Wraps an adapter-provided release guard into a domain [`ReadLease`]. /// /// The infra adapter passes its own lock guard (e.g. a tokio `OwnedRwLockReadGuard`); /// the domain treats it opaquely and only relies on its `Drop` to release. #[must_use] pub fn new(guard: Box) -> Self { Self { _guard: guard } } } impl std::fmt::Debug for ReadLease { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("ReadLease") } } /// A RAII **write** lease: holding it guarantees exclusive write access to the /// resource; dropping it releases the writer slot. At most one write lease exists /// for a resource at a time, and it excludes all readers. #[must_use = "a write lease releases the guard as soon as it is dropped"] pub struct WriteLease { _guard: Box, } impl WriteLease { /// Wraps an adapter-provided release guard into a domain [`WriteLease`]. #[must_use] pub fn new(guard: Box) -> Self { Self { _guard: guard } } } impl std::fmt::Debug for WriteLease { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("WriteLease") } } /// A reader/writer guard over the bounded set of IdeA-owned files /// ([`GuardedResource`]). /// /// Contract: /// - `acquire_read` grants a shared [`ReadLease`]; **N readers** may hold one /// resource concurrently. /// - `acquire_write` grants an exclusive [`WriteLease`]; it excludes all other /// readers and writers of the **same** resource (different resources are /// independent). /// - [`GuardedResource::ProjectContext`] is **single-writer**: `acquire_write` by a /// `who` that is not the orchestrator returns [`GuardError::Forbidden`]. /// /// Object-safe (held as `Arc`); blocking acquires serialize, so the /// only error is `Forbidden` (the cooperative path never spuriously fails a wait). #[async_trait] pub trait FileGuard: Send + Sync { /// Acquires a shared **read** lease on `res` for `who`. Serializes behind any /// in-flight writer of the same resource, then returns a [`ReadLease`]. /// /// # Errors /// [`GuardError`] — reading is never `Forbidden`, so a blocking adapter returns /// `Ok` once the writer (if any) releases. async fn acquire_read( &self, who: ConversationParty, res: GuardedResource, ) -> Result; /// Acquires an exclusive **write** lease on `res` for `who`. Serializes behind /// any in-flight readers/writer of the same resource. /// /// # Errors /// [`GuardError::Forbidden`] when `who` is not the orchestrator and `res` is the /// single-writer [`GuardedResource::ProjectContext`]. async fn acquire_write( &self, who: ConversationParty, res: GuardedResource, ) -> Result; } /// Whether `who` is allowed to **write** `res` directly (vs. having to propose). /// /// Pure policy, shared by the port's documented contract and the adapter: only the /// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone /// may write the per-agent context and memory. The orchestrator is modelled as /// [`ConversationParty::User`] — IdeA's single logical operator identity (the /// human-driven orchestrator), never a project [`AgentId`]. #[must_use] pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool { if res.is_project_context() { is_orchestrator(who) } else { true } } /// Whether `who` is the orchestrator identity for the single-writer rule. /// /// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a /// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must /// propose changes to the global context. #[must_use] pub const fn is_orchestrator(who: ConversationParty) -> bool { matches!(who, ConversationParty::User) } #[cfg(test)] mod tests { use super::*; fn agent_party(n: u128) -> ConversationParty { ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) } #[test] fn project_context_is_single_writer_to_orchestrator_only() { assert!(may_write_directly( ConversationParty::User, &GuardedResource::ProjectContext )); assert!(!may_write_directly( agent_party(1), &GuardedResource::ProjectContext )); } #[test] fn agent_context_and_memory_are_writable_by_anyone() { let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap()); let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9))); for who in [ConversationParty::User, agent_party(1)] { assert!(may_write_directly(who, &mem)); assert!(may_write_directly(who, &ctx)); } } #[test] fn is_orchestrator_only_for_user() { assert!(is_orchestrator(ConversationParty::User)); assert!(!is_orchestrator(agent_party(1))); } #[test] fn guarded_resource_equality_keys_on_identity() { let a = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1))); let b = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1))); let c = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(2))); assert_eq!(a, b); assert_ne!(a, c); assert_ne!( GuardedResource::ProjectContext, GuardedResource::Memory(MemorySlug::new("x").unwrap()) ); } }