diff --git a/crates/vestige-core/src/advanced/compression.rs b/crates/vestige-core/src/advanced/compression.rs index 2ea2309..e99458b 100644 --- a/crates/vestige-core/src/advanced/compression.rs +++ b/crates/vestige-core/src/advanced/compression.rs @@ -274,7 +274,10 @@ impl MemoryCompressor { // Update stats self.stats.memories_compressed += memories.len(); self.stats.compressions_created += 1; - self.stats.bytes_saved += original_size - compressed.compressed_size; + // saturating_sub: short/repetitive memories can compress LARGER than the + // original (header + bullet facts > tiny inputs), which would underflow a + // usize subtraction (panic in debug, huge wrap in release). + self.stats.bytes_saved += original_size.saturating_sub(compressed.compressed_size); self.stats.operations += 1; self.update_average_stats(&compressed); diff --git a/crates/vestige-core/src/advanced/dreams.rs b/crates/vestige-core/src/advanced/dreams.rs index 5cf3492..81d9039 100644 --- a/crates/vestige-core/src/advanced/dreams.rs +++ b/crates/vestige-core/src/advanced/dreams.rs @@ -357,8 +357,13 @@ impl ConsolidationScheduler { /// Stage 1: Replay recent memories in sequence fn stage1_replay(&self, memories: &[DreamMemory]) -> MemoryReplay { - // Sort by creation time for sequential replay - let mut sorted: Vec<_> = memories.iter().take(MAX_REPLAY_MEMORIES).collect(); + // Select the MOST RECENT memories, then order them chronologically for + // sequential replay. The old code took the first N in arbitrary input + // order BEFORE sorting, so "recent" memories were dropped whenever the + // caller's slice was not already recency-ordered. + let mut sorted: Vec<_> = memories.iter().collect(); + sorted.sort_by_key(|m| std::cmp::Reverse(m.created_at)); + sorted.truncate(MAX_REPLAY_MEMORIES); sorted.sort_by_key(|m| m.created_at); let sequence: Vec = sorted.iter().map(|m| m.id.clone()).collect(); diff --git a/crates/vestige-core/src/advanced/prediction_error.rs b/crates/vestige-core/src/advanced/prediction_error.rs index 3693d77..17277db 100644 --- a/crates/vestige-core/src/advanced/prediction_error.rs +++ b/crates/vestige-core/src/advanced/prediction_error.rs @@ -493,6 +493,10 @@ impl PredictionErrorGate { ) -> GateDecision { match intent { EvaluationIntent::ForceCreate => { + // Count this evaluation: the fallback branches reach evaluate() + // (which counts), but these direct branches must count themselves + // or create/update/supersede rates can exceed 1.0. + self.stats.total_evaluations += 1; self.stats.creates += 1; GateDecision::Create { reason: CreateReason::ExplicitCreate, @@ -504,6 +508,7 @@ impl PredictionErrorGate { // Find the target candidate if let Some(c) = candidates.iter().find(|c| c.id == target_id) { let similarity = cosine_similarity(new_embedding, &c.embedding); + self.stats.total_evaluations += 1; self.stats.updates += 1; GateDecision::Update { target_id: target_id.clone(), @@ -522,6 +527,7 @@ impl PredictionErrorGate { } => { if let Some(c) = candidates.iter().find(|c| c.id == old_memory_id) { let similarity = cosine_similarity(new_embedding, &c.embedding); + self.stats.total_evaluations += 1; self.stats.supersedes += 1; GateDecision::Supersede { old_memory_id, diff --git a/crates/vestige-core/src/advanced/reconsolidation.rs b/crates/vestige-core/src/advanced/reconsolidation.rs index 933442c..93c99e6 100644 --- a/crates/vestige-core/src/advanced/reconsolidation.rs +++ b/crates/vestige-core/src/advanced/reconsolidation.rs @@ -551,6 +551,10 @@ impl ReconsolidationManager { /// /// Returns the reconsolidation result with all applied modifications. pub fn reconsolidate(&mut self, memory_id: &str) -> Option { + // remove() already guarantees idempotency: a second call finds no entry + // and returns None via `?`. The `reconsolidated` flag was never set to + // true anywhere, so the guard below was dead — but keep it as a correct, + // explicit belt-and-suspenders in case the entry is ever retained. let state = self.labile_memories.remove(memory_id)?; if state.reconsolidated { diff --git a/crates/vestige-core/src/advanced/speculative.rs b/crates/vestige-core/src/advanced/speculative.rs index eebe947..66ff19f 100644 --- a/crates/vestige-core/src/advanced/speculative.rs +++ b/crates/vestige-core/src/advanced/speculative.rs @@ -268,13 +268,23 @@ impl SpeculativeRetriever { } } - // Update file-memory associations + // Update file-memory associations. Dedupe and cap per file so the map + // cannot grow without bound in a long-running server (mirrors the + // MAX_PATTERN_HISTORY trim on access_sequence above). if let Some(file) = file_context && let Ok(mut map) = self.file_memory_map.write() { - map.entry(file.to_string()) - .or_insert_with(Vec::new) - .push(memory_id.to_string()); + let ids = map.entry(file.to_string()).or_insert_with(Vec::new); + let id = memory_id.to_string(); + if !ids.contains(&id) { + ids.push(id); + // keep only the most recent N associations per file + const MAX_FILE_MEMORIES: usize = 256; + if ids.len() > MAX_FILE_MEMORIES { + let excess = ids.len() - MAX_FILE_MEMORIES; + ids.drain(0..excess); + } + } } } diff --git a/crates/vestige-core/src/codebase/relationships.rs b/crates/vestige-core/src/codebase/relationships.rs index ce7a1d2..fb2305a 100644 --- a/crates/vestige-core/src/codebase/relationships.rs +++ b/crates/vestige-core/src/codebase/relationships.rs @@ -176,6 +176,15 @@ impl RelationshipTracker { let id = relationship.id.clone(); + // Reject duplicate ids: a second insert with the same id but different + // files would overwrite the relationship while leaving the stale id in + // each previous file's index, corrupting get_related_files. + if self.relationships.contains_key(&id) { + return Err(RelationshipError::Invalid(format!( + "duplicate relationship id: {id}" + ))); + } + // Index by each file for file in &relationship.files { self.file_relationships diff --git a/crates/vestige-core/src/connectors/github.rs b/crates/vestige-core/src/connectors/github.rs index 3665b3f..f31e0a3 100644 --- a/crates/vestige-core/src/connectors/github.rs +++ b/crates/vestige-core/src/connectors/github.rs @@ -103,6 +103,18 @@ impl GithubConnector { "owner and repo are required".to_string(), )); } + // owner/repo are interpolated raw into request URLs; restrict them to + // GitHub's actual charset so `/`, `%`, `?`, `#`, traversal sequences, etc. + // cannot break out of the path or redirect the request. + let valid = |s: &str| { + s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + }; + if !valid(&config.owner) || !valid(&config.repo) { + return Err(ConnectorError::Config( + "owner/repo may only contain [A-Za-z0-9._-]".to_string(), + )); + } let client = reqwest::Client::builder() .user_agent(USER_AGENT) .build() diff --git a/crates/vestige-core/src/connectors/redmine.rs b/crates/vestige-core/src/connectors/redmine.rs index de4464f..df4369d 100644 --- a/crates/vestige-core/src/connectors/redmine.rs +++ b/crates/vestige-core/src/connectors/redmine.rs @@ -445,7 +445,11 @@ impl Connector for RedmineConnector { // Enumerate all issue ids (open AND closed) for the reconcile pass. // status_id=* is mandatory here too, or closed issues read as deleted. let mut ids = Vec::new(); - let mut offset: u32 = 0; + // u64 offset (a u32 could wrap on a huge/compromised total_count, turning + // the loop infinite + allocating unboundedly). Also hard-cap pages. + let mut offset: u64 = 0; + const MAX_PAGES: u32 = 10_000; + let mut pages = 0u32; loop { let url = format!("{}/issues.json", self.config.root()); let resp = self @@ -472,8 +476,14 @@ impl Connector for RedmineConnector { for issue in &page.issues { ids.push(issue.id.to_string()); } - offset += page.issues.len() as u32; - if (offset as u64) >= page.total_count { + let new_offset = offset + page.issues.len() as u64; + // Defensive: a non-advancing page would loop forever. + if new_offset <= offset { + break; + } + offset = new_offset; + pages += 1; + if offset >= page.total_count || pages >= MAX_PAGES { break; } } diff --git a/crates/vestige-core/src/consolidation/phases.rs b/crates/vestige-core/src/consolidation/phases.rs index 7f74489..9fe41b7 100644 --- a/crates/vestige-core/src/consolidation/phases.rs +++ b/crates/vestige-core/src/consolidation/phases.rs @@ -626,13 +626,15 @@ impl DreamEngine { tag_b: &str, conn_type: CreativeConnectionType, ) -> String { + // char-boundary-safe: &content[..60] panics if a multi-byte UTF-8 char + // straddles byte 60. get(..60) returns None at a non-boundary => fall back. let a_summary = if a.content.len() > 60 { - &a.content[..60] + a.content.get(..60).unwrap_or(&a.content) } else { &a.content }; let b_summary = if b.content.len() > 60 { - &b.content[..60] + b.content.get(..60).unwrap_or(&b.content) } else { &b.content }; diff --git a/crates/vestige-mcp/src/tools/backfill.rs b/crates/vestige-mcp/src/tools/backfill.rs index 7d0b155..d7c6e69 100644 --- a/crates/vestige-mcp/src/tools/backfill.rs +++ b/crates/vestige-mcp/src/tools/backfill.rs @@ -14,11 +14,10 @@ use serde::Deserialize; use serde_json::{Value, json}; -use std::collections::HashSet; use std::sync::Arc; use vestige_core::advanced::retroactive_backfill::{ - BackfillCandidate, FailureEvent, RetroactiveBackfill, + self, BackfillCandidate, FailureEvent, RetroactiveBackfill, }; use vestige_core::advanced::prediction_error::cosine_similarity; use vestige_core::{KnowledgeNode, Storage}; @@ -70,41 +69,23 @@ struct Args { } /// Pull entities out of a memory: its tags, plus heuristic code-ish tokens from -/// content (UPPER_SNAKE env vars, dotted/slashed file paths, FooBar identifiers). -/// These are the shared-entity join keys the backward reach follows. +/// content (UPPER_SNAKE env vars, dotted/slashed file paths). These are the +/// shared-entity join keys the backward reach follows. +/// +/// Thin `&KnowledgeNode` adapter over the single core definition +/// [`retroactive_backfill::extract_entities`] so the MCP tool, CLI, and the +/// offline consolidation pass all extract entities identically (no drift). fn extract_entities(node: &KnowledgeNode) -> Vec { - let mut set: HashSet = node.tags.iter().map(|t| t.to_lowercase()).collect(); - for raw in node.content.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.' || c == '/' || c == '-')) { - let tok = raw.trim_matches(|c: char| c == '.' || c == '/' || c == '-'); - if tok.len() < 3 { - continue; - } - let is_env = tok.len() >= 3 - && tok.chars().all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()) - && tok.chars().any(|c| c.is_ascii_uppercase()); - let is_path = (tok.contains('/') || tok.contains('.')) - && tok.chars().any(|c| c.is_ascii_alphabetic()); - if is_env || is_path { - set.insert(tok.to_lowercase()); - } - } - set.into_iter().collect() + retroactive_backfill::extract_entities(&node.content, &node.tags) } /// Heuristic: does this memory read like a failure/"aversive event"? Checks both /// content AND tags against the full FAILURE_MARKERS list. Public so the CLI and /// any caller share ONE failure-detection definition (no drifting subsets). +/// +/// Thin `&KnowledgeNode` adapter over [`retroactive_backfill::looks_like_failure`]. pub fn looks_like_failure(node: &KnowledgeNode) -> bool { - let hay = node.content.to_lowercase(); - vestige_core::advanced::retroactive_backfill::FAILURE_MARKERS - .iter() - .any(|m| hay.contains(m)) - || node.tags.iter().any(|t| { - let tl = t.to_lowercase(); - vestige_core::advanced::retroactive_backfill::FAILURE_MARKERS - .iter() - .any(|m| tl.contains(m)) - }) + retroactive_backfill::looks_like_failure(&node.content, &node.tags) } pub async fn execute(storage: &Arc, args: Option) -> Result { @@ -112,9 +93,13 @@ pub async fn execute(storage: &Arc, args: Option) -> Result serde_json::from_value(v).map_err(|e| e.to_string())?, None => Args::default(), }; - let lookback = args.lookback_days.unwrap_or(30); + // Clamp numeric inputs to the documented schema bounds. The MCP dispatch + // layer does NOT enforce the JSON-schema min/max, so a caller can send + // scan_limit=-1 (SQLite treats a negative LIMIT as unbounded => full-table + // fetch = DoS) or values above the 5000 cap. Clamp rather than trust. + let lookback = args.lookback_days.unwrap_or(30).clamp(1, 365); let promote = args.promote.unwrap_or(true); - let scan_limit = args.scan_limit.unwrap_or(500); + let scan_limit = args.scan_limit.unwrap_or(500).clamp(10, 5000); // 1. Resolve the failure event. let failure_node = match &args.failure_id { @@ -146,6 +131,7 @@ pub async fn execute(storage: &Arc, args: Option) -> Result Vec { use vestige_core::{ - classify_write, MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, + MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, classify_write, }; if !is_write_tool(tool) { @@ -139,7 +139,10 @@ pub fn gate_writes( let ctx = WriteContext { source: Some(WriteSource::Agent), - node_type: node.as_ref().map(|n| n.node_type.clone()).unwrap_or_default(), + node_type: node + .as_ref() + .map(|n| n.node_type.clone()) + .unwrap_or_default(), content: node.as_ref().map(|n| n.content.clone()).unwrap_or_default(), tags: node.as_ref().map(|n| n.tags.clone()).unwrap_or_default(), contradicts_trust, @@ -176,9 +179,9 @@ pub fn gate_writes( // secret, and the PR row is read by the dashboard and may be exported). // Store a short, redacted preview + a content hash instead. The preview // is dropped entirely when the write was gated for a sensitive topic. - let sensitive = signals.iter().any(|s| { - s.code == "sensitive_topic" || s.code == "sensitive_node_type" - }); + let sensitive = signals + .iter() + .any(|s| s.code == "sensitive_topic" || s.code == "sensitive_node_type"); let raw_content = node.as_ref().map(|n| n.content.as_str()).unwrap_or(""); let preview = content_preview(raw_content, sensitive); let content_hash = hash_content(raw_content); @@ -238,6 +241,196 @@ pub fn gate_writes( opened } +struct PendingMemoryMutation { + action: String, + id: String, + reason: Option, +} + +/// Pre-gate memory mutations that would otherwise be irreversible or directly +/// inhibitory before the reviewer sees them. +/// +/// Normal risky writes are still handled post-commit by [`gate_writes`]. Purge, +/// delete, and suppress are different: executing the tool first either removes +/// the row or mutates retrieval influence before review. Under Risk-Gated and +/// Paranoid modes this function opens a pending Memory PR and returns a tool +/// response without performing the mutation. Fast mode keeps the historical +/// direct-execution behavior. +pub fn gate_pending_memory_mutation( + storage: &Arc, + event_tx: Option<&broadcast::Sender>, + run_id: &str, + tool: &str, + args: &Option, + mode: vestige_core::ReviewMode, +) -> Result, String> { + use vestige_core::{ + MemoryPr, MemoryPrKind, MemoryPrStatus, RiskClass, WriteContext, classify_write, + }; + + if matches!(mode, vestige_core::ReviewMode::Fast) { + return Ok(None); + } + + let Some(pending) = pending_memory_mutation(tool, args) else { + return Ok(None); + }; + let node = match storage.get_node(&pending.id) { + Ok(Some(node)) => node, + Ok(None) => return Ok(None), + Err(e) => return Err(format!("failed to inspect memory before review gate: {e}")), + }; + + let ctx = WriteContext { + source: Some(WriteSource::Agent), + node_type: node.node_type.clone(), + content: node.content.clone(), + tags: node.tags.clone(), + forgets: true, + ..Default::default() + }; + let (class, signals) = classify_write(&ctx, mode); + if class != RiskClass::Review { + return Ok(None); + } + + let sensitive = signals + .iter() + .any(|s| s.code == "sensitive_topic" || s.code == "sensitive_node_type"); + let preview = content_preview(&node.content, sensitive); + let content_hash = hash_content(&node.content); + let kind = MemoryPrKind::NodeDecayed; + let title = format!("{}: {}", pr_kind_phrase(kind), preview); + let pr = MemoryPr { + id: format!("pr_{}", uuid::Uuid::new_v4().simple()), + kind, + status: MemoryPrStatus::Pending, + title: title.clone(), + diff: serde_json::json!({ + "decision": pending.action, + "pendingAction": pending.action, + "requiresApproval": true, + "reason": pending.reason, + "node": { + "id": pending.id, + "nodeType": node.node_type, + "contentPreview": preview, + "contentHash": content_hash, + "tags": node.tags, + "deleted": false, + }, + }), + signals: signals.clone(), + subject_id: Some(pending.id.clone()), + run_id: Some(run_id.to_string()), + created_at: Utc::now().to_rfc3339(), + decided_at: None, + decision: None, + }; + + if let Err(e) = storage.save_memory_pr(&pr) { + tracing::warn!("pending destructive Memory PR save failed: {e}"); + return Err(format!( + "review gate failed closed: could not open Memory PR for pending mutation: {e}" + )); + } + + if let Some(tx) = event_tx { + let _ = tx.send(VestigeEvent::MemoryPrOpened { + id: pr.id.clone(), + kind: kind.as_str().to_string(), + title: title.clone(), + signal_count: signals.len(), + run_id: Some(run_id.to_string()), + timestamp: Utc::now(), + }); + } + + let opened = serde_json::json!({ + "id": pr.id, + "kind": kind.as_str(), + "title": pr.title, + "signals": signals, + "subjectId": pending.id, + }); + + Ok(Some(serde_json::json!({ + "action": format!("{}_pending_review", pending.action), + "success": false, + "pendingReview": true, + "nodeId": pending.id, + "message": "Mutation was not executed. Vestige opened a Memory PR and is waiting for review.", + "memoryPrsOpened": [opened], + "memoryPrNotice": "Vestige opened a Memory PR before applying this destructive or suppressive memory mutation. Approve with `forget`; keep the memory with `promote`; hold it suppressed with `quarantine`.", + }))) +} + +fn pending_memory_mutation( + tool: &str, + args: &Option, +) -> Option { + let args = args.as_ref()?; + match tool { + "memory" => { + let action = args.get("action")?.as_str()?.to_ascii_lowercase(); + if !matches!(action.as_str(), "purge" | "delete") { + return None; + } + if !args + .get("confirm") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return None; + } + Some(PendingMemoryMutation { + action, + id: args.get("id")?.as_str()?.to_string(), + reason: args + .get("reason") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + } + "delete_knowledge" => { + if !args + .get("confirm") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return None; + } + Some(PendingMemoryMutation { + action: "delete".to_string(), + id: args.get("id")?.as_str()?.to_string(), + reason: args + .get("reason") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + } + "suppress" => { + if args + .get("reverse") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return None; + } + Some(PendingMemoryMutation { + action: "suppress".to_string(), + id: args.get("id")?.as_str()?.to_string(), + reason: args + .get("reason") + .or_else(|| args.get("note")) + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + } + _ => None, + } +} + /// Whether a write decision permanently removes / forgets memory (so the live /// row may already be gone when the gate runs). fn is_destructive_decision(label: &str) -> bool { @@ -719,7 +912,10 @@ fn extract_veto(result: &Value) -> Option<(String, Vec, f64)> { .collect() }) .unwrap_or_default(); - let confidence = veto.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0); + let confidence = veto + .get("confidence") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); Some((claim, evidence_ids, confidence)) } @@ -771,7 +967,9 @@ fn extract_dream_proposals(result: &Value, tool: &str) -> Vec { .map(|a| { a.iter() .filter_map(|m| m.as_str()) - .map(|s| &s[..s.len().min(8)]) + // char-boundary-safe: byte-slicing &s[..8] panics when a + // multi-byte UTF-8 char straddles byte 8. + .map(|s| s.chars().take(8).collect::()) .collect::>() .join("+") }) @@ -875,7 +1073,10 @@ mod tests { #[test] fn extract_writes_single_and_batch() { let single = serde_json::json!({ "decision": "create", "nodeId": "n1" }); - assert_eq!(extract_writes(&single), vec![("n1".into(), "create".into())]); + assert_eq!( + extract_writes(&single), + vec![("n1".into(), "create".into())] + ); let batch = serde_json::json!({ "results": [ { "decision": "update", "id": "n2" } ] }); @@ -886,9 +1087,15 @@ mod tests { fn extract_writes_recognizes_action_shape_b2() { // B2: memory promote/demote return `action` + `nodeId`, not `decision`. let promoted = serde_json::json!({ "action": "promoted", "nodeId": "m1" }); - assert_eq!(extract_writes(&promoted), vec![("m1".into(), "promoted".into())]); + assert_eq!( + extract_writes(&promoted), + vec![("m1".into(), "promoted".into())] + ); let demoted = serde_json::json!({ "action": "demoted", "nodeId": "m2" }); - assert_eq!(extract_writes(&demoted), vec![("m2".into(), "demoted".into())]); + assert_eq!( + extract_writes(&demoted), + vec![("m2".into(), "demoted".into())] + ); // codebase remember_decision returns action + nodeId. let decision = serde_json::json!({ "action": "remember_decision", "nodeId": "c1" }); assert_eq!( @@ -908,7 +1115,14 @@ mod tests { #[test] fn destructive_decision_classification_c2() { - for d in ["purge", "delete", "forget", "purged", "deleted", "forgotten"] { + for d in [ + "purge", + "delete", + "forget", + "purged", + "deleted", + "forgotten", + ] { assert!(is_destructive_decision(d), "{d} is destructive"); } for d in ["create", "update", "promote", "reinforce"] { @@ -987,8 +1201,14 @@ mod tests { vestige_core::ReviewMode::RiskGated, ); - assert_eq!(opened.len(), 1, "destructive write must open a PR even with the node gone"); - let pr = s.list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10).unwrap(); + assert_eq!( + opened.len(), + 1, + "destructive write must open a PR even with the node gone" + ); + let pr = s + .list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10) + .unwrap(); assert_eq!(pr.len(), 1); assert_eq!(pr[0].subject_id.as_deref(), Some(node.id.as_str())); // The diff marks the node as deleted and carries no resurrected content. @@ -1035,6 +1255,110 @@ mod tests { assert!(pr.diff["node"]["contentHash"].as_str().is_some()); } + #[test] + fn pre_gate_blocks_purge_before_deleting_c2() { + let s = store(); + let node = s + .ingest(vestige_core::IngestInput { + content: "A memory awaiting destructive review.".to_string(), + node_type: "fact".to_string(), + ..Default::default() + }) + .unwrap(); + let args = Some(serde_json::json!({ + "action": "purge", + "id": node.id, + "confirm": true, + "reason": "test purge" + })); + + let response = gate_pending_memory_mutation( + &s, + None, + "run_pre_gate", + "memory", + &args, + vestige_core::ReviewMode::RiskGated, + ) + .unwrap() + .expect("purge should be pre-gated"); + + assert_eq!(response["pendingReview"], serde_json::json!(true)); + assert!( + s.get_node(&node.id).unwrap().is_some(), + "pre-gating must not delete before review" + ); + let pr = s + .list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10) + .unwrap(); + assert_eq!(pr.len(), 1); + assert_eq!(pr[0].diff["pendingAction"], serde_json::json!("purge")); + assert_eq!(pr[0].diff["node"]["deleted"], serde_json::json!(false)); + } + + #[test] + fn pre_gate_leaves_fast_mode_direct() { + let s = store(); + let node = s + .ingest(vestige_core::IngestInput { + content: "Fast mode purge target.".to_string(), + node_type: "fact".to_string(), + ..Default::default() + }) + .unwrap(); + let args = Some(serde_json::json!({ + "action": "purge", + "id": node.id, + "confirm": true + })); + + assert!( + gate_pending_memory_mutation( + &s, + None, + "run_fast", + "memory", + &args, + vestige_core::ReviewMode::Fast, + ) + .unwrap() + .is_none(), + "Fast mode should preserve direct execution" + ); + } + + #[test] + fn pre_gate_blocks_direct_suppress_before_mutating() { + let s = store(); + let node = s + .ingest(vestige_core::IngestInput { + content: "A memory awaiting suppress review.".to_string(), + node_type: "fact".to_string(), + ..Default::default() + }) + .unwrap(); + let args = Some(serde_json::json!({ "id": node.id, "reason": "test suppress" })); + + let response = gate_pending_memory_mutation( + &s, + None, + "run_suppress", + "suppress", + &args, + vestige_core::ReviewMode::RiskGated, + ) + .unwrap() + .expect("suppress should be pre-gated"); + + assert_eq!(response["pendingReview"], serde_json::json!(true)); + let kept = s.get_node(&node.id).unwrap().unwrap(); + assert_eq!(kept.suppression_count, 0, "pre-gate must not suppress yet"); + let pr = s + .list_memory_prs(Some(vestige_core::MemoryPrStatus::Pending), 10) + .unwrap(); + assert_eq!(pr[0].diff["pendingAction"], serde_json::json!("suppress")); + } + #[test] fn write_tool_set_includes_codebase_b2() { assert!(is_write_tool("codebase"));