From 0b785214b1888d41feb46afb66e9582f617a0b75 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 5 Jul 2026 16:01:26 -0400 Subject: [PATCH] feat(core): enforce hosted memory provenance (#105 #106 #112) Fixes #105. Fixes #106. Fixes #112. - enforce hosted memory frontmatter scope and trust - preserve provenance metadata in hosted memory context - require structured review output to cite memory refs Signed-off-by: Timothy Wayne Gregg --- docs/configuration.md | 24 +- docs/shared/hosted-review-phase-4-pr.md | 34 +++ src-rust/crates/commands/src/lib.rs | 60 +++- src-rust/crates/core/src/claudemd.rs | 311 +++++++++++++++++++- src-rust/crates/core/src/lib.rs | 40 ++- src-rust/crates/core/src/system_prompt.rs | 26 +- src-rust/crates/core/src/voice.rs | 26 +- src-rust/crates/query/src/lib.rs | 5 + src-rust/crates/query/src/session_memory.rs | 9 +- 9 files changed, 511 insertions(+), 24 deletions(-) create mode 100644 docs/shared/hosted-review-phase-4-pr.md diff --git a/docs/configuration.md b/docs/configuration.md index 07934988..8df5b48d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -350,7 +350,12 @@ AGENTS.md files may begin with optional YAML frontmatter to control loading: --- memory_type: project priority: 10 -scope: project +scope: repo +trust: maintainer_approved +visibility: public_review +source: github_pr +source_ref: OpenCoven/coven-code#123 +expires_at: 2099-12-31 --- # My Project Notes @@ -362,9 +367,22 @@ Frontmatter fields: | Field | Description | |-------|-------------| -| `memory_type` | Informal label (currently informational only). | +| `id` | Stable memory id used for hosted review citation, for example `mem_auth_policy`. If omitted, Coven Code derives a stable id from path and content. | +| `memory_type` | Memory category label such as `project`, `user`, `reference`, or `feedback`. | | `priority` | Integer sort priority (lower numbers are prepended first within the same scope). | -| `scope` | Informational label for documentation purposes. | +| `scope` | Intended scope, such as `user`, `tenant`, `installation`, `repo`, `branch`, or `pr`. | +| `trust` | Source trust. Hosted review enforces this against `hostedReview.memoryTrustThreshold`. Supported values include `system_policy`, `maintainer_approved`, `default_branch_code`, `model_inferred`, `contributor_input`, `fork_input`, and `unknown`. | +| `visibility` | Intended review visibility: `public_review`, `private_review`, or `security_private`. Hosted public reviews exclude `security_private` memory by default. | +| `source` | Provenance source kind, for example `manual`, `github_pr`, `github_pr_review`, or `session_memory_extraction`. | +| `source_ref` | Source reference such as `owner/repo#123`, a commit SHA, or another non-secret audit handle. | +| `expires_at` | Optional expiry date in `YYYY-MM-DD` format. Expired hosted memory is ignored. | +| `created_at`, `created_by`, `session_id`, `transcript_ref`, `confidence` | Optional provenance fields for audit and review artifacts. | + +Local mode tolerates missing metadata for backward compatibility. Hosted review +mode treats missing trust as `unknown`, ignores expired memory, and excludes +memory below the configured trust threshold. Tagged hosted memory is injected +with memory ids and provenance metadata; findings that rely on memory should +include those ids in `memory_refs`. ### @include directives diff --git a/docs/shared/hosted-review-phase-4-pr.md b/docs/shared/hosted-review-phase-4-pr.md new file mode 100644 index 00000000..3d51abd2 --- /dev/null +++ b/docs/shared/hosted-review-phase-4-pr.md @@ -0,0 +1,34 @@ +# Hosted Review Phase 4 PR Notes + +## Linked issues + +Fixes #105. +Fixes #106. +Fixes #112. + +## Summary + +- Extends AGENTS.md frontmatter with enforceable hosted metadata: stable id, trust, visibility, source, source_ref, expiry, created_at, created_by, session_id, transcript_ref, and confidence. +- Enforces hosted memory metadata during loading: missing trust is lowest trust, expired memory is ignored, security-private memory is excluded from public hosted review, and trust must meet the configured threshold. +- Renders hosted memory as tagged entries with stable ids, trust, visibility, source, source_ref, and session metadata so review output can cite memory refs. +- Threads session-scoped provenance into hosted auto-extracted memory candidates. +- Adds structured review-output parsing and validation helpers for `memory_refs` on memory-dependent findings. +- Documents the hosted frontmatter contract and citation behavior. + +## Test evidence + +- `cargo fmt --all -- --check` +- `cargo check --workspace` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test -p claurst-core --lib claudemd --quiet` +- `cargo test -p claurst-core --lib system_prompt --quiet` +- `cargo test -p claurst-core --lib --quiet` +- `cargo test -p claurst-query session_memory --quiet` +- `cargo test -p claurst-commands structured_review_output --quiet` +- `cargo test --workspace --quiet` + +## Risk notes + +- The slash `/review` command remains markdown-first; the structured review parser is available for hosted integrations that request JSON review artifacts. +- Hosted memory without trust metadata is intentionally ignored unless policy lowers the trust threshold. +- Provenance stores references and ids, not secret values. diff --git a/src-rust/crates/commands/src/lib.rs b/src-rust/crates/commands/src/lib.rs index 90233f59..ded9f487 100644 --- a/src-rust/crates/commands/src/lib.rs +++ b/src-rust/crates/commands/src/lib.rs @@ -3382,6 +3382,39 @@ impl SlashCommand for LearnCommand { // ---- /review ------------------------------------------------------------- +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +pub struct StructuredReviewOutput { + #[serde(default)] + pub findings: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +pub struct StructuredReviewFinding { + pub title: String, + #[serde(default)] + pub memory_refs: Vec, + #[serde(default)] + pub memory_dependent: bool, +} + +pub fn parse_structured_review_output(text: &str) -> Option { + serde_json::from_str::(text).ok() +} + +pub fn validate_structured_review_memory_refs(review: &StructuredReviewOutput) -> Vec { + review + .findings + .iter() + .filter(|finding| finding.memory_dependent && finding.memory_refs.is_empty()) + .map(|finding| { + format!( + "finding '{}' is marked memory-dependent but has no memory_refs", + finding.title + ) + }) + .collect() +} + #[async_trait] impl SlashCommand for ReviewCommand { fn name(&self) -> &str { @@ -3527,7 +3560,8 @@ impl SlashCommand for ReviewCommand { (1-3 sentences describing what changed)\n\n\ ## Issues\n\ (bulleted list: [CRITICAL|MAJOR|MINOR] file:line — description; \ - omit section if none)\n\n\ + omit section if none; if the issue depends on a loaded memory entry, \ + include memory_refs: [\"mem_...\"] on that bullet)\n\n\ ## Suggestions\n\ (bulleted list of optional improvements; omit section if none)\n\n\ ## Verdict\n\ @@ -10488,6 +10522,30 @@ mod tests { } } + #[test] + fn structured_review_output_parses_memory_refs() { + let review = parse_structured_review_output( + r#"{"findings":[{"title":"Auth check","memory_refs":["mem_auth"],"memory_dependent":true}]}"#, + ) + .unwrap(); + + assert_eq!(review.findings[0].memory_refs, vec!["mem_auth"]); + assert!(validate_structured_review_memory_refs(&review).is_empty()); + } + + #[test] + fn structured_review_output_warns_when_memory_refs_missing() { + let review = parse_structured_review_output( + r#"{"findings":[{"title":"Auth check","memory_dependent":true}]}"#, + ) + .unwrap(); + + let warnings = validate_structured_review_memory_refs(&review); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("Auth check")); + assert!(warnings[0].contains("memory_refs")); + } + #[tokio::test] async fn test_learn_resolves_and_emits_skill_prompt() { // Resolvable by name and by alias. diff --git a/src-rust/crates/core/src/claudemd.rs b/src-rust/crates/core/src/claudemd.rs index 0c0d328a..eb3fbdfc 100644 --- a/src-rust/crates/core/src/claudemd.rs +++ b/src-rust/crates/core/src/claudemd.rs @@ -5,11 +5,12 @@ //! Supports @include directives, YAML frontmatter, and mtime-based caching. use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::SystemTime; -use crate::hosted_review::RuntimeMode; +use crate::hosted_review::{MemorySourceTrust, RuntimeMode}; // --------------------------------------------------------------------------- // Types @@ -32,12 +33,42 @@ pub enum MemoryScope { /// Frontmatter parsed from a AGENTS.md file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MemoryFrontmatter { + #[serde(default)] + pub id: Option, #[serde(default)] pub memory_type: Option, #[serde(default)] pub priority: Option, #[serde(default)] pub scope: Option, + #[serde(default)] + pub trust: Option, + #[serde(default)] + pub visibility: Option, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub source_ref: Option, + #[serde(default)] + pub expires_at: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub created_by: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub transcript_ref: Option, + #[serde(default)] + pub confidence: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryVisibility { + PublicReview, + PrivateReview, + SecurityPrivate, } /// Loaded memory file with metadata. @@ -56,6 +87,8 @@ pub struct MemoryLoadOptions { pub mode: RuntimeMode, pub allow_user_memory: bool, pub allow_managed_rules: bool, + pub min_trust: MemorySourceTrust, + pub allow_security_private: bool, } impl MemoryLoadOptions { @@ -64,6 +97,8 @@ impl MemoryLoadOptions { mode: RuntimeMode::Local, allow_user_memory: true, allow_managed_rules: true, + min_trust: MemorySourceTrust::Unknown, + allow_security_private: true, } } @@ -72,6 +107,8 @@ impl MemoryLoadOptions { mode: RuntimeMode::HostedReview, allow_user_memory: false, allow_managed_rules: false, + min_trust: MemorySourceTrust::MaintainerApproved, + allow_security_private: false, } } @@ -145,9 +182,22 @@ pub fn parse_frontmatter(content: &str) -> (MemoryFrontmatter, &str) { if let Some((key, val)) = line.split_once(':') { let val = val.trim().to_string(); match key.trim() { + "id" => fm.id = Some(strip_frontmatter_value(&val).to_string()), "memory_type" => fm.memory_type = Some(val), "priority" => fm.priority = val.parse().ok(), - "scope" => fm.scope = Some(val), + "scope" => fm.scope = Some(strip_frontmatter_value(&val).to_string()), + "trust" => fm.trust = parse_memory_trust(&val), + "visibility" => fm.visibility = parse_memory_visibility(&val), + "source" => fm.source = Some(strip_frontmatter_value(&val).to_string()), + "source_ref" => fm.source_ref = Some(strip_frontmatter_value(&val).to_string()), + "expires_at" => fm.expires_at = Some(strip_frontmatter_value(&val).to_string()), + "created_at" => fm.created_at = Some(strip_frontmatter_value(&val).to_string()), + "created_by" => fm.created_by = Some(strip_frontmatter_value(&val).to_string()), + "session_id" => fm.session_id = Some(strip_frontmatter_value(&val).to_string()), + "transcript_ref" => { + fm.transcript_ref = Some(strip_frontmatter_value(&val).to_string()) + } + "confidence" => fm.confidence = val.parse().ok(), _ => {} } } @@ -157,6 +207,41 @@ pub fn parse_frontmatter(content: &str) -> (MemoryFrontmatter, &str) { (MemoryFrontmatter::default(), content) } +fn strip_frontmatter_value(value: &str) -> &str { + value.trim().trim_matches('"').trim_matches('\'') +} + +fn normalized_frontmatter_value(value: &str) -> String { + strip_frontmatter_value(value) + .trim() + .to_ascii_lowercase() + .replace('-', "_") +} + +fn parse_memory_trust(value: &str) -> Option { + match normalized_frontmatter_value(value).as_str() { + "system_policy" => Some(MemorySourceTrust::SystemPolicy), + "maintainer_approved" | "maintainer" => Some(MemorySourceTrust::MaintainerApproved), + "default_branch_code" | "default_branch" => Some(MemorySourceTrust::DefaultBranchCode), + "contributor_input" | "contributor" | "untrusted" => { + Some(MemorySourceTrust::ContributorInput) + } + "fork_input" | "fork" => Some(MemorySourceTrust::ForkInput), + "model_inferred" => Some(MemorySourceTrust::ModelInferred), + "unknown" => Some(MemorySourceTrust::Unknown), + _ => None, + } +} + +fn parse_memory_visibility(value: &str) -> Option { + match normalized_frontmatter_value(value).as_str() { + "public_review" | "public" => Some(MemoryVisibility::PublicReview), + "private_review" | "private" => Some(MemoryVisibility::PrivateReview), + "security_private" | "security" => Some(MemoryVisibility::SecurityPrivate), + _ => None, + } +} + // --------------------------------------------------------------------------- // @include directive expansion // --------------------------------------------------------------------------- @@ -262,6 +347,115 @@ pub fn load_memory_file(path: &Path, scope: MemoryScope) -> Option bool { + if !options.mode.is_hosted_review() { + return true; + } + + if memory_is_expired(file.frontmatter.expires_at.as_deref()) { + return false; + } + + if matches!( + file.frontmatter.visibility, + Some(MemoryVisibility::SecurityPrivate) + ) && !options.allow_security_private + { + return false; + } + + file.frontmatter + .trust + .unwrap_or(MemorySourceTrust::Unknown) + .meets_threshold(options.min_trust) +} + +fn memory_is_expired(expires_at: Option<&str>) -> bool { + let Some(expires_at) = expires_at else { + return false; + }; + let Ok(expires) = chrono::NaiveDate::parse_from_str(expires_at.trim(), "%Y-%m-%d") else { + return false; + }; + expires < chrono::Local::now().date_naive() +} + +pub fn memory_id(file: &MemoryFileInfo) -> String { + if let Some(id) = file.frontmatter.id.as_deref().filter(|id| !id.is_empty()) { + return id.to_string(); + } + + let mut hasher = Sha256::new(); + hasher.update(file.path.to_string_lossy().as_bytes()); + hasher.update(b"\0"); + hasher.update(file.content.as_bytes()); + let digest = hasher.finalize(); + format!("mem_{}", hex::encode(&digest[..8])) +} + +pub fn format_memory_file_for_prompt(file: &MemoryFileInfo, hosted: bool) -> String { + let body = file.content.trim(); + if !hosted { + return body.to_string(); + } + + let trust = file + .frontmatter + .trust + .map(memory_trust_label) + .unwrap_or("unknown"); + let visibility = file + .frontmatter + .visibility + .map(memory_visibility_label) + .unwrap_or("unspecified"); + let source = file.frontmatter.source.as_deref().unwrap_or("manual"); + let source_ref = file.frontmatter.source_ref.as_deref().unwrap_or(""); + let mut attrs = format!( + "id=\"{}\" trust=\"{}\" visibility=\"{}\" source=\"{}\"", + xml_escape_attr(&memory_id(file)), + trust, + visibility, + xml_escape_attr(source) + ); + if !source_ref.is_empty() { + attrs.push_str(&format!(" source_ref=\"{}\"", xml_escape_attr(source_ref))); + } + if let Some(session_id) = file.frontmatter.session_id.as_deref() { + attrs.push_str(&format!(" session_id=\"{}\"", xml_escape_attr(session_id))); + } + + format!("\n{}\n", attrs, body) +} + +fn memory_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", + } +} + +fn memory_visibility_label(visibility: MemoryVisibility) -> &'static str { + match visibility { + MemoryVisibility::PublicReview => "public-review", + MemoryVisibility::PrivateReview => "private-review", + MemoryVisibility::SecurityPrivate => "security-private", + } +} + +fn xml_escape_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + /// Load memory files from a directory for a given scope. /// /// Loads `AGENTS.md` first (primary/universal standard), then `CLAUDE.md` if @@ -336,6 +530,9 @@ pub fn load_all_memory_files_with_options( ); files + .into_iter() + .filter(|file| memory_file_allowed_for_options(file, options)) + .collect() } /// Concatenate all memory file contents into a single system-prompt fragment. @@ -343,7 +540,19 @@ pub fn build_memory_prompt(files: &[MemoryFileInfo]) -> String { files .iter() .filter(|f| !f.content.trim().is_empty()) - .map(|f| f.content.trim().to_string()) + .map(|f| format_memory_file_for_prompt(f, false)) + .collect::>() + .join("\n\n") +} + +pub fn build_memory_prompt_with_options( + files: &[MemoryFileInfo], + options: &MemoryLoadOptions, +) -> String { + files + .iter() + .filter(|f| !f.content.trim().is_empty()) + .map(|f| format_memory_file_for_prompt(f, options.mode.is_hosted_review())) .collect::>() .join("\n\n") } @@ -361,6 +570,23 @@ mod tests { assert_eq!(body.trim(), "Hello world"); } + #[test] + fn parse_frontmatter_hosted_metadata() { + let content = "---\nid: mem_auth\nmemory_type: project\nscope: repo\ntrust: maintainer_approved\nvisibility: public_review\nsource: github_pr\nsource_ref: OpenCoven/coven-code#123\nexpires_at: 2099-12-31\nsession_id: sess-1\nconfidence: 0.9\n---\nUse explicit auth checks."; + let (fm, body) = parse_frontmatter(content); + + assert_eq!(fm.id.as_deref(), Some("mem_auth")); + assert_eq!(fm.scope.as_deref(), Some("repo")); + assert_eq!(fm.trust, Some(MemorySourceTrust::MaintainerApproved)); + assert_eq!(fm.visibility, Some(MemoryVisibility::PublicReview)); + assert_eq!(fm.source.as_deref(), Some("github_pr")); + assert_eq!(fm.source_ref.as_deref(), Some("OpenCoven/coven-code#123")); + assert_eq!(fm.expires_at.as_deref(), Some("2099-12-31")); + assert_eq!(fm.session_id.as_deref(), Some("sess-1")); + assert_eq!(fm.confidence, Some(0.9)); + assert_eq!(body.trim(), "Use explicit auth checks."); + } + #[test] fn parse_frontmatter_none() { let content = "No frontmatter here"; @@ -413,7 +639,11 @@ mod tests { #[test] fn hosted_review_excludes_user_memory_by_default() { let project = tempfile::tempdir().unwrap(); - std::fs::write(project.path().join("AGENTS.md"), "project memory").unwrap(); + std::fs::write( + project.path().join("AGENTS.md"), + "---\ntrust: maintainer_approved\nvisibility: public_review\n---\nproject memory", + ) + .unwrap(); let home = tempfile::tempdir().unwrap(); let coven_code = home.path().join(".coven-code"); @@ -452,7 +682,11 @@ mod tests { let home = tempfile::tempdir().unwrap(); let rules = home.path().join(".coven-code").join("rules"); std::fs::create_dir_all(&rules).unwrap(); - std::fs::write(rules.join("managed.md"), "managed hosted policy").unwrap(); + std::fs::write( + rules.join("managed.md"), + "---\ntrust: system_policy\nvisibility: public_review\n---\nmanaged hosted policy", + ) + .unwrap(); let _lock = crate::coven_shared::COVEN_HOME_ENV_LOCK .lock() @@ -491,6 +725,73 @@ mod tests { })); } + #[test] + fn hosted_review_excludes_missing_or_untrusted_memory_metadata() { + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("AGENTS.md"), "legacy project memory").unwrap(); + std::fs::create_dir_all(project.path().join(".coven-code")).unwrap(); + std::fs::write( + project.path().join(".coven-code").join("AGENTS.md"), + "---\ntrust: contributor_input\nvisibility: public_review\n---\nuntrusted memory", + ) + .unwrap(); + + let files = + load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::hosted_review()); + + assert!(files.is_empty()); + } + + #[test] + fn hosted_review_excludes_expired_memory() { + let project = tempfile::tempdir().unwrap(); + std::fs::write( + project.path().join("AGENTS.md"), + "---\ntrust: maintainer_approved\nvisibility: public_review\nexpires_at: 2000-01-01\n---\nexpired memory", + ) + .unwrap(); + + let files = + load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::hosted_review()); + + assert!(files.is_empty()); + } + + #[test] + fn hosted_review_excludes_security_private_memory_by_default() { + let project = tempfile::tempdir().unwrap(); + std::fs::write( + project.path().join("AGENTS.md"), + "---\ntrust: maintainer_approved\nvisibility: security_private\n---\nprivate memory", + ) + .unwrap(); + + let files = + load_all_memory_files_with_options(project.path(), &MemoryLoadOptions::hosted_review()); + + assert!(files.is_empty()); + } + + #[test] + fn hosted_review_renders_memory_ids_and_provenance() { + let project = tempfile::tempdir().unwrap(); + std::fs::write( + project.path().join("AGENTS.md"), + "---\nid: mem_review_policy\ntrust: maintainer_approved\nvisibility: public_review\nsource: github_pr\nsource_ref: OpenCoven/coven-code#123\nsession_id: sess-1\n---\nAlways cite auth policy.", + ) + .unwrap(); + + let options = MemoryLoadOptions::hosted_review(); + let files = load_all_memory_files_with_options(project.path(), &options); + let prompt = build_memory_prompt_with_options(&files, &options); + + assert!(prompt.contains(" String { // 12. Memory injection (from memdir) if !opts.memory_content.is_empty() { - parts.push(format!("\n\n{}\n", opts.memory_content)); + if opts.memory_content.contains("\n{}\n\n\ + If a finding, recommendation, or decision depends on a memory entry, cite its memory id in `memory_refs`.", + opts.memory_content + )); + } else { + parts.push(format!("\n\n{}\n", opts.memory_content)); + } } // 13. Active goal addendum (dynamic — changes each session) @@ -615,6 +623,22 @@ mod tests { ); } + #[test] + fn hosted_tagged_memory_uses_context_wrapper_and_citation_instruction() { + let opts = SystemPromptOptions { + memory_content: + "\nUse repo auth policy.\n" + .to_string(), + ..Default::default() + }; + let prompt = build_system_prompt(&opts); + + assert!(prompt.contains("")); + assert!(prompt.contains("id=\"mem_123\"")); + assert!(prompt.contains("memory_refs")); + assert!(!prompt.contains("\n(f: impl FnOnce() -> T) -> T { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let original = std::env::var(KILL_SWITCH_ENV).ok(); + std::env::remove_var(KILL_SWITCH_ENV); + let result = f(); + match original { + Some(value) => std::env::set_var(KILL_SWITCH_ENV, value), + None => std::env::remove_var(KILL_SWITCH_ENV), + } + result + } + #[test] fn test_no_tokens_requires_oauth() { - let result = check_voice_availability(None); + let result = with_kill_switch_unset(|| check_voice_availability(None)); assert_eq!(result, VoiceAvailability::RequiresOAuth); assert!(!result.is_available()); assert!(result.error_message().is_some()); @@ -599,7 +611,7 @@ mod tests { #[test] fn test_available_with_all_scopes() { let tokens = tokens_with_scopes(vec!["user:inference", "user:profile"]); - let result = check_voice_availability(Some(&tokens)); + let result = with_kill_switch_unset(|| check_voice_availability(Some(&tokens))); assert_eq!(result, VoiceAvailability::Available); assert!(result.is_available()); assert!(result.error_message().is_none()); @@ -608,7 +620,7 @@ mod tests { #[test] fn test_missing_one_scope() { let tokens = tokens_with_scopes(vec!["user:inference"]); - let result = check_voice_availability(Some(&tokens)); + let result = with_kill_switch_unset(|| check_voice_availability(Some(&tokens))); assert!(matches!(result, VoiceAvailability::MissingScopes { .. })); assert!(!result.is_available()); let msg = result.error_message().unwrap(); @@ -617,10 +629,8 @@ mod tests { #[test] fn test_missing_all_scopes() { - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - std::env::remove_var(KILL_SWITCH_ENV); let tokens = tokens_with_scopes(vec!["org:create_api_key"]); - let result = check_voice_availability(Some(&tokens)); + let result = with_kill_switch_unset(|| check_voice_availability(Some(&tokens))); assert!(matches!(result, VoiceAvailability::MissingScopes { .. })); assert!(!result.is_available()); } @@ -628,7 +638,7 @@ mod tests { #[test] fn test_empty_scopes_missing() { let tokens = tokens_with_scopes(vec![]); - let result = check_voice_availability(Some(&tokens)); + let result = with_kill_switch_unset(|| check_voice_availability(Some(&tokens))); assert!( matches!(result, VoiceAvailability::MissingScopes { ref have, .. } if have.is_empty()) ); @@ -671,7 +681,7 @@ mod tests { "org:create_api_key", "user:file_upload", ]); - let result = check_voice_availability(Some(&tokens)); + let result = with_kill_switch_unset(|| check_voice_availability(Some(&tokens))); assert_eq!(result, VoiceAvailability::Available); } diff --git a/src-rust/crates/query/src/lib.rs b/src-rust/crates/query/src/lib.rs index d2a46cc9..3b496957 100644 --- a/src-rust/crates/query/src/lib.rs +++ b/src-rust/crates/query/src/lib.rs @@ -1959,6 +1959,10 @@ pub async fn run_query_loop( 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(); + let memory_provenance = format!( + "session:{};source:session-memory-extraction", + tool_ctx.session_id + ); // Build a fresh client using the same API key. This avoids // requiring an Arc in the existing run_query_loop signature. @@ -1992,6 +1996,7 @@ pub async fn run_query_loop( &candidate_store, runtime_mode, &hosted_review_config, + &memory_provenance, ) .await { diff --git a/src-rust/crates/query/src/session_memory.rs b/src-rust/crates/query/src/session_memory.rs index 6abb5232..5b0e0156 100644 --- a/src-rust/crates/query/src/session_memory.rs +++ b/src-rust/crates/query/src/session_memory.rs @@ -525,6 +525,7 @@ impl SessionMemoryExtractor { candidate_store: &MemoryCandidateStore, mode: RuntimeMode, hosted_config: &HostedReviewConfig, + provenance: &str, ) -> anyhow::Result { if memories.is_empty() { return Ok(MemoryPersistenceOutcome::Skipped); @@ -555,7 +556,7 @@ impl SessionMemoryExtractor { let candidates = candidate_store .write_pending( &trusted_memories, - "session-memory-extraction", + provenance, "hosted-review", "durable-memory", Some(&reason), @@ -928,6 +929,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; &candidate_store, RuntimeMode::HostedReview, &config, + "session:test-session;source:session-memory-extraction", ) .await .unwrap(); @@ -948,6 +950,10 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; 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.provenance, + "session:test-session;source:session-memory-extraction" + ); assert_eq!( candidate.rejection_reason.as_deref(), Some("hosted-approval-required") @@ -978,6 +984,7 @@ MEMORY: code_pattern | 7 | Uses builder pattern"; &candidate_store, RuntimeMode::HostedReview, &config, + "session:test-session;source:session-memory-extraction", ) .await .unwrap();