Files
IdeA/crates/domain/src/fileguard.rs
Blomios 287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

324 lines
12 KiB
Rust

//! 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<dyn Send + Sync>,
}
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<dyn Send + Sync>) -> 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<dyn Send + Sync>,
}
impl WriteLease {
/// Wraps an adapter-provided release guard into a domain [`WriteLease`].
#[must_use]
pub fn new(guard: Box<dyn Send + Sync>) -> 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<dyn FileGuard>`); 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<ReadLease, GuardError>;
/// 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<WriteLease, GuardError>;
}
/// **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<AgentId>);
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<AgentId> {
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())
);
}
}