//! 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/<who>-<ts>.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::{may_write_directly, 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<dyn AgentContextStore>,
    project: &Project,
    name: &str,
) -> Result<AgentId, AppError> {
    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<dyn FileGuard>,
    contexts: Arc<dyn AgentContextStore>,
    fs: Arc<dyn FileSystem>,
}

/// 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<String>,
    /// 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<dyn FileGuard>,
        contexts: Arc<dyn AgentContextStore>,
        fs: Arc<dyn FileSystem>,
    ) -> 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<MarkdownDoc, AppError> {
        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 use case asks the domain
/// policy whether the requester may write directly; otherwise it materialises a
/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live
/// context.
pub struct ProposeContext {
    guard: Arc<dyn FileGuard>,
    contexts: Arc<dyn AgentContextStore>,
    fs: Arc<dyn FileSystem>,
    clock: Arc<dyn Clock>,
}

/// 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<String>,
    /// 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<dyn FileGuard>,
        contexts: Arc<dyn AgentContextStore>,
        fs: Arc<dyn FileSystem>,
        clock: Arc<dyn Clock>,
    ) -> 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<ProposeOutcome, AppError> {
        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. Authorization stays in the
                // domain policy; the guard only serialises the eventual direct write.
                let manifest = self.contexts.load_manifest(&project).await?;
                let designation = manifest.orchestrator_designation();
                let resource = GuardedResource::ProjectContext;
                if !may_write_directly(requester, &resource, &designation) {
                    let path = self.file_proposal(&project, requester, &content).await?;
                    return Ok(ProposeOutcome::Proposed { path });
                }
                let _lease = self
                    .guard
                    .acquire_write(requester, resource)
                    .await
                    .map_err(map_guard_err)?;
                let path = join_root(&project, PROJECT_CONTEXT_FILE);
                self.fs.write(&path, content.as_bytes()).await?;
                Ok(ProposeOutcome::Written)
            }
        }
    }

    /// Materialises a rejected global-context change as a proposal file under
    /// `.ideai/proposals/<who>-<ts>.md`, returning its path.
    async fn file_proposal(
        &self,
        project: &Project,
        who: ConversationParty,
        content: &str,
    ) -> Result<String, AppError> {
        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<dyn FileGuard>,
    memory: Arc<dyn MemoryStore>,
}

/// Input for [`ReadMemory`].
pub struct ReadMemoryInput {
    /// The project to read within.
    pub project: Project,
    /// Target note slug; `None` ⇒ the aggregated index.
    pub slug: Option<String>,
    /// The reading party.
    pub requester: ConversationParty,
}

impl ReadMemory {
    /// Builds the use case from its ports.
    #[must_use]
    pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> 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<String, AppError> {
        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<String> = 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<dyn FileGuard>,
    memory: Arc<dyn MemoryStore>,
}

/// 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<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> 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, &note).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::{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<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
    }

    impl TestGuard {
        fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
            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<ReadLease, GuardError> {
            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<WriteLease, GuardError> {
            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<HashMap<String, Vec<u8>>>,
        dirs: Mutex<Vec<String>>,
    }

    #[async_trait]
    impl FileSystem for FakeFs {
        async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, 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<bool, FsError> {
            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<Vec<domain::ports::DirEntry>, FsError> {
            Ok(Vec::new())
        }
        async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
            Ok(())
        }
    }

    struct FakeContexts {
        manifest: AgentManifest,
        contexts: Mutex<HashMap<AgentId, String>>,
    }

    #[async_trait]
    impl AgentContextStore for FakeContexts {
        async fn read_context(
            &self,
            _project: &Project,
            agent: &AgentId,
        ) -> Result<MarkdownDoc, StoreError> {
            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<AgentManifest, StoreError> {
            Ok(self.manifest.clone())
        }
        async fn save_manifest(
            &self,
            _project: &Project,
            _manifest: &AgentManifest,
        ) -> Result<(), StoreError> {
            Ok(())
        }
    }

    #[derive(Default)]
    struct FakeMemory {
        notes: Mutex<HashMap<String, String>>,
    }

    #[async_trait]
    impl MemoryStore for FakeMemory {
        async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
            Ok(Vec::new())
        }
        async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
            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<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
            Ok(Vec::new())
        }
        async fn resolve_links(
            &self,
            _root: &ProjectPath,
            _slug: &MemorySlug,
        ) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
            Ok(Vec::new())
        }
    }

    struct FixedClock;
    impl Clock for FixedClock {
        fn now_millis(&self) -> i64 {
            42
        }
    }

    fn guard() -> Arc<dyn FileGuard> {
        Arc::new(TestGuard::default())
    }

    fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
        let mut contexts = HashMap::new();
        contexts.insert(agent, body.to_owned());
        Arc::new(FakeContexts {
            manifest: AgentManifest {
                version: 1,
                orchestrator: None,
                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<dyn FileSystem>,
            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<dyn FileSystem>,
            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<dyn MemoryStore>);
        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<dyn MemoryStore>);
        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);
    }
}
