//! 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; } /// **Which agent (if any) the project designates as its orchestrator.** /// /// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du /// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator /// and is never represented here. /// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator. /// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio /// selection, single agent by construction — the illegal "two orchestrators" state /// is unrepresentable). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OrchestratorDesignation(Option); impl OrchestratorDesignation { /// No agent designated: only the human ([`ConversationParty::User`]) orchestrates. #[must_use] pub const fn none() -> Self { Self(None) } /// Designates `agent` as the project's orchestrator (radio selection). #[must_use] pub const fn of(agent: AgentId) -> Self { Self(Some(agent)) } /// The designated agent, if any. `None` ⇒ default (human-only orchestrator). #[must_use] pub const fn designated(&self) -> Option { self.0 } } /// 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 an /// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone /// may write the per-agent context and memory. Orchestrator identity is resolved /// against the project's [`OrchestratorDesignation`] (the human is always one; a /// project agent only when explicitly designated). #[must_use] pub fn may_write_directly( who: ConversationParty, res: &GuardedResource, d: &OrchestratorDesignation, ) -> bool { if res.is_project_context() { is_orchestrator(who, d) } else { true } } /// Whether `who` is an orchestrator identity for the single-writer rule, given the /// project's [`OrchestratorDesignation`] `d`. /// /// The human-driven operator ([`ConversationParty::User`]) is **always** an /// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator /// **only** when it is the designated one; otherwise it must propose changes to the /// global context. #[must_use] pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool { match who { ConversationParty::User => true, ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id), } } #[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() { // Single-writer rule preserved with the default (no designated agent): // the human writes, every agent is refused. let default = OrchestratorDesignation::none(); assert!(may_write_directly( ConversationParty::User, &GuardedResource::ProjectContext, &default, )); assert!(!may_write_directly( agent_party(1), &GuardedResource::ProjectContext, &default, )); } #[test] fn designated_agent_may_write_project_context() { let id = AgentId::from_uuid(uuid::Uuid::from_u128(1)); let designation = OrchestratorDesignation::of(id); // The designated agent now writes the global context directly… assert!(may_write_directly( ConversationParty::agent(id), &GuardedResource::ProjectContext, &designation, )); // …while another agent stays refused, and the human stays allowed. assert!(!may_write_directly( agent_party(2), &GuardedResource::ProjectContext, &designation, )); assert!(may_write_directly( ConversationParty::User, &GuardedResource::ProjectContext, &designation, )); } #[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))); let default = OrchestratorDesignation::none(); for who in [ConversationParty::User, agent_party(1)] { assert!(may_write_directly(who, &mem, &default)); assert!(may_write_directly(who, &ctx, &default)); } } #[test] fn is_orchestrator_for_user_and_designated_agent() { let id = AgentId::from_uuid(uuid::Uuid::from_u128(1)); // Human is always an orchestrator, designation or not. assert!(is_orchestrator( ConversationParty::User, &OrchestratorDesignation::none() )); // An agent is an orchestrator only when it is the designated one. assert!(!is_orchestrator( ConversationParty::agent(id), &OrchestratorDesignation::none() )); assert!(is_orchestrator( ConversationParty::agent(id), &OrchestratorDesignation::of(id) )); assert!(!is_orchestrator( agent_party(2), &OrchestratorDesignation::of(id) )); } #[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()) ); } }