From 76583315c7cea05b94baa5d81a460c3cca8fab80 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 5 Jul 2026 16:01:26 -0400 Subject: [PATCH] feat(query): gate hosted memory extraction (#101 #107 #108) Fixes #101. Fixes #107. Fixes #108. - gate hosted durable memory writes by source trust - add hosted memory candidate approval flow - keep local memory extraction behavior unchanged Signed-off-by: Timothy Wayne Gregg --- docs/advanced.md | 14 + docs/configuration.md | 29 ++ docs/shared/hosted-review-phase-3-pr.md | 30 ++ src-rust/crates/core/src/hosted_review.rs | 114 +++++- src-rust/crates/query/src/lib.rs | 24 +- src-rust/crates/query/src/session_memory.rs | 423 +++++++++++++++++++- 6 files changed, 621 insertions(+), 13 deletions(-) create mode 100644 docs/shared/hosted-review-phase-3-pr.md diff --git a/docs/advanced.md b/docs/advanced.md index b898ae61..211fb31b 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -543,6 +543,20 @@ policy settings such as `hostedReview.allowManagedRules`, `hostedReview.allowPlugins` can opt specific surfaces back in for controlled deployments. Prefer tenant-approved managed rules over `allowUserMemory`. +Session memory extraction is also approval-gated in hosted review mode. +Untrusted fork or contributor sessions cannot automatically append learned +facts to durable `.coven-code/AGENTS.md` memory. Instead, extracted memories +are written as JSON candidates under `.coven-code/memory-candidates/` with +content, semantic category, confidence, provenance, source trust, proposed +scope, proposed visibility, status, and rejection reason metadata. Approved +candidates can be promoted into durable memory as maintainer-approved entries; +rejected candidates remain artifacts and are not loaded into future prompts. + +Direct hosted auto-persistence requires an explicit trusted policy: +`hostedReview.allowAutoMemoryPersistence` must be true and +`hostedReview.memorySourceTrust` must meet or exceed +`hostedReview.memoryTrustThreshold`. + --- ## Security and permissions diff --git a/docs/configuration.md b/docs/configuration.md index 64cd86ba..07934988 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,6 +157,35 @@ shared surfaces back in: review jobs should prefer tenant-approved managed rules over operator-global user memory. +Auto-extracted memories are approval-gated in hosted review mode. By default, +hosted sessions write reviewable JSON candidates under +`.coven-code/memory-candidates/` instead of appending directly to durable +`.coven-code/AGENTS.md` memory. Each candidate records content, category, +confidence, provenance, source trust, proposed scope, proposed visibility, +status, and any rejection reason. + +Trusted deployments can opt into direct durable writes only when the source +trust meets the configured threshold: + +```json +{ + "config": { + "hostedReview": { + "enabled": true, + "allowAutoMemoryPersistence": true, + "memorySourceTrust": "maintainer-approved", + "memoryTrustThreshold": "maintainer-approved" + } + } +} +``` + +Supported `memorySourceTrust` and `memoryTrustThreshold` values are +`system-policy`, `maintainer-approved`, `default-branch-code`, +`contributor-input`, `fork-input`, `model-inferred`, and `unknown`. +Untrusted fork or contributor contexts should leave durable persistence +disabled and promote only reviewed candidates. + ### Tool access | Key | Type | Default | Description | diff --git a/docs/shared/hosted-review-phase-3-pr.md b/docs/shared/hosted-review-phase-3-pr.md new file mode 100644 index 00000000..ec70309c --- /dev/null +++ b/docs/shared/hosted-review-phase-3-pr.md @@ -0,0 +1,30 @@ +# Hosted Review Phase 3 PR Notes + +## Linked issues + +Fixes #101. +Fixes #107. +Fixes #108. + +## Summary + +- Adds hosted memory source trust classification with configurable `memorySourceTrust`, `memoryTrustThreshold`, and `allowAutoMemoryPersistence`. +- Routes hosted session memory extraction through a policy gate instead of always appending to durable `.coven-code/AGENTS.md`. +- Writes untrusted or unapproved hosted extractions as reviewable JSON candidates under `.coven-code/memory-candidates/`. +- Adds candidate approval and rejection APIs; approval promotes candidates to durable memory as maintainer-approved entries, while rejection records a reason without durable writes. +- Preserves local mode direct memory persistence. + +## Test evidence + +- `cargo fmt --all -- --check` +- `cargo check --workspace` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test -p claurst-core --lib hosted --quiet` +- `cargo test -p claurst-query session_memory --quiet` +- `cargo test --workspace --quiet` + +## Risk notes + +- Candidate approval/rejection is exposed as Rust API surface in this phase; hosted dashboard or CLI wiring can call it in a later integration PR. +- Hosted direct durable writes remain disabled by default and require both explicit policy and sufficient source trust. +- Candidate artifacts are not loaded into prompts by the existing memory loader, so rejected or pending candidates do not affect future sessions. diff --git a/src-rust/crates/core/src/hosted_review.rs b/src-rust/crates/core/src/hosted_review.rs index 0bce7dc6..37d3f327 100644 --- a/src-rust/crates/core/src/hosted_review.rs +++ b/src-rust/crates/core/src/hosted_review.rs @@ -19,7 +19,7 @@ impl RuntimeMode { } /// Settings-backed hosted review configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HostedReviewConfig { #[serde(default, skip_serializing_if = "is_false")] @@ -34,6 +34,31 @@ pub struct HostedReviewConfig { pub allow_mcp_servers: bool, #[serde(default, skip_serializing_if = "is_false")] pub allow_plugins: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub allow_auto_memory_persistence: bool, + #[serde(default, skip_serializing_if = "MemorySourceTrust::is_unknown")] + pub memory_source_trust: MemorySourceTrust, + #[serde( + default = "default_memory_trust_threshold", + skip_serializing_if = "is_default_memory_trust_threshold" + )] + pub memory_trust_threshold: MemorySourceTrust, +} + +impl Default for HostedReviewConfig { + fn default() -> Self { + Self { + enabled: false, + allow_user_memory: false, + allow_managed_rules: false, + allow_write_tools: false, + allow_mcp_servers: false, + allow_plugins: false, + allow_auto_memory_persistence: false, + memory_source_trust: MemorySourceTrust::Unknown, + memory_trust_threshold: default_memory_trust_threshold(), + } + } } impl HostedReviewConfig { @@ -44,9 +69,71 @@ impl HostedReviewConfig { && !self.allow_write_tools && !self.allow_mcp_servers && !self.allow_plugins + && !self.allow_auto_memory_persistence + && self.memory_source_trust == MemorySourceTrust::Unknown + && self.memory_trust_threshold == default_memory_trust_threshold() + } + + pub fn memory_source_trust(&self) -> MemorySourceTrust { + self.memory_source_trust + } + + pub fn memory_trust_threshold(&self) -> MemorySourceTrust { + self.memory_trust_threshold + } + + pub fn allows_auto_memory_persistence(&self) -> bool { + self.allow_auto_memory_persistence + && self + .memory_source_trust + .meets_threshold(self.memory_trust_threshold) + } +} + +/// Trust classification for the source that produced or approved memory. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum MemorySourceTrust { + SystemPolicy, + MaintainerApproved, + DefaultBranchCode, + ContributorInput, + ForkInput, + ModelInferred, + #[default] + Unknown, +} + +impl MemorySourceTrust { + pub fn is_unknown(&self) -> bool { + matches!(self, Self::Unknown) + } + + pub fn meets_threshold(self, threshold: Self) -> bool { + self.rank() >= threshold.rank() + } + + fn rank(self) -> u8 { + match self { + Self::Unknown => 0, + Self::ForkInput => 10, + Self::ContributorInput => 20, + Self::ModelInferred => 30, + Self::DefaultBranchCode => 60, + Self::MaintainerApproved => 80, + Self::SystemPolicy => 100, + } } } +fn default_memory_trust_threshold() -> MemorySourceTrust { + MemorySourceTrust::MaintainerApproved +} + +fn is_default_memory_trust_threshold(value: &MemorySourceTrust) -> bool { + *value == default_memory_trust_threshold() +} + /// Canonical repository identity supplied by the hosted control plane or /// derived from a git remote for local diagnostics. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -327,6 +414,31 @@ mod tests { ); } + #[test] + fn memory_source_trust_enforces_threshold_order() { + assert!(MemorySourceTrust::MaintainerApproved + .meets_threshold(MemorySourceTrust::DefaultBranchCode)); + assert!(!MemorySourceTrust::ContributorInput + .meets_threshold(MemorySourceTrust::MaintainerApproved)); + assert!(!MemorySourceTrust::ForkInput.meets_threshold(MemorySourceTrust::ContributorInput)); + } + + #[test] + fn hosted_memory_persistence_requires_explicit_trusted_policy() { + let mut config = HostedReviewConfig { + enabled: true, + ..Default::default() + }; + assert!(!config.allows_auto_memory_persistence()); + + config.allow_auto_memory_persistence = true; + config.memory_source_trust = MemorySourceTrust::ContributorInput; + assert!(!config.allows_auto_memory_persistence()); + + config.memory_source_trust = MemorySourceTrust::MaintainerApproved; + assert!(config.allows_auto_memory_persistence()); + } + #[test] fn security_private_domain_requires_explicit_public_review_allowance() { assert!(!MemoryDomain::SecurityPrivate.can_load_in_public_review(false)); diff --git a/src-rust/crates/query/src/lib.rs b/src-rust/crates/query/src/lib.rs index 2b952b57..d2a46cc9 100644 --- a/src-rust/crates/query/src/lib.rs +++ b/src-rust/crates/query/src/lib.rs @@ -32,7 +32,8 @@ pub use compact::{ pub use cron_scheduler::start_cron_scheduler; pub use goal_loop::{check_and_continue_goal, mark_goal_complete, GoalContinuation, StopReason}; pub use session_memory::{ - ExtractedMemory, MemoryCategory, SessionMemoryExtractor, SessionMemoryState, + ExtractedMemory, MemoryCandidate, MemoryCandidateStatus, MemoryCandidateStore, MemoryCategory, + MemoryPersistenceOutcome, SessionMemoryExtractor, SessionMemoryState, }; pub use skill_prefetch::{ format_skill_listing, prefetch_skills, SharedSkillIndex, SkillDefinition, SkillIndex, @@ -1956,6 +1957,8 @@ pub async fn run_query_loop( let model_clone = config.model.clone(); let messages_clone = messages.clone(); let working_dir_clone = tool_ctx.working_dir.clone(); + let runtime_mode = tool_ctx.config.runtime_mode(); + let hosted_review_config = tool_ctx.config.hosted_review.clone(); // Build a fresh client using the same API key. This avoids // requiring an Arc in the existing run_query_loop signature. @@ -1979,15 +1982,22 @@ pub async fn run_query_loop( let target = working_dir_clone .join(".coven-code") .join("AGENTS.md"); - if let Err(e) = - session_memory::SessionMemoryExtractor::persist( - &memories, &target, - ) - .await + let candidate_store = + session_memory::MemoryCandidateStore::for_working_dir( + &working_dir_clone, + ); + if let Err(e) = session_memory::SessionMemoryExtractor::persist_with_policy( + &memories, + &target, + &candidate_store, + runtime_mode, + &hosted_review_config, + ) + .await { tracing::warn!( error = %e, - "Failed to persist session memories" + "Failed to store session memories" ); } } diff --git a/src-rust/crates/query/src/session_memory.rs b/src-rust/crates/query/src/session_memory.rs index 661dba2e..6abb5232 100644 --- a/src-rust/crates/query/src/session_memory.rs +++ b/src-rust/crates/query/src/session_memory.rs @@ -18,9 +18,11 @@ use claurst_api::{ AnthropicStreamEvent, ApiMessage, CreateMessageRequest, StreamAccumulator, StreamHandler, SystemPrompt, }; +use claurst_core::hosted_review::{HostedReviewConfig, MemorySourceTrust, RuntimeMode}; use claurst_core::types::{Message, Role}; +use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; use tracing::{debug, info, warn}; @@ -40,7 +42,8 @@ const MIN_TOOL_CALLS_BETWEEN_EXTRACTIONS: usize = 3; // --------------------------------------------------------------------------- /// Category of an extracted memory entry. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum MemoryCategory { UserPreference, ProjectFact, @@ -75,7 +78,8 @@ impl MemoryCategory { } /// A single fact extracted from the conversation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ExtractedMemory { /// The fact to remember, as a markdown bullet point or sentence. pub content: String, @@ -83,6 +87,172 @@ pub struct ExtractedMemory { pub category: MemoryCategory, /// Model confidence, 0.0–1.0. pub confidence: f32, + #[serde(default)] + pub source_trust: MemorySourceTrust, +} + +impl ExtractedMemory { + pub fn with_source_trust(mut self, source_trust: MemorySourceTrust) -> Self { + self.source_trust = source_trust; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum MemoryCandidateStatus { + Pending, + Approved, + Rejected, + Expired, +} + +/// A reviewable memory candidate written by hosted review mode instead of +/// directly mutating durable AGENTS.md memory. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryCandidate { + pub id: String, + pub content: String, + pub category: MemoryCategory, + pub confidence: f32, + pub provenance: String, + pub source_trust: MemorySourceTrust, + pub proposed_scope: String, + pub proposed_visibility: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggested_expiry: Option, + pub status: MemoryCandidateStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub rejection_reason: Option, + pub created_at: String, +} + +impl MemoryCandidate { + fn from_memory( + memory: &ExtractedMemory, + provenance: &str, + proposed_scope: &str, + proposed_visibility: &str, + rejection_reason: Option<&str>, + ) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + content: memory.content.clone(), + category: memory.category.clone(), + confidence: memory.confidence, + provenance: provenance.to_string(), + source_trust: memory.source_trust, + proposed_scope: proposed_scope.to_string(), + proposed_visibility: proposed_visibility.to_string(), + suggested_expiry: None, + status: MemoryCandidateStatus::Pending, + rejection_reason: rejection_reason.map(str::to_string), + created_at: chrono::Utc::now().to_rfc3339(), + } + } + + fn to_approved_memory(&self) -> ExtractedMemory { + ExtractedMemory { + content: self.content.clone(), + category: self.category.clone(), + confidence: self.confidence, + source_trust: MemorySourceTrust::MaintainerApproved, + } + } +} + +#[derive(Debug, Clone)] +pub struct MemoryCandidateStore { + root: PathBuf, +} + +impl MemoryCandidateStore { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn for_working_dir(working_dir: &Path) -> Self { + Self::new(working_dir.join(".coven-code").join("memory-candidates")) + } + + pub async fn write_pending( + &self, + memories: &[ExtractedMemory], + provenance: &str, + proposed_scope: &str, + proposed_visibility: &str, + rejection_reason: Option<&str>, + ) -> anyhow::Result> { + if memories.is_empty() { + return Ok(Vec::new()); + } + + fs::create_dir_all(&self.root).await?; + let mut candidates = Vec::with_capacity(memories.len()); + for memory in memories { + let candidate = MemoryCandidate::from_memory( + memory, + provenance, + proposed_scope, + proposed_visibility, + rejection_reason, + ); + self.write_candidate(&candidate).await?; + candidates.push(candidate); + } + Ok(candidates) + } + + pub async fn approve( + &self, + candidate_id: &str, + target_path: &Path, + ) -> anyhow::Result { + let mut candidate = self.read_candidate(candidate_id).await?; + candidate.status = MemoryCandidateStatus::Approved; + candidate.source_trust = MemorySourceTrust::MaintainerApproved; + candidate.rejection_reason = None; + SessionMemoryExtractor::persist(&[candidate.to_approved_memory()], target_path).await?; + self.write_candidate(&candidate).await?; + Ok(candidate) + } + + pub async fn reject( + &self, + candidate_id: &str, + reason: impl Into, + ) -> anyhow::Result { + let mut candidate = self.read_candidate(candidate_id).await?; + candidate.status = MemoryCandidateStatus::Rejected; + candidate.rejection_reason = Some(reason.into()); + self.write_candidate(&candidate).await?; + Ok(candidate) + } + + pub async fn read_candidate(&self, candidate_id: &str) -> anyhow::Result { + let path = self.candidate_path(candidate_id); + let content = fs::read_to_string(path).await?; + Ok(serde_json::from_str(&content)?) + } + + async fn write_candidate(&self, candidate: &MemoryCandidate) -> anyhow::Result<()> { + fs::create_dir_all(&self.root).await?; + let path = self.candidate_path(&candidate.id); + fs::write(path, serde_json::to_string_pretty(candidate)?).await?; + Ok(()) + } + + fn candidate_path(&self, candidate_id: &str) -> PathBuf { + self.root.join(format!("{candidate_id}.json")) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MemoryPersistenceOutcome { + DurableWritten { count: usize }, + CandidatesWritten { count: usize, reason: String }, + Skipped, } // --------------------------------------------------------------------------- @@ -299,11 +469,17 @@ impl SessionMemoryExtractor { let date_str = chrono::Local::now().format("%Y-%m-%d").to_string(); let mut new_block = format!("\n### Session memories — {}\n\n", date_str); for memory in memories { + let trust_label = if memory.source_trust.is_unknown() { + String::new() + } else { + format!(", trust: {}", source_trust_label(memory.source_trust)) + }; new_block.push_str(&format!( - "- **[{}]** {} *(confidence: {:.0}%)*\n", + "- **[{}]** {} *(confidence: {:.0}%{})*\n", memory.category.label(), memory.content, - memory.confidence * 100.0 + memory.confidence * 100.0, + trust_label )); } @@ -342,6 +518,78 @@ impl SessionMemoryExtractor { info!(path = %target_path.display(), count = memories.len(), "Memories persisted"); Ok(()) } + + pub async fn persist_with_policy( + memories: &[ExtractedMemory], + target_path: &Path, + candidate_store: &MemoryCandidateStore, + mode: RuntimeMode, + hosted_config: &HostedReviewConfig, + ) -> anyhow::Result { + if memories.is_empty() { + return Ok(MemoryPersistenceOutcome::Skipped); + } + + if !mode.is_hosted_review() { + Self::persist(memories, target_path).await?; + return Ok(MemoryPersistenceOutcome::DurableWritten { + count: memories.len(), + }); + } + + let source_trust = hosted_config.memory_source_trust(); + let trusted_memories: Vec = memories + .iter() + .cloned() + .map(|memory| memory.with_source_trust(source_trust)) + .collect(); + + if hosted_config.allows_auto_memory_persistence() { + Self::persist(&trusted_memories, target_path).await?; + return Ok(MemoryPersistenceOutcome::DurableWritten { + count: trusted_memories.len(), + }); + } + + let reason = hosted_memory_candidate_reason(hosted_config); + let candidates = candidate_store + .write_pending( + &trusted_memories, + "session-memory-extraction", + "hosted-review", + "durable-memory", + Some(&reason), + ) + .await?; + Ok(MemoryPersistenceOutcome::CandidatesWritten { + count: candidates.len(), + reason, + }) + } +} + +fn hosted_memory_candidate_reason(config: &HostedReviewConfig) -> String { + if !config.allow_auto_memory_persistence { + return "hosted-approval-required".to_string(); + } + + format!( + "source-trust-below-threshold:{}<{}", + source_trust_label(config.memory_source_trust()), + source_trust_label(config.memory_trust_threshold()) + ) +} + +fn source_trust_label(trust: MemorySourceTrust) -> &'static str { + match trust { + MemorySourceTrust::SystemPolicy => "system-policy", + MemorySourceTrust::MaintainerApproved => "maintainer-approved", + MemorySourceTrust::DefaultBranchCode => "default-branch-code", + MemorySourceTrust::ContributorInput => "contributor-input", + MemorySourceTrust::ForkInput => "fork-input", + MemorySourceTrust::ModelInferred => "model-inferred", + MemorySourceTrust::Unknown => "unknown", + } } // --------------------------------------------------------------------------- @@ -410,6 +658,7 @@ fn parse_extraction_response(response: &str) -> Vec { content, category, confidence, + source_trust: MemorySourceTrust::Unknown, }); } @@ -576,6 +825,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; content: "Uses async Rust".to_string(), category: MemoryCategory::ProjectFact, confidence: 0.9, + source_trust: MemorySourceTrust::Unknown, }]; SessionMemoryExtractor::persist(&memories, &target) @@ -602,6 +852,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; content: "Prefers explicit error handling".to_string(), category: MemoryCategory::UserPreference, confidence: 0.8, + source_trust: MemorySourceTrust::Unknown, }]; SessionMemoryExtractor::persist(&memories, &target) @@ -627,6 +878,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; content: "New fact discovered".to_string(), category: MemoryCategory::ProjectFact, confidence: 0.7, + source_trust: MemorySourceTrust::Unknown, }]; SessionMemoryExtractor::persist(&memories, &target) @@ -652,6 +904,167 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; assert!(!target.exists()); } + #[tokio::test] + async fn hosted_fork_session_writes_candidate_not_durable_memory() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(".coven-code").join("AGENTS.md"); + let candidate_dir = dir.path().join(".coven-code").join("memory-candidates"); + let candidate_store = MemoryCandidateStore::new(&candidate_dir); + let memories = vec![ExtractedMemory { + content: "This fork claims auth checks are intentionally skipped".to_string(), + category: MemoryCategory::Constraint, + confidence: 0.9, + source_trust: MemorySourceTrust::Unknown, + }]; + let config = HostedReviewConfig { + enabled: true, + memory_source_trust: MemorySourceTrust::ForkInput, + ..Default::default() + }; + + let outcome = SessionMemoryExtractor::persist_with_policy( + &memories, + &target, + &candidate_store, + RuntimeMode::HostedReview, + &config, + ) + .await + .unwrap(); + + assert_eq!( + outcome, + MemoryPersistenceOutcome::CandidatesWritten { + count: 1, + reason: "hosted-approval-required".to_string() + } + ); + assert!(!target.exists()); + + let mut entries = fs::read_dir(&candidate_dir).await.unwrap(); + let entry = entries.next_entry().await.unwrap().unwrap(); + assert!(entries.next_entry().await.unwrap().is_none()); + let candidate: MemoryCandidate = + serde_json::from_str(&fs::read_to_string(entry.path()).await.unwrap()).unwrap(); + assert_eq!(candidate.status, MemoryCandidateStatus::Pending); + assert_eq!(candidate.source_trust, MemorySourceTrust::ForkInput); + assert_eq!( + candidate.rejection_reason.as_deref(), + Some("hosted-approval-required") + ); + } + + #[tokio::test] + async fn hosted_maintainer_session_writes_durable_only_when_policy_allows() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(".coven-code").join("AGENTS.md"); + let candidate_store = MemoryCandidateStore::for_working_dir(dir.path()); + let memories = vec![ExtractedMemory { + content: "Maintainers require explicit error handling".to_string(), + category: MemoryCategory::CodePattern, + confidence: 0.8, + source_trust: MemorySourceTrust::Unknown, + }]; + let config = HostedReviewConfig { + enabled: true, + allow_auto_memory_persistence: true, + memory_source_trust: MemorySourceTrust::MaintainerApproved, + ..Default::default() + }; + + let outcome = SessionMemoryExtractor::persist_with_policy( + &memories, + &target, + &candidate_store, + RuntimeMode::HostedReview, + &config, + ) + .await + .unwrap(); + + assert_eq!( + outcome, + MemoryPersistenceOutcome::DurableWritten { count: 1 } + ); + let content = fs::read_to_string(&target).await.unwrap(); + assert!(content.contains("Maintainers require explicit error handling")); + assert!(content.contains("trust: maintainer-approved")); + assert!(!dir + .path() + .join(".coven-code") + .join("memory-candidates") + .exists()); + } + + #[tokio::test] + async fn candidate_approval_promotes_to_durable_memory() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(".coven-code").join("AGENTS.md"); + let store = MemoryCandidateStore::for_working_dir(dir.path()); + let memories = vec![ExtractedMemory { + content: "Approved candidate fact".to_string(), + category: MemoryCategory::ProjectFact, + confidence: 0.7, + source_trust: MemorySourceTrust::ContributorInput, + }]; + let candidates = store + .write_pending( + &memories, + "session-memory-extraction", + "hosted-review", + "durable-memory", + Some("hosted-approval-required"), + ) + .await + .unwrap(); + + let approved = store.approve(&candidates[0].id, &target).await.unwrap(); + + assert_eq!(approved.status, MemoryCandidateStatus::Approved); + assert_eq!(approved.source_trust, MemorySourceTrust::MaintainerApproved); + let content = fs::read_to_string(&target).await.unwrap(); + assert!(content.contains("Approved candidate fact")); + assert!(content.contains("trust: maintainer-approved")); + } + + #[tokio::test] + async fn candidate_rejection_records_reason_without_durable_write() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(".coven-code").join("AGENTS.md"); + let store = MemoryCandidateStore::for_working_dir(dir.path()); + let memories = vec![ExtractedMemory { + content: "Rejected candidate fact".to_string(), + category: MemoryCategory::ProjectFact, + confidence: 0.7, + source_trust: MemorySourceTrust::ContributorInput, + }]; + let candidates = store + .write_pending( + &memories, + "session-memory-extraction", + "hosted-review", + "durable-memory", + None, + ) + .await + .unwrap(); + + let rejected = store + .reject(&candidates[0].id, "not-repo-policy") + .await + .unwrap(); + + assert_eq!(rejected.status, MemoryCandidateStatus::Rejected); + assert_eq!( + rejected.rejection_reason.as_deref(), + Some("not-repo-policy") + ); + assert!(!target.exists()); + let stored = store.read_candidate(&candidates[0].id).await.unwrap(); + assert_eq!(stored.status, MemoryCandidateStatus::Rejected); + assert_eq!(stored.rejection_reason.as_deref(), Some("not-repo-policy")); + } + // ---- SessionMemoryState -------------------------------------------- #[test]