Skip to main content

semantic_memory_mcp/
server.rs

1//! MCP server handler using rmcp's #[tool_router] macro.
2//!
3//! Each #[tool] method becomes an MCP tool that Hermes/Claude Desktop
4//! can discover and call. The rmcp macro auto-generates JSON Schema
5//! from the parameter structs in tools.rs.
6
7use crate::bridge::MemoryBridge;
8use crate::tools::*;
9use rmcp::{
10    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
11    tool, tool_handler, tool_router, ErrorData, Json, ServerHandler,
12};
13use schemars::JsonSchema;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::{Arc, Mutex};
17use tokio::runtime::Handle;
18
19static WITNESS_REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
20const ROUTING_POLICY_PERSIST_BATCH: usize = 10;
21
22#[derive(Default)]
23struct RoutingPolicyBatchState {
24    policy: Option<semantic_memory::rl_routing::RoutingPolicy>,
25    pending_outcomes: usize,
26}
27
28// Re-export the specific parameter types we use in tool signatures.
29use crate::tools::{
30    AddGraphEdgeParams, BenchmarkTrustParams, CommunityParams, FactorGraphParams,
31    InvalidateGraphEdgeParams, ListGraphEdgesParams, RecordOutcomeParams, SearchProofDebtParams,
32    SubgraphPruneParams, TopologyParams,
33};
34
35/// Process-local index from semantic-memory facts to claim-ledger support
36/// judgments, consulted by search-time trust enrichment. Never a source of
37/// truth itself — the claim ledger is; this is a pure in-memory projection
38/// rebuilt from the ledger's hash-chained entries on every startup via
39/// `rebuild_from_ledger`, so there is nothing here to persist independently.
40#[cfg(feature = "claim-integration")]
41#[derive(Default)]
42struct ClaimTrustIndex {
43    enabled: bool,
44    fact_to_claim: HashMap<String, String>,
45    claim_support: HashMap<String, claim_ledger::SupportState>,
46    /// Reverse index from a claim's normalized content to its claim id, used
47    /// to auto-link newly added facts whose content matches an existing
48    /// claim without requiring an explicit sm_create_claim call.
49    content_to_claim: HashMap<String, String>,
50    /// Trigram → claim ids index over normalized claim content, used for
51    /// fuzzy (Jaccard-similarity) auto-linking when no exact match exists.
52    trigram_index: HashMap<String, Vec<String>>,
53    /// Sequence number of the last ledger entry folded into this index, so
54    /// `rebuild_from_ledger_incremental` only replays entries appended since
55    /// the last checkpoint instead of rescanning the whole ledger.
56    last_processed_sequence: u64,
57}
58
59#[cfg(feature = "claim-integration")]
60impl ClaimTrustIndex {
61    fn load_snapshot(&mut self, snapshot: &claim_ledger::LedgerSnapshot) {
62        self.fact_to_claim.clear();
63        self.claim_support.clear();
64        self.content_to_claim.clear();
65        self.trigram_index.clear();
66        for link in &snapshot.content_to_claim_links {
67            self.register_claim(link.claim_id.clone(), link.normalized_claim.clone());
68        }
69        for link in &snapshot.fact_to_claim_links {
70            self.link_fact(link.fact_id.clone(), link.claim_id.clone());
71        }
72        for support in &snapshot.claim_support {
73            self.record_judgment(support.claim_id.clone(), support.support_state);
74        }
75        self.last_processed_sequence = snapshot.last_compacted_sequence;
76        self.enabled = true;
77    }
78
79    fn disable(&mut self) {
80        *self = Self::default();
81    }
82
83    /// Fold a single ledger entry into the index. The ledger is the source
84    /// of truth; this is the sole code path (used both at startup replay and
85    /// at write time) that projects `ClaimAdded`, `SupportJudgment`, and
86    /// `ContradictionCandidate` events into the process-local lookup cache.
87    fn apply_entry(&mut self, entry: &claim_ledger::LedgerEntry) {
88        use claim_ledger::{LedgerEvent, SupportState};
89        match &entry.event {
90            LedgerEvent::ClaimAdded {
91                claim_id,
92                source_id,
93                normalized_claim,
94                ..
95            } => {
96                if let Some(fact_id) = source_id.strip_prefix("semantic-memory:fact:") {
97                    self.link_fact(fact_id.to_string(), claim_id.clone());
98                }
99                self.register_claim(claim_id.clone(), normalized_claim.clone());
100            }
101            LedgerEvent::SupportJudgment {
102                claim_id,
103                support_state,
104                ..
105            } => {
106                self.record_judgment(claim_id.clone(), *support_state);
107            }
108            LedgerEvent::ContradictionCandidate { claim_refs, .. } => {
109                for claim_ref in claim_refs {
110                    self.record_judgment(claim_ref.clone(), SupportState::Contradicted);
111                }
112            }
113            _ => {}
114        }
115        self.last_processed_sequence = entry.sequence;
116    }
117
118    /// Rebuild (or catch up) the index from the claim ledger's entries,
119    /// replaying only entries with `sequence > last_processed_sequence`.
120    /// On a fresh index this processes the whole ledger once; called again
121    /// on an already-caught-up index it is O(new_entries).
122    fn rebuild_from_ledger_incremental(&mut self, entries: &[claim_ledger::LedgerEntry]) {
123        for entry in entries {
124            if entry.sequence > self.last_processed_sequence {
125                self.apply_entry(entry);
126            }
127        }
128    }
129
130    fn link_fact(&mut self, bare_fact_id: String, claim_id: String) {
131        self.fact_to_claim.insert(bare_fact_id, claim_id);
132    }
133
134    /// Record a claim's normalized content in the reverse and trigram
135    /// indexes so future facts with matching (or fuzzy-matching) content can
136    /// be auto-linked without an explicit sm_create_claim call.
137    fn register_claim(&mut self, claim_id: String, normalized_content: String) {
138        if normalized_content.is_empty() {
139            return;
140        }
141        for trigram in Self::trigrams(&normalized_content) {
142            self.trigram_index
143                .entry(trigram)
144                .or_default()
145                .push(claim_id.clone());
146        }
147        self.content_to_claim.insert(normalized_content, claim_id);
148    }
149
150    /// Character 3-grams of `text`, lowercased. Text shorter than 3 chars
151    /// yields the whole (lowercased) text as its only "trigram".
152    fn trigrams(text: &str) -> HashSet<String> {
153        let lower = text.to_lowercase();
154        let chars: Vec<char> = lower.chars().collect();
155        if chars.len() < 3 {
156            let mut set = HashSet::new();
157            if !lower.is_empty() {
158                set.insert(lower);
159            }
160            return set;
161        }
162        chars
163            .windows(3)
164            .map(|w| w.iter().collect::<String>())
165            .collect()
166    }
167
168    fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
169        if a.is_empty() && b.is_empty() {
170            return 0.0;
171        }
172        let intersection = a.intersection(b).count();
173        let union = a.union(b).count();
174        if union == 0 {
175            0.0
176        } else {
177            intersection as f64 / union as f64
178        }
179    }
180
181    /// Best-effort: if `bare_fact_id` isn't already linked to a claim, look
182    /// for the best fuzzy (trigram-Jaccard) content match among known
183    /// claims, falling back to an exact normalized-text match. No-op if the
184    /// fact is already linked or no candidate reaches the similarity bar.
185    fn auto_link_content(&mut self, bare_fact_id: &str, content: &str) -> Option<String> {
186        if !self.enabled {
187            return None;
188        }
189        if self.fact_to_claim.contains_key(bare_fact_id) {
190            return None;
191        }
192        let normalized = claim_ledger::ids::normalize_text(content);
193        if normalized.is_empty() {
194            return None;
195        }
196
197        const SIMILARITY_THRESHOLD: f64 = 0.7;
198        let query_trigrams = Self::trigrams(&normalized);
199        let mut candidate_ids: HashSet<&String> = HashSet::new();
200        for trigram in &query_trigrams {
201            if let Some(ids) = self.trigram_index.get(trigram) {
202                candidate_ids.extend(ids.iter());
203            }
204        }
205
206        let mut best: Option<(String, f64)> = None;
207        for claim_id in &candidate_ids {
208            if let Some((existing_content, existing_claim_id)) = self
209                .content_to_claim
210                .iter()
211                .find(|(_, cid)| *cid == *claim_id)
212            {
213                let similarity = Self::jaccard(&query_trigrams, &Self::trigrams(existing_content));
214                if best.as_ref().map(|(_, s)| similarity > *s).unwrap_or(true) {
215                    best = Some((existing_claim_id.clone(), similarity));
216                }
217            }
218        }
219
220        let claim_id = match best {
221            Some((claim_id, similarity)) if similarity >= SIMILARITY_THRESHOLD => claim_id,
222            _ => self.content_to_claim.get(&normalized)?.clone(),
223        };
224        self.link_fact(bare_fact_id.to_string(), claim_id.clone());
225        Some(claim_id)
226    }
227
228    fn record_judgment(&mut self, claim_id: String, state: claim_ledger::SupportState) {
229        self.claim_support.insert(claim_id, state);
230    }
231
232    fn trust_for_fact(&self, bare_fact_id: &str) -> String {
233        if !self.enabled {
234            return "trust_enrichment_disabled".to_string();
235        }
236        let Some(claim_id) = self.fact_to_claim.get(bare_fact_id) else {
237            return "persisted_unjudged".to_string();
238        };
239        let Some(state) = self.claim_support.get(claim_id) else {
240            return "persisted_unjudged".to_string();
241        };
242        support_state_label(*state).to_string()
243    }
244
245    /// The claim linked to this fact (if any) and its recorded support
246    /// judgment (if any judgment has been made yet). Distinguishes "no claim
247    /// at all" (no proof debt to speak of) from "claim exists but unjudged"
248    /// (real proof debt, `SupportState::Unknown`).
249    fn claim_and_state_for_fact(
250        &self,
251        bare_fact_id: &str,
252    ) -> Option<(String, Option<claim_ledger::SupportState>)> {
253        if !self.enabled {
254            return None;
255        }
256        let claim_id = self.fact_to_claim.get(bare_fact_id)?;
257        Some((claim_id.clone(), self.claim_support.get(claim_id).copied()))
258    }
259}
260
261/// Map a claim's support judgment to the proof-debt obligations it incurs.
262/// Supported/PartiallySupported claims carry no debt. Unjudged, heuristic, or
263/// unsupported claims lack a source basis. Contradicted claims carry that
264/// same missing-source-basis debt plus a missing-repro debt, so the real
265/// claim-ledger weights (not a hardcoded number) rank them strictly worse.
266#[cfg(feature = "claim-integration")]
267fn proof_debts_for_support_state(
268    state: claim_ledger::SupportState,
269) -> Vec<claim_ledger::ProofDebt> {
270    use claim_ledger::{ProofDebt, SupportState};
271    match state {
272        SupportState::Supported | SupportState::PartiallySupported => Vec::new(),
273        SupportState::Unknown | SupportState::HeuristicOnly | SupportState::Unsupported => {
274            vec![ProofDebt::MissingSourceBasis]
275        }
276        SupportState::Contradicted => vec![ProofDebt::MissingSourceBasis, ProofDebt::MissingRepro],
277    }
278}
279
280#[cfg(feature = "claim-integration")]
281#[derive(serde::Serialize, serde::Deserialize)]
282struct ClaimLedgerManifest {
283    format_version: String,
284    generation: String,
285    snapshot_path: String,
286    tail_path: String,
287    receipt_path: String,
288}
289
290#[cfg(feature = "claim-integration")]
291struct ClaimLedgerCompactionConfig {
292    dry_run: bool,
293    max_entries: usize,
294    max_bytes: u64,
295    retain_tail_entries: usize,
296    max_backups: usize,
297}
298
299/// Hash-chained claim-ledger store. Before the first compaction `path` is the
300/// legacy JSONL file. Afterwards an atomically replaced manifest selects one
301/// verified snapshot/retained-tail generation; the snapshot is a checkpoint,
302/// never an independent source of truth.
303#[cfg(feature = "claim-integration")]
304struct ClaimLedgerStore {
305    entries: Vec<claim_ledger::LedgerEntry>,
306    path: std::path::PathBuf,
307    legacy_path: std::path::PathBuf,
308    snapshot: Option<claim_ledger::LedgerSnapshot>,
309    receipt: Option<claim_ledger::CompactionReceipt>,
310    trust_enabled: bool,
311}
312
313#[cfg(feature = "claim-integration")]
314impl ClaimLedgerStore {
315    fn open(path: std::path::PathBuf) -> Self {
316        match Self::open_verified(&path) {
317            Ok(store) => store,
318            Err(error) => {
319                tracing::error!(error = %error, "CRITICAL: claim ledger verification failed; trust enrichment disabled");
320                Self {
321                    entries: Vec::new(),
322                    path: path.clone(),
323                    legacy_path: path,
324                    snapshot: None,
325                    receipt: None,
326                    trust_enabled: false,
327                }
328            }
329        }
330    }
331
332    fn open_verified(legacy_path: &std::path::Path) -> Result<Self, String> {
333        let memory_dir = legacy_path
334            .parent()
335            .ok_or("claim ledger has no parent directory")?;
336        let manifest_path = memory_dir.join("claim_ledger.active_compaction.json");
337        if manifest_path.exists() {
338            let manifest: ClaimLedgerManifest = serde_json::from_slice(
339                &std::fs::read(&manifest_path)
340                    .map_err(|e| format!("failed to read compaction manifest: {e}"))?,
341            )
342            .map_err(|e| format!("invalid compaction manifest: {e}"))?;
343            if manifest.format_version != "claim-ledger.active-compaction.v1" {
344                return Err(format!(
345                    "unsupported compaction manifest format {}",
346                    manifest.format_version
347                ));
348            }
349            let snapshot_path = Self::resolve_relative(memory_dir, &manifest.snapshot_path)?;
350            let tail_path = Self::resolve_relative(memory_dir, &manifest.tail_path)?;
351            let receipt_path = Self::resolve_relative(memory_dir, &manifest.receipt_path)?;
352            let snapshot: claim_ledger::LedgerSnapshot = serde_json::from_slice(
353                &std::fs::read(&snapshot_path)
354                    .map_err(|e| format!("failed to read ledger snapshot: {e}"))?,
355            )
356            .map_err(|e| format!("invalid ledger snapshot: {e}"))?;
357            let receipt: claim_ledger::CompactionReceipt = serde_json::from_slice(
358                &std::fs::read(&receipt_path)
359                    .map_err(|e| format!("failed to read compaction receipt: {e}"))?,
360            )
361            .map_err(|e| format!("invalid compaction receipt: {e}"))?;
362            let tail_contents = std::fs::read_to_string(&tail_path)
363                .map_err(|e| format!("failed to read retained ledger tail: {e}"))?;
364            let entries = claim_ledger::parse_ledger_entries(&tail_contents)
365                .map_err(|e| format!("invalid retained ledger tail: {e}"))?;
366            let verification = claim_ledger::verify_compaction(&snapshot, &entries, &receipt)
367                .map_err(|e| e.to_string())?;
368            tracing::info!(
369                generation = %manifest.generation,
370                entries = verification.last_sequence,
371                "claim ledger snapshot and retained tail verified"
372            );
373            return Ok(Self {
374                entries,
375                path: tail_path,
376                legacy_path: legacy_path.to_path_buf(),
377                snapshot: Some(snapshot),
378                receipt: Some(receipt),
379                trust_enabled: true,
380            });
381        }
382
383        let contents = match std::fs::read_to_string(legacy_path) {
384            Ok(contents) => contents,
385            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
386            Err(error) => return Err(format!("failed to read claim ledger: {error}")),
387        };
388        let entries = claim_ledger::parse_ledger_entries(&contents)
389            .map_err(|e| format!("invalid claim ledger: {e}"))?;
390        let expected_head = match entries.last() {
391            None => claim_ledger::ExpectedLedgerHead::Empty,
392            Some(last) => claim_ledger::ExpectedLedgerHead::Entry {
393                sequence: last.sequence,
394                entry_digest: last.entry_digest.clone(),
395            },
396        };
397        let verification =
398            claim_ledger::verify_ledger(&entries, &expected_head).map_err(|e| e.to_string())?;
399        tracing::info!(
400            entries = verification.last_sequence,
401            "claim ledger verified"
402        );
403        Ok(Self {
404            entries,
405            path: legacy_path.to_path_buf(),
406            legacy_path: legacy_path.to_path_buf(),
407            snapshot: None,
408            receipt: None,
409            trust_enabled: true,
410        })
411    }
412
413    fn resolve_relative(
414        base: &std::path::Path,
415        relative: &str,
416    ) -> Result<std::path::PathBuf, String> {
417        let path = std::path::Path::new(relative);
418        if path.is_absolute()
419            || path.components().any(|component| {
420                !matches!(
421                    component,
422                    std::path::Component::Normal(_) | std::path::Component::CurDir
423                )
424            })
425        {
426            return Err(format!("unsafe path in compaction manifest: {relative}"));
427        }
428        Ok(base.join(path))
429    }
430
431    fn next_sequence(&self) -> u64 {
432        self.entries
433            .last()
434            .map(|entry| entry.sequence)
435            .or_else(|| {
436                self.snapshot
437                    .as_ref()
438                    .map(|snapshot| snapshot.last_compacted_sequence)
439            })
440            .unwrap_or(0)
441            .saturating_add(1)
442    }
443
444    fn last_digest(&self) -> Option<String> {
445        self.entries
446            .last()
447            .map(|entry| entry.entry_digest.clone())
448            .or_else(|| {
449                self.snapshot
450                    .as_ref()
451                    .and_then(|snapshot| snapshot.last_compacted_entry_digest.clone())
452            })
453    }
454
455    /// Append a new entry to the in-memory chain and the backing file.
456    /// Returns the entry's digest on success.
457    fn append(&mut self, entry: claim_ledger::LedgerEntry) -> Result<String, String> {
458        if !self.trust_enabled {
459            return Err("claim ledger is disabled after verification failure".into());
460        }
461        if entry.sequence != self.next_sequence()
462            || entry.previous_entry_digest != self.last_digest()
463        {
464            return Err("claim ledger append does not continue the verified head".into());
465        }
466        let line = claim_ledger::serialize_entry(&entry).map_err(|e| e.to_string())?;
467        use std::io::Write;
468        let mut file = std::fs::OpenOptions::new()
469            .create(true)
470            .append(true)
471            .open(&self.path)
472            .map_err(|e| format!("failed to open claim ledger file: {e}"))?;
473        writeln!(file, "{line}").map_err(|e| format!("failed to write claim ledger entry: {e}"))?;
474        file.sync_data()
475            .map_err(|e| format!("failed to fsync claim ledger entry: {e}"))?;
476        let digest = entry.entry_digest.clone();
477        self.entries.push(entry);
478        Ok(digest)
479    }
480
481    fn compact(
482        &mut self,
483        config: ClaimLedgerCompactionConfig,
484    ) -> Result<serde_json::Value, String> {
485        if !self.trust_enabled {
486            return Err("claim ledger trust is disabled; refusing compaction".into());
487        }
488        let current_bytes = std::fs::metadata(&self.path)
489            .map(|metadata| metadata.len())
490            .unwrap_or(0);
491        let threshold_exceeded =
492            self.entries.len() > config.max_entries || current_bytes > config.max_bytes;
493        if !threshold_exceeded {
494            return Ok(serde_json::json!({
495                "ok": true,
496                "dry_run": config.dry_run,
497                "compacted": false,
498                "reason": "threshold_not_exceeded",
499                "entries": self.entries.len(),
500                "bytes": current_bytes,
501                "max_entries": config.max_entries,
502                "max_bytes": config.max_bytes,
503            }));
504        }
505
506        let compacted = claim_ledger::compact_ledger_from_snapshot(
507            self.snapshot.as_ref(),
508            &self.entries,
509            &claim_ledger::CompactionPolicy {
510                retain_tail_entries: config.retain_tail_entries,
511                unprojectable_events: claim_ledger::UnprojectableEventPolicy::Retain,
512            },
513        )
514        .map_err(|e| e.to_string())?;
515        let compacted_entries = self
516            .entries
517            .len()
518            .saturating_sub(compacted.retained_tail.len());
519        let result = serde_json::json!({
520            "ok": true,
521            "dry_run": config.dry_run,
522            "compacted": !config.dry_run && compacted_entries > 0,
523            "entries_before": self.entries.len(),
524            "bytes_before": current_bytes,
525            "entries_checkpointed": compacted_entries,
526            "retained_tail_entries": compacted.retained_tail.len(),
527            "snapshot_sequence": compacted.snapshot.last_compacted_sequence,
528            "snapshot_digest": compacted.snapshot.snapshot_digest,
529            "receipt": compacted.receipt,
530        });
531        if config.dry_run || compacted_entries == 0 {
532            return Ok(result);
533        }
534
535        let memory_dir = self
536            .legacy_path
537            .parent()
538            .ok_or("claim ledger has no parent directory")?;
539        let generation = compacted.receipt.receipt_digest[..16].to_string();
540        let generations_dir = memory_dir.join("claim_ledger_generations");
541        std::fs::create_dir_all(&generations_dir)
542            .map_err(|e| format!("failed to create ledger generation directory: {e}"))?;
543        let final_dir = generations_dir.join(&generation);
544        let temp_dir = generations_dir.join(format!(".tmp-{generation}"));
545        if temp_dir.exists() {
546            std::fs::remove_dir_all(&temp_dir)
547                .map_err(|e| format!("failed to clear stale compaction temp directory: {e}"))?;
548        }
549        std::fs::create_dir(&temp_dir)
550            .map_err(|e| format!("failed to create compaction temp directory: {e}"))?;
551
552        let snapshot_bytes = serde_json::to_vec_pretty(&compacted.snapshot)
553            .map_err(|e| format!("failed to serialize ledger snapshot: {e}"))?;
554        let receipt_bytes = serde_json::to_vec_pretty(&compacted.receipt)
555            .map_err(|e| format!("failed to serialize compaction receipt: {e}"))?;
556        let mut tail_bytes = Vec::new();
557        for entry in &compacted.retained_tail {
558            tail_bytes.extend_from_slice(
559                claim_ledger::serialize_entry(entry)
560                    .map_err(|e| e.to_string())?
561                    .as_bytes(),
562            );
563            tail_bytes.push(b'\n');
564        }
565        Self::write_synced(&temp_dir.join("snapshot.json"), &snapshot_bytes)?;
566        Self::write_synced(&temp_dir.join("tail.jsonl"), &tail_bytes)?;
567        Self::write_synced(&temp_dir.join("receipt.json"), &receipt_bytes)?;
568        Self::sync_directory(&temp_dir)?;
569        std::fs::rename(&temp_dir, &final_dir)
570            .map_err(|e| format!("failed to publish ledger generation: {e}"))?;
571        Self::sync_directory(&generations_dir)?;
572
573        let manifest = ClaimLedgerManifest {
574            format_version: "claim-ledger.active-compaction.v1".into(),
575            generation: generation.clone(),
576            snapshot_path: format!("claim_ledger_generations/{generation}/snapshot.json"),
577            tail_path: format!("claim_ledger_generations/{generation}/tail.jsonl"),
578            receipt_path: format!("claim_ledger_generations/{generation}/receipt.json"),
579        };
580        let manifest_bytes = serde_json::to_vec_pretty(&manifest)
581            .map_err(|e| format!("failed to serialize compaction manifest: {e}"))?;
582        let manifest_path = memory_dir.join("claim_ledger.active_compaction.json");
583        let manifest_temp = memory_dir.join(format!(".claim_ledger.manifest.{generation}.tmp"));
584        Self::write_synced(&manifest_temp, &manifest_bytes)?;
585        std::fs::rename(&manifest_temp, &manifest_path)
586            .map_err(|e| format!("failed to atomically activate compaction: {e}"))?;
587        Self::sync_directory(memory_dir)?;
588
589        let previous_path = self.path.clone();
590        self.path = final_dir.join("tail.jsonl");
591        self.entries = compacted.retained_tail;
592        self.snapshot = Some(compacted.snapshot);
593        self.receipt = Some(compacted.receipt);
594
595        if previous_path == self.legacy_path && previous_path.exists() {
596            let backups_dir = memory_dir.join("claim_ledger_backups");
597            if std::fs::create_dir_all(&backups_dir).is_ok() {
598                let backup_path = backups_dir.join(format!("{generation}-pre-compaction.jsonl"));
599                if let Err(error) = std::fs::rename(&previous_path, &backup_path) {
600                    tracing::error!(error = %error, "failed to rotate legacy claim ledger backup");
601                }
602            }
603        }
604        Self::rotate_backups(memory_dir, &generation, config.max_backups);
605        Ok(result)
606    }
607
608    fn write_synced(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
609        use std::io::Write;
610        let mut file = std::fs::OpenOptions::new()
611            .write(true)
612            .create_new(true)
613            .open(path)
614            .map_err(|e| format!("failed to create {}: {e}", path.display()))?;
615        file.write_all(bytes)
616            .map_err(|e| format!("failed to write {}: {e}", path.display()))?;
617        file.sync_all()
618            .map_err(|e| format!("failed to fsync {}: {e}", path.display()))
619    }
620
621    fn sync_directory(path: &std::path::Path) -> Result<(), String> {
622        std::fs::File::open(path)
623            .and_then(|directory| directory.sync_all())
624            .map_err(|e| format!("failed to fsync directory {}: {e}", path.display()))
625    }
626
627    fn rotate_backups(memory_dir: &std::path::Path, active: &str, max_backups: usize) {
628        let generations_dir = memory_dir.join("claim_ledger_generations");
629        let mut generations: Vec<_> = std::fs::read_dir(&generations_dir)
630            .into_iter()
631            .flatten()
632            .flatten()
633            .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
634            .filter(|entry| entry.file_name() != active)
635            .collect();
636        generations.sort_by_key(|entry| {
637            entry
638                .metadata()
639                .and_then(|metadata| metadata.modified())
640                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
641        });
642        let remove_count = generations.len().saturating_sub(max_backups);
643        for entry in generations.into_iter().take(remove_count) {
644            if let Err(error) = std::fs::remove_dir_all(entry.path()) {
645                tracing::error!(error = %error, "failed to remove old claim-ledger generation");
646            }
647        }
648
649        let backups_dir = memory_dir.join("claim_ledger_backups");
650        let mut backups: Vec<_> = std::fs::read_dir(&backups_dir)
651            .into_iter()
652            .flatten()
653            .flatten()
654            .filter(|entry| {
655                entry
656                    .file_type()
657                    .map(|kind| kind.is_file())
658                    .unwrap_or(false)
659            })
660            .collect();
661        backups.sort_by_key(|entry| {
662            entry
663                .metadata()
664                .and_then(|metadata| metadata.modified())
665                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
666        });
667        let remove_count = backups.len().saturating_sub(max_backups);
668        for entry in backups.into_iter().take(remove_count) {
669            if let Err(error) = std::fs::remove_file(entry.path()) {
670                tracing::error!(error = %error, "failed to remove old claim-ledger backup");
671            }
672        }
673    }
674}
675
676#[cfg(feature = "claim-integration")]
677fn support_state_label(state: claim_ledger::SupportState) -> &'static str {
678    use claim_ledger::SupportState;
679    match state {
680        SupportState::Supported => "supported",
681        SupportState::PartiallySupported => "partially_supported",
682        SupportState::Unsupported => "unsupported",
683        SupportState::Contradicted => "contradicted",
684        SupportState::HeuristicOnly => "heuristic_only",
685        SupportState::Unknown => "persisted_unjudged",
686    }
687}
688
689/// Schema-backed structured output shared by heterogeneous MCP tools.
690///
691/// Existing object responses retain their top-level wire shape through
692/// `flatten`. Scalar and array results are wrapped under `value`, because MCP
693/// requires structured tool outputs to have an object root schema.
694#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
695pub struct StructuredOutput {
696    #[serde(flatten)]
697    pub fields: std::collections::BTreeMap<String, serde_json::Value>,
698}
699
700impl StructuredOutput {
701    fn from_value(value: serde_json::Value) -> Self {
702        let fields = match value {
703            serde_json::Value::Object(map) => map.into_iter().collect(),
704            value => std::collections::BTreeMap::from([("value".to_string(), value)]),
705        };
706        Self { fields }
707    }
708}
709
710fn structured_output(value: serde_json::Value) -> Json<StructuredOutput> {
711    Json(StructuredOutput::from_value(value))
712}
713
714/// Typed output for sm_stats — provides outputSchema with type: "object" for MCP.
715#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
716pub struct StatsOutput {
717    pub ok: bool,
718    pub components: serde_json::Value,
719    pub facts: Option<u64>,
720    pub chunks: Option<u64>,
721    pub documents: Option<u64>,
722    pub sessions: Option<u64>,
723    pub messages: Option<u64>,
724    pub graph_edges: Option<usize>,
725    pub db_size_bytes: Option<u64>,
726    pub db_size_mb: Option<f64>,
727    pub embedding_model: Option<String>,
728    pub embedding_dimensions: Option<usize>,
729}
730
731pub struct SemanticMemoryServer {
732    bridge: Arc<MemoryBridge>,
733    tool_router: ToolRouter<Self>,
734    routing_policy_batch: Mutex<RoutingPolicyBatchState>,
735    #[cfg(feature = "claim-integration")]
736    claim_trust: Mutex<ClaimTrustIndex>,
737    #[cfg(feature = "claim-integration")]
738    claim_ledger_store: Mutex<ClaimLedgerStore>,
739}
740
741impl SemanticMemoryServer {
742    pub fn new(bridge: MemoryBridge, tool_profile: &str) -> Self {
743        let mut router = Self::tool_router();
744
745        match tool_profile {
746            "full" => { /* all tools visible */ }
747            "stable" => {
748                let allowed: HashSet<&str> = [
749                    "sm_search",
750                    "sm_search_witnessed",
751                    "sm_stats",
752                    "sm_list_namespaces",
753                    "sm_get_fact",
754                    "sm_get_fact_neighbors",
755                    "sm_graph_path",
756                    "sm_search_conversations",
757                    "sm_add_fact",
758                    "sm_supersede_fact",
759                    "sm_add_graph_edge",
760                    "sm_decide_assertion_authority",
761                    "sm_decide_action_authority",
762                ]
763                .into_iter()
764                .collect();
765                let names: Vec<_> = router
766                    .list_all()
767                    .into_iter()
768                    .map(|tool| tool.name.into_owned())
769                    .collect();
770                for name in names {
771                    if !allowed.contains(name.as_str()) {
772                        router.disable_route(name);
773                    }
774                }
775            }
776            "lean" => {
777                let allowed: HashSet<&str> = [
778                    "sm_search_witnessed",
779                    "sm_replay_search",
780                    "sm_decide_assertion_authority",
781                    "sm_decide_action_authority",
782                ]
783                .into_iter()
784                .collect();
785                let names: Vec<_> = router
786                    .list_all()
787                    .into_iter()
788                    .map(|tool| tool.name.into_owned())
789                    .collect();
790                for name in names {
791                    if !allowed.contains(name.as_str()) {
792                        router.disable_route(name);
793                    }
794                }
795            }
796            "standard" => {
797                let allowed: HashSet<&str> = [
798                    "sm_search",
799                    "sm_search_witnessed",
800                    "sm_replay_search",
801                    "sm_stats",
802                    "sm_list_namespaces",
803                    "sm_get_fact",
804                    "sm_get_fact_neighbors",
805                    "sm_graph_path",
806                    "sm_search_conversations",
807                    "sm_add_fact",
808                    "sm_supersede_fact",
809                    "sm_add_graph_edge",
810                    "sm_decide_assertion_authority",
811                    "sm_decide_action_authority",
812                    "sm_update_fact",
813                    "sm_set_provenance",
814                    "sm_list_facts",
815                ]
816                .into_iter()
817                .collect();
818                let names: Vec<_> = router
819                    .list_all()
820                    .into_iter()
821                    .map(|tool| tool.name.into_owned())
822                    .collect();
823                for name in names {
824                    if !allowed.contains(name.as_str()) {
825                        router.disable_route(name);
826                    }
827                }
828            }
829            "agent" => {
830                let allowed: HashSet<&str> = [
831                    "sm_add_fact",
832                    "sm_add_graph_edge",
833                    "sm_decide_action_authority",
834                    "sm_decide_assertion_authority",
835                    "sm_get_fact",
836                    "sm_get_fact_neighbors",
837                    "sm_get_search_receipt",
838                    "sm_graph_path",
839                    "sm_list_namespaces",
840                    "sm_replay_search",
841                    "sm_search_conversations",
842                    "sm_search_witnessed",
843                    "sm_set_provenance",
844                    "sm_stats",
845                    "sm_supersede_fact",
846                    "sm_update_fact",
847                ]
848                .into_iter()
849                .collect();
850                let names: Vec<_> = router
851                    .list_all()
852                    .into_iter()
853                    .map(|tool| tool.name.into_owned())
854                    .collect();
855                for name in names {
856                    if !allowed.contains(name.as_str()) {
857                        router.disable_route(name);
858                    }
859                }
860            }
861            _ => panic!(
862                "Unknown tool profile '{}'. Must be one of: stable, lean, standard, agent, full",
863                tool_profile
864            ),
865        }
866
867        eprintln!(
868            "Tool profile: {} ({} tools visible)",
869            tool_profile,
870            router.list_all().len()
871        );
872
873        #[cfg(feature = "claim-integration")]
874        let claim_ledger_store =
875            ClaimLedgerStore::open(bridge.memory_dir.join("claim_ledger.jsonl"));
876        #[cfg(feature = "claim-integration")]
877        let mut claim_trust = ClaimTrustIndex::default();
878        #[cfg(feature = "claim-integration")]
879        if claim_ledger_store.trust_enabled {
880            claim_trust.enabled = true;
881            if let Some(snapshot) = &claim_ledger_store.snapshot {
882                claim_trust.load_snapshot(snapshot);
883            }
884            claim_trust.rebuild_from_ledger_incremental(&claim_ledger_store.entries);
885        } else {
886            claim_trust.disable();
887        }
888
889        Self {
890            bridge: Arc::new(bridge),
891            tool_router: router,
892            routing_policy_batch: Mutex::new(RoutingPolicyBatchState::default()),
893            #[cfg(feature = "claim-integration")]
894            claim_trust: Mutex::new(claim_trust),
895            #[cfg(feature = "claim-integration")]
896            claim_ledger_store: Mutex::new(claim_ledger_store),
897        }
898    }
899
900    pub fn exposes_tool(&self, name: &str) -> bool {
901        self.tool_router
902            .list_all()
903            .iter()
904            .any(|tool| tool.name == name)
905    }
906
907    pub fn exposed_tool_names(&self) -> Vec<String> {
908        let mut names: Vec<_> = self
909            .tool_router
910            .list_all()
911            .into_iter()
912            .map(|tool| tool.name.into_owned())
913            .collect();
914        names.sort();
915        names
916    }
917
918    pub fn tool_annotations(&self, name: &str) -> Option<rmcp::model::ToolAnnotations> {
919        self.tool_router
920            .list_all()
921            .into_iter()
922            .find(|tool| tool.name == name)
923            .and_then(|tool| tool.annotations)
924    }
925
926    fn decide_governed_authority(
927        &self,
928        params: GovernedDecisionParams,
929        purpose: semantic_memory::GovernedAccessPurposeV1,
930    ) -> Result<Json<StructuredOutput>, ErrorData> {
931        use semantic_memory::{
932            AudienceV1, CallerPrincipalV1, DelegationElevationLeaseV1, GovernedAccessPurposeV1,
933            GovernedAccessRequestV1, NamespaceScopeV1, SubjectPrincipalV1,
934        };
935
936        let GovernedDecisionParams {
937            fact_id,
938            caller,
939            subject,
940            audiences,
941            scope,
942            delegation_or_elevation,
943        } = params;
944        let caller = CallerPrincipalV1::new(caller)
945            .map_err(|error| ErrorData::invalid_params(error, None))?;
946        let subject = SubjectPrincipalV1::new(subject)
947            .map_err(|error| ErrorData::invalid_params(error, None))?;
948        let scope = NamespaceScopeV1 {
949            namespace: scope.namespace,
950            domain: scope.domain,
951            workspace_id: scope.workspace_id,
952            repo_id: scope.repo_id,
953        };
954        let mut request =
955            GovernedAccessRequestV1::for_principals(caller, subject, audiences, purpose, scope);
956        if let Some(lease) = delegation_or_elevation {
957            let lease_scope = NamespaceScopeV1 {
958                namespace: lease.scope.namespace,
959                domain: lease.scope.domain,
960                workspace_id: lease.scope.workspace_id,
961                repo_id: lease.scope.repo_id,
962            };
963            let purposes = lease
964                .purposes
965                .into_iter()
966                .map(|purpose| match purpose {
967                    GovernedAccessPurposeParam::Recall => GovernedAccessPurposeV1::Recall,
968                    GovernedAccessPurposeParam::Assertion => GovernedAccessPurposeV1::Assertion,
969                    GovernedAccessPurposeParam::Action => GovernedAccessPurposeV1::Action,
970                    GovernedAccessPurposeParam::Export => GovernedAccessPurposeV1::Export,
971                    GovernedAccessPurposeParam::Replay => GovernedAccessPurposeV1::Replay,
972                    GovernedAccessPurposeParam::Admin => GovernedAccessPurposeV1::Admin,
973                })
974                .collect();
975            request = request.with_delegation_or_elevation(DelegationElevationLeaseV1 {
976                lease_id: lease.lease_id,
977                delegator: SubjectPrincipalV1::new(lease.delegator)
978                    .map_err(|error| ErrorData::invalid_params(error, None))?,
979                delegatee: CallerPrincipalV1::new(lease.delegatee)
980                    .map_err(|error| ErrorData::invalid_params(error, None))?,
981                purposes,
982                scope: lease_scope,
983                audience: AudienceV1::new(lease.audiences),
984                expires_at: lease.expires_at,
985                revoked: lease.revoked,
986                elevation: lease.elevation,
987            });
988        }
989
990        let fact_id = fact_id
991            .strip_prefix("fact:")
992            .unwrap_or(&fact_id)
993            .to_string();
994        let access = tokio::task::block_in_place(|| {
995            Handle::current().block_on(
996                self.bridge
997                    .store
998                    .authority()
999                    .get_fact_governed(&fact_id, request),
1000            )
1001        })
1002        .map_err(|error| {
1003            ErrorData::internal_error(format!("governed authority decision error: {error}"), None)
1004        })?;
1005
1006        // Deliberately serialize only the canonical typed receipt. `access.fact`
1007        // and `access.origin` are never part of this MCP decision surface.
1008        let value = serde_json::to_value(&access.decision).map_err(|error| {
1009            ErrorData::internal_error(
1010                format!("decision receipt serialization error: {error}"),
1011                None,
1012            )
1013        })?;
1014        Ok(structured_output(value))
1015    }
1016}
1017
1018/// Helper: load all stored graph edges from the store as GraphEdgeRef tuples
1019/// for discord scoring.
1020fn load_stored_edge_refs(
1021    store: &semantic_memory::MemoryStore,
1022) -> Result<Vec<semantic_memory::discord::GraphEdgeRef>, ErrorData> {
1023    let edges =
1024        tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1025            .map_err(|e| {
1026                ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1027            })?;
1028    let refs = edges
1029        .iter()
1030        .map(|edge| {
1031            let parsed_type = edge
1032                .edge_type_parsed
1033                .clone()
1034                .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1035                .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1036                    relation: "unknown".to_string(),
1037                });
1038            let type_str = match parsed_type {
1039                semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
1040                semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
1041                semantic_memory::GraphEdgeType::Causal { .. } => "causal",
1042                semantic_memory::GraphEdgeType::Entity { .. } => "entity",
1043            };
1044            semantic_memory::discord::GraphEdgeRef {
1045                source: edge.source.clone(),
1046                target: edge.target.clone(),
1047                edge_type: type_str.to_string(),
1048                weight: edge.weight,
1049            }
1050        })
1051        .collect();
1052    Ok(refs)
1053}
1054
1055/// Helper: load all stored graph edges from the store as raw factor graph
1056/// edge tuples (source, target, GraphEdgeType, weight, metadata_json).
1057fn load_stored_factor_edges(
1058    store: &semantic_memory::MemoryStore,
1059) -> Result<Vec<FactorEdgeTuple>, ErrorData> {
1060    let edges =
1061        tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1062            .map_err(|e| {
1063                ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1064            })?;
1065    let raw = edges
1066        .iter()
1067        .map(|edge| {
1068            let parsed_type = edge
1069                .edge_type_parsed
1070                .clone()
1071                .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1072                .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1073                    relation: "unknown".to_string(),
1074                });
1075            (
1076                edge.source.clone(),
1077                edge.target.clone(),
1078                parsed_type,
1079                edge.weight,
1080                edge.metadata.clone(),
1081            )
1082        })
1083        .collect();
1084    Ok(raw)
1085}
1086
1087/// Helper: load all stored graph edges as (source, target) pairs.
1088fn load_stored_edge_pairs(
1089    store: &semantic_memory::MemoryStore,
1090) -> Result<Vec<(String, String)>, ErrorData> {
1091    let edges =
1092        tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1093            .map_err(|e| {
1094                ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1095            })?;
1096    let pairs = edges
1097        .iter()
1098        .map(|edge| (edge.source.clone(), edge.target.clone()))
1099        .collect();
1100    Ok(pairs)
1101}
1102
1103/// Helper: load graph edges for a neighborhood around the given seed node IDs.
1104/// Uses BFS expansion with max_hops=2 and max_nodes=200 by default.
1105/// Falls back to full graph load if seeds are empty.
1106fn load_neighborhood_edge_pairs(
1107    store: &semantic_memory::MemoryStore,
1108    seed_ids: &[String],
1109) -> Result<Vec<(String, String)>, ErrorData> {
1110    if seed_ids.is_empty() {
1111        return load_stored_edge_pairs(store);
1112    }
1113    let edges = tokio::task::block_in_place(|| {
1114        Handle::current().block_on(store.list_graph_edges_for_neighborhood(
1115            seed_ids.to_vec(),
1116            2,
1117            200,
1118        ))
1119    })
1120    .map_err(|e| {
1121        ErrorData::internal_error(format!("Failed to load neighborhood edges: {e}"), None)
1122    })?;
1123    let pairs = edges
1124        .iter()
1125        .map(|edge| (edge.source.clone(), edge.target.clone()))
1126        .collect();
1127    Ok(pairs)
1128}
1129
1130/// Helper: load graph edges for a neighborhood as GraphEdgeRef vec.
1131fn load_neighborhood_edge_refs(
1132    store: &semantic_memory::MemoryStore,
1133    seed_ids: &[String],
1134) -> Result<Vec<semantic_memory::discord::GraphEdgeRef>, ErrorData> {
1135    if seed_ids.is_empty() {
1136        return load_stored_edge_refs(store);
1137    }
1138    let edges = tokio::task::block_in_place(|| {
1139        Handle::current().block_on(store.list_graph_edges_for_neighborhood(
1140            seed_ids.to_vec(),
1141            2,
1142            200,
1143        ))
1144    })
1145    .map_err(|e| {
1146        ErrorData::internal_error(format!("Failed to load neighborhood edges: {e}"), None)
1147    })?;
1148    let refs = edges
1149        .iter()
1150        .map(|edge| {
1151            let parsed_type = edge
1152                .edge_type_parsed
1153                .clone()
1154                .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1155                .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1156                    relation: "unknown".to_string(),
1157                });
1158            let type_str = match parsed_type {
1159                semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
1160                semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
1161                semantic_memory::GraphEdgeType::Causal { .. } => "causal",
1162                semantic_memory::GraphEdgeType::Entity { .. } => "entity",
1163            };
1164            semantic_memory::discord::GraphEdgeRef {
1165                source: edge.source.clone(),
1166                target: edge.target.clone(),
1167                edge_type: type_str.to_string(),
1168                weight: edge.weight,
1169            }
1170        })
1171        .collect();
1172    Ok(refs)
1173}
1174
1175type FactorEdgeTuple = (
1176    String,
1177    String,
1178    semantic_memory::GraphEdgeType,
1179    f64,
1180    Option<String>,
1181);
1182
1183/// Helper: load graph edges for a neighborhood as factor graph tuples.
1184fn load_neighborhood_factor_edges(
1185    store: &semantic_memory::MemoryStore,
1186    seed_ids: &[String],
1187) -> Result<Vec<FactorEdgeTuple>, ErrorData> {
1188    if seed_ids.is_empty() {
1189        return load_stored_factor_edges(store);
1190    }
1191    let edges = tokio::task::block_in_place(|| {
1192        Handle::current().block_on(store.list_graph_edges_for_neighborhood(
1193            seed_ids.to_vec(),
1194            2,
1195            200,
1196        ))
1197    })
1198    .map_err(|e| {
1199        ErrorData::internal_error(format!("Failed to load neighborhood edges: {e}"), None)
1200    })?;
1201    let raw = edges
1202        .iter()
1203        .map(|edge| {
1204            let parsed_type = edge
1205                .edge_type_parsed
1206                .clone()
1207                .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1208                .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1209                    relation: "unknown".to_string(),
1210                });
1211            (
1212                edge.source.clone(),
1213                edge.target.clone(),
1214                parsed_type,
1215                edge.weight,
1216                edge.metadata.clone(),
1217            )
1218        })
1219        .collect();
1220    Ok(raw)
1221}
1222
1223/// Load fact ids targeted by entity relation="supersedes" graph edges.
1224fn load_superseded_targets(
1225    store: &semantic_memory::MemoryStore,
1226) -> Result<HashSet<String>, ErrorData> {
1227    let edges =
1228        tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1229            .map_err(|e| {
1230                ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1231            })?;
1232    let mut targets = HashSet::new();
1233    for edge in edges {
1234        let parsed_type = edge
1235            .edge_type_parsed
1236            .clone()
1237            .or_else(|| serde_json::from_str(&edge.edge_type).ok());
1238        if let Some(semantic_memory::GraphEdgeType::Entity { relation }) = parsed_type {
1239            if relation == "supersedes" {
1240                targets.insert(edge.target);
1241            }
1242        }
1243    }
1244    Ok(targets)
1245}
1246
1247fn query_allows_superseded(query: &str) -> bool {
1248    let q = query.to_lowercase();
1249    q.contains("supersed")
1250        || q.contains("stale")
1251        || q.contains("obsolete")
1252        || q.contains("histor")
1253        || q.contains("old fact")
1254        || q.contains("previous fact")
1255}
1256
1257/// Serialize a JSON value to a pretty string, mapping serialization errors
1258/// to protocol-level errors instead of success strings.
1259/// Build a `ProjectionQuery` from the MCP-facing `ProjectionQueryParams`.
1260///
1261/// Maps the flat parameter struct into the library's `ProjectionQuery` with
1262/// a fully-resolved `ScopeKey` and typed ID filters.
1263fn build_projection_query(params: ProjectionQueryParams) -> semantic_memory::ProjectionQuery {
1264    use stack_ids::{ClaimId, ClaimVersionId, EntityId, ScopeKey};
1265
1266    let scope = ScopeKey {
1267        namespace: params.namespace,
1268        domain: params.domain,
1269        workspace_id: params.workspace_id,
1270        repo_id: params.repo_id,
1271    };
1272
1273    let limit = params.limit.unwrap_or(10) as usize;
1274
1275    semantic_memory::ProjectionQuery {
1276        scope,
1277        text_query: params.text_query,
1278        valid_at: params.valid_at,
1279        recorded_at_or_before: params.recorded_at_or_before,
1280        subject_entity_id: params.subject_entity_id.map(EntityId::new),
1281        canonical_entity_id: params.canonical_entity_id.map(EntityId::new),
1282        claim_state: params.claim_state,
1283        claim_id: params.claim_id.map(ClaimId::new),
1284        claim_version_id: params.claim_version_id.map(ClaimVersionId::new),
1285        limit,
1286    }
1287}
1288
1289fn json_to_output(value: &serde_json::Value) -> Result<Json<StructuredOutput>, ErrorData> {
1290    Ok(structured_output(value.clone()))
1291}
1292
1293/// Generate a receipt ID for MCP mutation operations.
1294/// Format: `mcp-receipt:<tool_name>:<uuid>` — traceable to the tool that produced it.
1295fn mcp_receipt_id(tool_name: &str) -> String {
1296    format!("mcp-receipt:{tool_name}:{}", uuid::Uuid::new_v4())
1297}
1298
1299/// Current UTC timestamp in ISO 8601 format for receipt recording.
1300fn mcp_now_iso() -> String {
1301    chrono::Utc::now().to_rfc3339()
1302}
1303
1304/// Build a receipt envelope for a mutation tool response.
1305/// Every material MCP mutation must include this in its JSON output.
1306fn mcp_receipt(tool_name: &str) -> serde_json::Value {
1307    serde_json::json!({
1308        "receipt_id": mcp_receipt_id(tool_name),
1309        "recorded_at": mcp_now_iso(),
1310        "tool": tool_name,
1311    })
1312}
1313
1314/// Convert only a persisted fact into an autonomous-injection-safe witnessed hit.
1315///
1316/// The search index's source is not sufficient provenance: hydrate the fact row
1317/// so namespace and source are the values actually persisted with the memory.
1318/// Other result families currently lack this complete provenance surface, so are
1319/// deliberately omitted instead of receiving invented fields.
1320fn witnessed_injectible_fact(
1321    store: &semantic_memory::MemoryStore,
1322    result: semantic_memory::SearchResult,
1323    receipt_ref: &str,
1324) -> Result<Option<serde_json::Value>, ErrorData> {
1325    let semantic_memory::SearchSource::Fact { fact_id, .. } = &result.source else {
1326        return Ok(None);
1327    };
1328    let fact = tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(fact_id)))
1329        .map_err(|e| {
1330            ErrorData::internal_error(
1331                format!("witnessed fact provenance hydration failed: {e}"),
1332                None,
1333            )
1334        })?;
1335    let Some(fact) = fact else {
1336        return Ok(None);
1337    };
1338    let Some(source) = fact.source.filter(|source| !source.trim().is_empty()) else {
1339        return Ok(None);
1340    };
1341    let memory_id = format!("fact:{}", fact.id);
1342    Ok(Some(serde_json::json!({
1343        "memory_id": memory_id,
1344        "result_id": result.source.result_id(),
1345        "content": fact.content,
1346        "namespace": fact.namespace,
1347        "source": source,
1348        "trust": "persisted_unjudged",
1349        "state": "current",
1350        "retrieval_receipt_ref": receipt_ref,
1351        "score": result.score,
1352        "bm25_rank": result.bm25_rank,
1353        "vector_rank": result.vector_rank,
1354        "cosine_similarity": result.cosine_similarity,
1355    })))
1356}
1357
1358#[tool_router]
1359impl SemanticMemoryServer {
1360    // ── Core search tools ────────────────────────────────────────────
1361
1362    #[tool(
1363        description = "Semantic hybrid search (BM25 + vector + RRF). Returns ranked results with content, scores, and stable result IDs.",
1364        annotations(read_only_hint = true)
1365    )]
1366    fn sm_search(
1367        &self,
1368        Parameters(SearchParams {
1369            query,
1370            top_k,
1371            namespaces,
1372        }): Parameters<SearchParams>,
1373    ) -> Result<Json<StructuredOutput>, ErrorData> {
1374        let requested_k = top_k.map(|v| v as usize).unwrap_or(5);
1375        let allow_superseded = false;
1376        let search_k = if allow_superseded {
1377            requested_k
1378        } else {
1379            (requested_k * 4).max(20)
1380        };
1381        let ns: Option<Vec<&str>> = namespaces
1382            .as_ref()
1383            .map(|v| v.iter().map(|s| s.as_str()).collect());
1384
1385        let store = &self.bridge.store;
1386        let result = tokio::task::block_in_place(|| {
1387            Handle::current().block_on(store.search(&query, Some(search_k), ns.as_deref(), None))
1388        });
1389
1390        match result {
1391            Ok(results) => {
1392                let superseded_targets = if allow_superseded {
1393                    HashSet::new()
1394                } else {
1395                    load_superseded_targets(store)?
1396                };
1397                let fresh_results: Vec<_> = results
1398                    .iter()
1399                    .filter(|r| !superseded_targets.contains(&r.source.result_id()))
1400                    .collect();
1401                let result_refs: Vec<_> = if superseded_targets.is_empty() {
1402                    results.iter().collect()
1403                } else {
1404                    fresh_results
1405                };
1406                let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
1407                let json_results: Vec<serde_json::Value> = result_refs
1408                    .iter()
1409                    .take(requested_k)
1410                    .map(|r| {
1411                        serde_json::json!({
1412                            "result_id": r.source.result_id(),
1413                            "content": r.content,
1414                            "source": format!("{:?}", r.source),
1415                            "score": r.score,
1416                            "bm25_rank": r.bm25_rank,
1417                            "vector_rank": r.vector_rank,
1418                            "cosine_similarity": r.cosine_similarity,
1419                        })
1420                    })
1421                    .collect();
1422                json_to_output(&serde_json::json!({
1423                    "ok": true,
1424                    "results": json_results,
1425                    "count": json_results.len(),
1426                    "superseded_filtered_count": superseded_filtered_count,
1427                }))
1428            }
1429            Err(e) => Err(ErrorData::internal_error(
1430                format!("Search error: {e}"),
1431                None,
1432            )),
1433        }
1434    }
1435
1436    /// Best-effort claim-ledger trust lookup for a bare fact id. Falls back to
1437    /// "persisted_unjudged" whenever claim-integration is disabled, no claim
1438    /// exists for the fact, or no support judgment has been recorded yet.
1439    #[cfg(feature = "claim-integration")]
1440    fn trust_for_fact(&self, bare_fact_id: &str) -> String {
1441        self.claim_trust
1442            .lock()
1443            .unwrap()
1444            .trust_for_fact(bare_fact_id)
1445    }
1446
1447    #[cfg(not(feature = "claim-integration"))]
1448    fn trust_for_fact(&self, _bare_fact_id: &str) -> String {
1449        "persisted_unjudged".to_string()
1450    }
1451
1452    /// Best-effort: link `bare_fact_id` to an existing claim whose normalized
1453    /// content matches `content`, if one exists and the fact isn't already
1454    /// linked. Never fails the caller — this is a convenience wiring, not a
1455    /// truth-store operation.
1456    #[cfg(feature = "claim-integration")]
1457    fn auto_link_fact_to_claims(&self, bare_fact_id: &str, content: &str) {
1458        self.claim_trust
1459            .lock()
1460            .unwrap()
1461            .auto_link_content(bare_fact_id, content);
1462    }
1463
1464    #[cfg(not(feature = "claim-integration"))]
1465    fn auto_link_fact_to_claims(&self, _bare_fact_id: &str, _content: &str) {}
1466
1467    /// Overwrites the "trust" field of each search result (keyed off its
1468    /// "memory_id" of the form "fact:<id>") with the claim-ledger support
1469    /// state, when one has been recorded. Also attempts to auto-link any
1470    /// still-unjudged result whose content now matches a claim created since
1471    /// the fact was added. Never fails the search.
1472    fn enrich_results_with_trust(&self, results: &mut [serde_json::Value]) {
1473        for result in results.iter_mut() {
1474            let bare_fact_id = result
1475                .get("memory_id")
1476                .and_then(|v| v.as_str())
1477                .map(|s| s.strip_prefix("fact:").unwrap_or(s).to_string());
1478            let Some(bare_fact_id) = bare_fact_id else {
1479                continue;
1480            };
1481            if let Some(content) = result.get("content").and_then(|v| v.as_str()) {
1482                self.auto_link_fact_to_claims(&bare_fact_id, content);
1483            }
1484            if let Some(obj) = result.as_object_mut() {
1485                obj.insert(
1486                    "trust".to_string(),
1487                    serde_json::Value::String(self.trust_for_fact(&bare_fact_id)),
1488                );
1489            }
1490        }
1491    }
1492
1493    #[tool(
1494        description = "Mandatory witnessed retrieval. Bypasses cache, verifies durable receipt persistence, defaults to Current state, and supports privacy-preserving opt-in storage for complete replay.",
1495        annotations(read_only_hint = true)
1496    )]
1497    fn sm_search_witnessed(
1498        &self,
1499        Parameters(SearchWitnessedParams {
1500            query,
1501            top_k,
1502            namespaces,
1503            request_id,
1504            retrieval_mode,
1505            replay_mode,
1506        }): Parameters<SearchWitnessedParams>,
1507    ) -> Result<Json<StructuredOutput>, ErrorData> {
1508        use semantic_memory::{ExactnessProfile, ReceiptMode, ReplayMode, SearchContext};
1509        let k = top_k.map(|v| v as usize).unwrap_or(5);
1510        let request_id = request_id.unwrap_or_else(|| {
1511            format!(
1512                "mcp-witness-{}-{}",
1513                chrono::Utc::now().timestamp_micros(),
1514                WITNESS_REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed)
1515            )
1516        });
1517        let digest = |s: &str| format!("blake3:{}", blake3::hash(s.as_bytes()).to_hex());
1518        let filters = serde_json::json!({"namespaces": namespaces});
1519        let query_digest = digest(&query);
1520        let input_digest = digest(
1521            &serde_json::json!({"query": query, "top_k": k, "filters": filters}).to_string(),
1522        );
1523        let filter_digest = digest(&filters.to_string());
1524        let retrieval_mode = retrieval_mode.unwrap_or(RetrievalModeParam::Hybrid);
1525        let retrieval_mode_name = match retrieval_mode {
1526            RetrievalModeParam::Hybrid => "hybrid",
1527            RetrievalModeParam::FtsOnly => "fts_only",
1528            RetrievalModeParam::VectorOnly => "vector_only",
1529        };
1530        let config_digest = digest(&format!(
1531            "retrieval_mode={retrieval_mode_name};top_k={k};state=current;cache=bypass;exactness=prefer_exact"
1532        ));
1533        let ns: Option<Vec<&str>> = namespaces
1534            .as_ref()
1535            .map(|v| v.iter().map(String::as_str).collect());
1536        let mut context = SearchContext::default_now();
1537        context.receipt_mode = ReceiptMode::ReturnReceipt;
1538        context.replay_mode = match replay_mode.unwrap_or(ReplayModeParam::NoReplay) {
1539            ReplayModeParam::NoReplay => ReplayMode::NoReplay,
1540            ReplayModeParam::StoreInputs => ReplayMode::StoreInputs,
1541        };
1542        context.exactness_profile = ExactnessProfile::PreferExact;
1543        context.request_id = Some(request_id.clone());
1544        context.query_text_digest = Some(query_digest.clone());
1545        context.query_input_digest = Some(input_digest.clone());
1546        context.filter_digest = Some(filter_digest.clone());
1547        // ReturnReceipt bypasses semantic-memory's cache and propagates persistence failure.
1548        let response = tokio::task::block_in_place(|| {
1549            Handle::current().block_on(async {
1550                match retrieval_mode {
1551                    RetrievalModeParam::Hybrid => {
1552                        self.bridge
1553                            .store
1554                            .search_with_context(&query, Some(k), ns.as_deref(), None, context)
1555                            .await
1556                    }
1557                    RetrievalModeParam::FtsOnly => {
1558                        self.bridge
1559                            .store
1560                            .search_fts_only_with_context(
1561                                &query,
1562                                Some(k),
1563                                ns.as_deref(),
1564                                None,
1565                                context,
1566                            )
1567                            .await
1568                    }
1569                    RetrievalModeParam::VectorOnly => {
1570                        self.bridge
1571                            .store
1572                            .search_vector_only_with_context(
1573                                &query,
1574                                Some(k),
1575                                ns.as_deref(),
1576                                None,
1577                                context,
1578                            )
1579                            .await
1580                    }
1581                }
1582            })
1583        })
1584        .map_err(|e| {
1585            ErrorData::internal_error(
1586                format!("witnessed search/receipt persistence failed: {e}"),
1587                None,
1588            )
1589        })?;
1590        let receipt = response.receipt.ok_or_else(|| {
1591            ErrorData::internal_error("witness missing; operation contained".to_string(), None)
1592        })?;
1593        let authority_state = tokio::task::block_in_place(|| {
1594            Handle::current().block_on(self.bridge.store.authority().current_state())
1595        })
1596        .map_err(|error| {
1597            ErrorData::internal_error(format!("authority state lookup failed: {error}"), None)
1598        })?;
1599        let durable = tokio::task::block_in_place(|| {
1600            Handle::current().block_on(self.bridge.store.get_search_receipt(&receipt.receipt_id))
1601        })
1602        .map_err(|e| {
1603            ErrorData::internal_error(format!("receipt verification failed: {e}"), None)
1604        })?;
1605        if durable.is_none() {
1606            return Err(ErrorData::internal_error(
1607                "receipt not durable; operation contained".to_string(),
1608                None,
1609            ));
1610        }
1611        let complete_replay_available = tokio::task::block_in_place(|| {
1612            Handle::current().block_on(
1613                self.bridge
1614                    .store
1615                    .search_replay_inputs_available(&receipt.receipt_id),
1616            )
1617        })
1618        .map_err(|e| {
1619            ErrorData::internal_error(format!("replay input verification failed: {e}"), None)
1620        })?;
1621        let stats =
1622            tokio::task::block_in_place(|| Handle::current().block_on(self.bridge.store.stats()))
1623                .map_err(|e| {
1624                ErrorData::internal_error(format!("model identity unavailable: {e}"), None)
1625            })?;
1626        let model_digest = digest(&serde_json::json!({"model": stats.embedding_model, "dimensions": stats.embedding_dimensions}).to_string());
1627        let receipt_ref = format!("receipt:{}", receipt.receipt_id);
1628        let mut results = Vec::new();
1629        for result in response.results {
1630            if let Some(hit) = witnessed_injectible_fact(&self.bridge.store, result, &receipt_ref)?
1631            {
1632                results.push(hit);
1633            }
1634        }
1635        // T2.6: Enrich search results with claim-ledger support state.
1636        // Best-effort: falls back to "persisted_unjudged" when no claim exists.
1637        self.enrich_results_with_trust(&mut results);
1638
1639        // P1.3: Factor graph reranking (opt-in via integration feature).
1640        // When graph edges exist in the store, build a factor graph with
1641        // search scores as initial beliefs, run belief propagation, and
1642        // rerank results by refined beliefs. Items connected by multiple
1643        // relationship types get compounded confidence.
1644        #[cfg(feature = "integration")]
1645        {
1646            use semantic_memory::factor_graph::{
1647                factors_from_edges, FactorGraph, FactorGraphConfig,
1648            };
1649            let result_nodes: Vec<(String, f64)> = results
1650                .iter()
1651                .filter_map(|result| {
1652                    let id = result
1653                        .get("memory_id")
1654                        .and_then(|value| value.as_str())?
1655                        .to_string();
1656                    let score = result
1657                        .get("score")
1658                        .and_then(|value| value.as_f64())
1659                        .unwrap_or(0.5);
1660                    Some((id, score))
1661                })
1662                .collect();
1663            if !result_nodes.is_empty() {
1664                let seed_ids: Vec<String> = result_nodes
1665                    .iter()
1666                    .map(|(item_id, _)| item_id.clone())
1667                    .collect();
1668                let edge_tuples = load_neighborhood_factor_edges(&self.bridge.store, &seed_ids)?;
1669                if !edge_tuples.is_empty() {
1670                    let factors = factors_from_edges(&edge_tuples);
1671                    let factor_graph =
1672                        FactorGraph::new(&result_nodes, factors, FactorGraphConfig::default());
1673                    let result_beliefs = factor_graph.propagate();
1674                    let reranked = result_beliefs.top_k(result_nodes.len());
1675                    // Reorder results by factor graph beliefs (higher = better).
1676                    results.sort_by(|a, b| {
1677                        let a_id = a.get("memory_id").and_then(|v| v.as_str()).unwrap_or("");
1678                        let b_id = b.get("memory_id").and_then(|v| v.as_str()).unwrap_or("");
1679                        let a_belief = reranked
1680                            .iter()
1681                            .find(|(id, _)| id == a_id)
1682                            .map(|(_, b)| *b)
1683                            .unwrap_or(0.0);
1684                        let b_belief = reranked
1685                            .iter()
1686                            .find(|(id, _)| id == b_id)
1687                            .map(|(_, b)| *b)
1688                            .unwrap_or(0.0);
1689                        b_belief
1690                            .partial_cmp(&a_belief)
1691                            .unwrap_or(std::cmp::Ordering::Equal)
1692                    });
1693                }
1694            }
1695        }
1696
1697        let ordered_results: Vec<_> = results.iter().map(|r| serde_json::json!({"result_id": r["result_id"], "result_digest": digest(&r.to_string())})).collect();
1698        let exactness = if receipt.approximate {
1699            "approximate_candidates"
1700        } else if receipt.exact_rerank {
1701            "exact_f32_rerank"
1702        } else {
1703            "backend_reported_non_approximate"
1704        };
1705        json_to_output(&serde_json::json!({
1706            "schema_version": "retrieval_response_v1", "ok": true, "request_id": request_id, "receipt_id": receipt.receipt_id, "retrieval_mode": retrieval_mode_name,
1707            "state_view": {"kind": "Current"}, "current_snapshot_id": authority_state.snapshot_id.0,
1708            "retrieval_epoch": authority_state.retrieval_epoch.0,
1709            "evaluation_time": receipt.evaluation_time,
1710            "authority": {
1711                "snapshot_id": authority_state.snapshot_id.0,
1712                "retrieval_epoch": authority_state.retrieval_epoch.0,
1713                "status": "Applied",
1714                "degradation": null
1715            },
1716            "digests": {"query_text": query_digest, "input": input_digest, "filter": filter_digest, "config": config_digest, "model": model_digest},
1717            "execution": {"cache": "bypassed", "candidate_backend": receipt.candidate_backend, "exactness": exactness, "artifact_generation_id": receipt.artifact_generation_id},
1718            "ordered_results": ordered_results, "results": results,
1719            "stage_outcomes": {
1720                "authority_snapshot": {"outcome": "Applied", "degradation": null},
1721                "hybrid_retrieval": {"outcome": if matches!(retrieval_mode, RetrievalModeParam::Hybrid) { "Applied" } else { "Skipped" }, "degradation": null},
1722                "selected_retrieval": {"outcome": "Applied", "degradation": null, "mode": retrieval_mode_name},
1723                "receipt_persistence": {"outcome": "Applied", "degradation": null},
1724                "cache": {"outcome": "Skipped", "degradation": "witnessed retrieval bypasses cache"},
1725                "replay": if complete_replay_available {
1726                    serde_json::json!({"outcome": "Applied", "degradation": null})
1727                } else {
1728                    serde_json::json!({"outcome": "AnalysisOnly", "degradation": "complete replay inputs are not available"})
1729                }
1730            },
1731            "degradations": receipt.degradations,
1732            "complete_replay_available": complete_replay_available
1733        }))
1734    }
1735
1736    #[tool(
1737        description = "Search with proof-debt analysis. Runs a standard search, then for each result checks the claim-ledger trust index for support state. Returns results plus a proof_debt summary with total debt weight, unsupported count, and gate decision.",
1738        annotations(read_only_hint = true)
1739    )]
1740    fn sm_search_proof_debt(
1741        &self,
1742        Parameters(SearchProofDebtParams {
1743            query,
1744            top_k,
1745            namespaces,
1746            budget_micros,
1747        }): Parameters<SearchProofDebtParams>,
1748    ) -> Result<Json<StructuredOutput>, ErrorData> {
1749        let k = top_k.map(|v| v as usize).unwrap_or(5);
1750        let store = &self.bridge.store;
1751        let ns: Option<Vec<&str>> = namespaces
1752            .as_ref()
1753            .map(|v| v.iter().map(String::as_str).collect());
1754
1755        let results = tokio::task::block_in_place(|| {
1756            Handle::current().block_on(store.search(&query, Some(k), ns.as_deref(), None))
1757        })
1758        .map_err(|e| ErrorData::internal_error(format!("search failed: {e}"), None))?;
1759
1760        // T2.5/D3: Evaluate proof debt for search results using the real
1761        // claim-ledger budget machinery. Results with no linked claim carry
1762        // no debt (there is nothing to owe proof for). Results whose claim
1763        // is unjudged, heuristic-only, unsupported, or contradicted incur
1764        // the claim-ledger's real ProofDebt weights, and the aggregate is
1765        // run through the real gate evaluator — no hardcoded weights.
1766        #[cfg(feature = "claim-integration")]
1767        {
1768            use claim_ledger::{
1769                budget_for_claim, evaluate_proof_debt_gate_with_config, total_proof_debt_weight,
1770                ProofDebtBudgetConfig,
1771            };
1772            let budget = budget_micros.unwrap_or(500_000);
1773            let idx = self.claim_trust.lock().unwrap();
1774            let mut all_debts = Vec::new();
1775            let mut unsupported_count = 0usize;
1776            let mut per_result = Vec::new();
1777
1778            for r in &results {
1779                let fact_id = r.source.result_id();
1780                let bare_id = fact_id.strip_prefix("fact:").unwrap_or(&fact_id);
1781                let trust = idx.trust_for_fact(bare_id);
1782                let (claim_id, debts) = match idx.claim_and_state_for_fact(bare_id) {
1783                    Some((claim_id, state)) => {
1784                        let debts = proof_debts_for_support_state(
1785                            state.unwrap_or(claim_ledger::SupportState::Unknown),
1786                        );
1787                        (Some(claim_id), debts)
1788                    }
1789                    None => (None, Vec::new()),
1790                };
1791                if !debts.is_empty() {
1792                    unsupported_count += 1;
1793                }
1794                let debt_micros = total_proof_debt_weight(&debts);
1795                all_debts.extend(debts);
1796                per_result.push(serde_json::json!({
1797                    "fact_id": fact_id,
1798                    "claim_id": claim_id,
1799                    "trust": trust,
1800                    "debt_micros": debt_micros,
1801                }));
1802            }
1803
1804            let config = ProofDebtBudgetConfig::default();
1805            let total_debt = total_proof_debt_weight(&all_debts);
1806            let (proof_budget, debits) =
1807                budget_for_claim("sm_search_proof_debt", &all_debts, budget);
1808            let gate = evaluate_proof_debt_gate_with_config(&proof_budget, &config);
1809
1810            json_to_output(&serde_json::json!({
1811                "ok": true,
1812                "query": query,
1813                "results": per_result,
1814                "count": results.len(),
1815                "proof_debt": {
1816                    "total_debt_micros": total_debt,
1817                    "unsupported_count": unsupported_count,
1818                    "budget_micros": budget,
1819                    "consumed_pct": gate.consumed_pct,
1820                    "exhausted": gate.exhausted,
1821                    "gate_decision": gate.decision,
1822                    "gate_summary": gate.summary,
1823                    "debit_count": debits.len(),
1824                },
1825                "receipt": mcp_receipt("sm_search_proof_debt"),
1826            }))
1827        }
1828        #[cfg(not(feature = "claim-integration"))]
1829        {
1830            json_to_output(&serde_json::json!({
1831                "ok": true,
1832                "query": query,
1833                "results": results.iter().map(|r| serde_json::json!({
1834                    "fact_id": r.source.result_id(),
1835                    "content": r.content,
1836                    "trust": "persisted_unjudged",
1837                })).collect::<Vec<_>>(),
1838                "count": results.len(),
1839                "proof_debt": {
1840                    "total_debt_micros": 0,
1841                    "unsupported_count": 0,
1842                    "budget_micros": budget_micros.unwrap_or(500_000),
1843                    "gate_decision": "Pass",
1844                    "note": "claim-integration feature not enabled",
1845                },
1846                "receipt": mcp_receipt("sm_search_proof_debt"),
1847            }))
1848        }
1849    }
1850
1851    #[tool(
1852        description = "Benchmark trust quality across search results. Runs multiple queries and measures the trust distribution (supported/partially_supported/unsupported/contradicted/heuristic_only/persisted_unjudged) of returned results. Shows what fraction of retrieved facts have claim-ledger backing vs unjudged.",
1853        annotations(read_only_hint = true)
1854    )]
1855    fn sm_benchmark_trust(
1856        &self,
1857        Parameters(BenchmarkTrustParams {
1858            query_count,
1859            top_k,
1860            namespaces,
1861        }): Parameters<BenchmarkTrustParams>,
1862    ) -> Result<Json<StructuredOutput>, ErrorData> {
1863        let n = query_count.map(|v| v as usize).unwrap_or(10);
1864        let k = top_k.map(|v| v as usize).unwrap_or(5);
1865        let store = &self.bridge.store;
1866        let ns: Option<Vec<&str>> = namespaces
1867            .as_ref()
1868            .map(|v| v.iter().map(String::as_str).collect());
1869
1870        // Use recent facts as benchmark queries (search for their own content).
1871        let facts =
1872            tokio::task::block_in_place(|| Handle::current().block_on(store.list_facts("", n, 0)))
1873                .map_err(|e| ErrorData::internal_error(format!("list_facts failed: {e}"), None))?;
1874
1875        #[cfg(feature = "claim-integration")]
1876        {
1877            let idx = self.claim_trust.lock().unwrap();
1878            let mut trust_counts: HashMap<String, usize> = HashMap::new();
1879            for label in &[
1880                "supported",
1881                "partially_supported",
1882                "unsupported",
1883                "contradicted",
1884                "heuristic_only",
1885                "persisted_unjudged",
1886            ] {
1887                trust_counts.insert(label.to_string(), 0);
1888            }
1889
1890            let mut total_results = 0usize;
1891            let mut per_query = Vec::new();
1892
1893            for fact in &facts {
1894                let query = &fact.content;
1895                let results = tokio::task::block_in_place(|| {
1896                    Handle::current().block_on(store.search(query, Some(k), ns.as_deref(), None))
1897                })
1898                .unwrap_or_default();
1899
1900                let mut query_trust: HashMap<String, usize> = HashMap::new();
1901                for r in &results {
1902                    let bare_id = r.source.result_id();
1903                    let bare = bare_id.strip_prefix("fact:").unwrap_or(&bare_id);
1904                    let trust = idx.trust_for_fact(bare);
1905                    *trust_counts.entry(trust.clone()).or_insert(0) += 1;
1906                    *query_trust.entry(trust.clone()).or_insert(0) += 1;
1907                    total_results += 1;
1908                }
1909                per_query.push(serde_json::json!({
1910                    "query": query,
1911                    "result_count": results.len(),
1912                    "trust_distribution": query_trust,
1913                }));
1914            }
1915
1916            json_to_output(&serde_json::json!({
1917                "ok": true,
1918                "queries_run": facts.len(),
1919                "top_k": k,
1920                "total_results": total_results,
1921                "trust_distribution": trust_counts,
1922                "judged_pct": if total_results > 0 {
1923                    let judged = trust_counts.get("supported").copied().unwrap_or(0)
1924                        + trust_counts.get("partially_supported").copied().unwrap_or(0)
1925                        + trust_counts.get("contradicted").copied().unwrap_or(0)
1926                        + trust_counts.get("unsupported").copied().unwrap_or(0);
1927                    (judged as f64 / total_results as f64) * 100.0
1928                } else { 0.0 },
1929                "per_query": per_query,
1930                "receipt": mcp_receipt("sm_benchmark_trust"),
1931            }))
1932        }
1933        #[cfg(not(feature = "claim-integration"))]
1934        {
1935            json_to_output(&serde_json::json!({
1936                "ok": true,
1937                "queries_run": facts.len(),
1938                "top_k": k,
1939                "trust_distribution": {"persisted_unjudged": facts.len()},
1940                "judged_pct": 0.0,
1941                "note": "claim-integration feature not enabled",
1942                "receipt": mcp_receipt("sm_benchmark_trust"),
1943            }))
1944        }
1945    }
1946
1947    #[tool(
1948        description = "Return the canonical typed origin-authority decision receipt for asserting a fact. The purpose is fixed to assertion; recall authority is not reused. This read-only decision surface never returns memory content.",
1949        annotations(read_only_hint = true)
1950    )]
1951    fn sm_decide_assertion_authority(
1952        &self,
1953        Parameters(params): Parameters<GovernedDecisionParams>,
1954    ) -> Result<Json<StructuredOutput>, ErrorData> {
1955        self.decide_governed_authority(params, semantic_memory::GovernedAccessPurposeV1::Assertion)
1956    }
1957
1958    #[tool(
1959        description = "Return the canonical typed origin-authority decision receipt for acting on a fact. The purpose is fixed to action; recall or assertion authority is not reused. This read-only decision surface never returns memory content or performs the action.",
1960        annotations(read_only_hint = true)
1961    )]
1962    fn sm_decide_action_authority(
1963        &self,
1964        Parameters(params): Parameters<GovernedDecisionParams>,
1965    ) -> Result<Json<StructuredOutput>, ErrorData> {
1966        self.decide_governed_authority(params, semantic_memory::GovernedAccessPurposeV1::Action)
1967    }
1968
1969    // DEPRECATED #[tool(
1970    // description = "Search with full score breakdown showing how BM25 and vector scores combine. Useful for debugging retrieval quality.",
1971    // annotations(read_only_hint = true)
1972    // )]
1973    #[allow(dead_code)]
1974    fn sm_search_explained(
1975        &self,
1976        Parameters(SearchExplainedParams { query, top_k }): Parameters<SearchExplainedParams>,
1977    ) -> Result<Json<StructuredOutput>, ErrorData> {
1978        let requested_k = top_k.map(|v| v as usize).unwrap_or(5);
1979        let allow_superseded = query_allows_superseded(&query);
1980        let search_k = if allow_superseded {
1981            requested_k
1982        } else {
1983            (requested_k * 4).max(20)
1984        };
1985        let store = &self.bridge.store;
1986        let result = tokio::task::block_in_place(|| {
1987            Handle::current().block_on(store.search_explained(&query, Some(search_k), None, None))
1988        });
1989
1990        match result {
1991            Ok(results) => {
1992                let superseded_targets = if allow_superseded {
1993                    HashSet::new()
1994                } else {
1995                    load_superseded_targets(store)?
1996                };
1997                let fresh_results: Vec<_> = results
1998                    .iter()
1999                    .filter(|r| !superseded_targets.contains(&r.result.source.result_id()))
2000                    .collect();
2001                let result_refs: Vec<_> = if superseded_targets.is_empty() {
2002                    results.iter().collect()
2003                } else {
2004                    fresh_results
2005                };
2006                let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
2007                let json_results: Vec<serde_json::Value> = result_refs
2008                    .iter()
2009                    .take(requested_k)
2010                    .map(|r| {
2011                        serde_json::json!({
2012                            "result_id": r.result.source.result_id(),
2013                            "content": r.result.content,
2014                            "source": format!("{:?}", r.result.source),
2015                            "score": r.result.score,
2016                            "bm25_rank": r.result.bm25_rank,
2017                            "vector_rank": r.result.vector_rank,
2018                            "cosine_similarity": r.result.cosine_similarity,
2019                            "breakdown": {
2020                                "rrf_score": r.breakdown.rrf_score,
2021                                "bm25_score": r.breakdown.bm25_score,
2022                                "vector_score": r.breakdown.vector_score,
2023                                "recency_score": r.breakdown.recency_score,
2024                                "bm25_rank": r.breakdown.bm25_rank,
2025                                "vector_rank": r.breakdown.vector_rank,
2026                                "vector_source_rank": r.breakdown.vector_source_rank,
2027                                "vector_source_score": r.breakdown.vector_source_score,
2028                                "bm25_contribution": r.breakdown.bm25_contribution,
2029                                "vector_contribution": r.breakdown.vector_contribution,
2030                                "vector_reranked_from_f32": r.breakdown.vector_reranked_from_f32,
2031                                "bm25_weight": r.breakdown.bm25_weight,
2032                                "vector_weight": r.breakdown.vector_weight,
2033                                "recency_weight": r.breakdown.recency_weight,
2034                                "rrf_k": r.breakdown.rrf_k,
2035                            },
2036                        })
2037                    })
2038                    .collect();
2039                json_to_output(&serde_json::json!({
2040                    "ok": true,
2041                    "results": json_results,
2042                    "count": json_results.len(),
2043                    "superseded_filtered_count": superseded_filtered_count,
2044                }))
2045            }
2046            Err(e) => Err(ErrorData::internal_error(
2047                format!("Search error: {e}"),
2048                None,
2049            )),
2050        }
2051    }
2052
2053    #[tool(
2054        description = "Add a fact to the knowledge base. Embedded and indexed for semantic search. Returns fact ID and content digest.",
2055        annotations(idempotent_hint = true)
2056    )]
2057    fn sm_add_fact(
2058        &self,
2059        Parameters(AddFactParams {
2060            content,
2061            namespace,
2062            source,
2063            extract_entities,
2064            memory_kind,
2065            sensitivity,
2066            evidence_refs,
2067            idempotency_key,
2068        }): Parameters<AddFactParams>,
2069    ) -> Result<Json<StructuredOutput>, ErrorData> {
2070        let store = &self.bridge.store;
2071
2072        // Admission gate: classify sensitivity
2073        let sens = sensitivity.unwrap_or_else(|| "internal".to_string());
2074        let kind = memory_kind.unwrap_or_else(|| "durable_fact".to_string());
2075
2076        // Block confidential/restricted content from autocapture
2077        if sens == "confidential" || sens == "restricted" {
2078            return Err(ErrorData::invalid_params(
2079                format!("Admission gate BLOCKED: sensitivity='{sens}' content cannot be stored without explicit user request"),
2080                None,
2081            ));
2082        }
2083
2084        // Block ephemeral_inference from becoming durable without evidence
2085        let explicit_evidence: Vec<String> = evidence_refs
2086            .as_deref()
2087            .unwrap_or_default()
2088            .iter()
2089            .filter(|reference| !reference.trim().is_empty())
2090            .cloned()
2091            .collect();
2092        if kind == "ephemeral_inference" && explicit_evidence.is_empty() {
2093            return Err(ErrorData::invalid_params(
2094                "Admission gate BLOCKED: ephemeral_inference requires evidence_refs to promote to durable".to_string(),
2095                None,
2096            ));
2097        }
2098
2099        let mut authority_evidence = explicit_evidence;
2100        if let Some(source_ref) = source.as_ref().filter(|value| !value.trim().is_empty()) {
2101            if !authority_evidence.contains(source_ref) {
2102                authority_evidence.push(source_ref.clone());
2103            }
2104        }
2105
2106        // Build metadata JSON with typed memory fields
2107        let mut meta = serde_json::Map::new();
2108        meta.insert("memory_kind".to_string(), serde_json::json!(kind));
2109        meta.insert("sensitivity".to_string(), serde_json::json!(sens));
2110        if let Some(refs) = evidence_refs {
2111            meta.insert("evidence_refs".to_string(), serde_json::json!(refs));
2112        }
2113        let metadata = serde_json::Value::Object(meta);
2114
2115        let caller_idempotency_key = match idempotency_key {
2116            Some(key) if !key.trim().is_empty() => key,
2117            Some(_) => {
2118                return Err(ErrorData::invalid_params(
2119                    "idempotency_key must not be blank".to_string(),
2120                    None,
2121                ))
2122            }
2123            None => format!("mcp-sm-add-fact:{}", uuid::Uuid::new_v4()),
2124        };
2125        let origin = if authority_evidence.is_empty() {
2126            semantic_memory::OriginAuthorityLabelV1::operator_system(
2127                "principal:semantic-memory-mcp",
2128                "caller:sm_add_fact",
2129            )
2130        } else {
2131            semantic_memory::OriginAuthorityLabelV1::new(
2132                semantic_memory::OriginClassV1::ExternalEvidence,
2133                "principal:semantic-memory-mcp",
2134                "caller:sm_add_fact",
2135                format!(
2136                    "blake3:{}",
2137                    blake3::hash(authority_evidence.join("\n").as_bytes()).to_hex()
2138                ),
2139                semantic_memory::OriginRiskV1::Medium,
2140                semantic_memory::AuthorityScopesV1 {
2141                    recall: semantic_memory::AuthorityScopeV1::Universal,
2142                    assertion: semantic_memory::AuthorityScopeV1::Denied,
2143                    action: semantic_memory::AuthorityScopeV1::Denied,
2144                },
2145                semantic_memory::ElevationRequirementV1::ExplicitOperatorApproval,
2146                None,
2147                semantic_memory::RevocationStatusV1::Active,
2148                vec!["principal:semantic-memory-mcp".into()],
2149            )
2150            .map_err(|error| {
2151                ErrorData::internal_error(format!("invalid origin label: {error}"), None)
2152            })?
2153        };
2154        let permit = if authority_evidence.is_empty() {
2155            semantic_memory::AuthorityPermit::operator_system(
2156                "principal:semantic-memory-mcp",
2157                "caller:sm_add_fact",
2158                semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
2159            )
2160        } else {
2161            semantic_memory::AuthorityPermit::with_evidence(
2162                "principal:semantic-memory-mcp",
2163                "caller:sm_add_fact",
2164                semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
2165                authority_evidence,
2166            )
2167        }
2168        .with_origin(origin);
2169
2170        let result = tokio::task::block_in_place(|| {
2171            Handle::current().block_on(store.authority().append_with_metadata(
2172                permit,
2173                caller_idempotency_key,
2174                namespace.clone(),
2175                content.clone(),
2176                source.clone(),
2177                Some(metadata),
2178            ))
2179        });
2180
2181        match result {
2182            Ok(receipt) => {
2183                let id = receipt.affected_ids.first().cloned().ok_or_else(|| {
2184                    ErrorData::internal_error(
2185                        "authority append returned no affected fact id".to_string(),
2186                        None,
2187                    )
2188                })?;
2189                // D4: best-effort auto-link to an existing claim with matching
2190                // normalized content. Never fails the whole operation.
2191                self.auto_link_fact_to_claims(&id, &content);
2192                // Optional entity extraction — best-effort, never fails the whole operation.
2193                if extract_entities == Some(true) {
2194                    let prompt = format!(
2195                        "Extract entities from this text as JSON. Format: {{\"entities\": [{{\"name\": \"...\", \"type\": \"person|project|concept|tool|version|path\"}}]}}\nText: {content}\nJSON:"
2196                    );
2197                    let body = serde_json::json!({
2198                        "model": "granite4.1:3b",
2199                        "prompt": prompt,
2200                        "stream": false,
2201                        "options": {"temperature": 0, "num_predict": 200}
2202                    });
2203                    if let Ok(resp) = reqwest::blocking::Client::new()
2204                        .post("http://127.0.0.1:11434/api/generate")
2205                        .json(&body)
2206                        .send()
2207                    {
2208                        if let Ok(v) = resp.json::<serde_json::Value>() {
2209                            if let Some(response_str) = v.get("response").and_then(|r| r.as_str()) {
2210                                // Use boundary compiler for robust JSON parsing with duplicate-key rejection
2211                                let parsed_result =
2212                                    boundary_compiler::parse_with_dup_check(response_str.trim());
2213                                if let Ok(parsed) = parsed_result {
2214                                    if let Some(entities) =
2215                                        parsed.get("entities").and_then(|e| e.as_array())
2216                                    {
2217                                        let fact_node = format!("fact:{id}");
2218                                        for entity in entities {
2219                                            if let Some(name) =
2220                                                entity.get("name").and_then(|n| n.as_str())
2221                                            {
2222                                                let entity_node = format!("entity:{name}");
2223                                                let _ = tokio::task::block_in_place(|| {
2224                                                    Handle::current()
2225                                                        .block_on(store.add_graph_edge(
2226                                                        &fact_node,
2227                                                        &entity_node,
2228                                                        semantic_memory::GraphEdgeType::Entity {
2229                                                            relation: "mentions".to_string(),
2230                                                        },
2231                                                        1.0,
2232                                                        None,
2233                                                    ))
2234                                                });
2235                                            }
2236                                        }
2237                                    }
2238                                }
2239                            }
2240                        }
2241                    }
2242                }
2243
2244                json_to_output(&serde_json::json!({
2245                    "ok": true,
2246                    "fact_id": id,
2247                    "namespace": namespace,
2248                    "receipt": mcp_receipt("sm_add_fact"),
2249                    "message": "Fact added successfully",
2250                }))
2251            }
2252            Err(e) => Err(ErrorData::internal_error(
2253                format!("Error adding fact: {e}"),
2254                None,
2255            )),
2256        }
2257    }
2258
2259    #[tool(
2260        description = "Ingest a document with automatic chunking. Splits into chunks, each embedded and indexed. Returns document ID and chunk count.",
2261        annotations(idempotent_hint = true)
2262    )]
2263    fn sm_ingest_document(
2264        &self,
2265        Parameters(IngestDocumentParams {
2266            content,
2267            title,
2268            namespace,
2269        }): Parameters<IngestDocumentParams>,
2270    ) -> Result<Json<StructuredOutput>, ErrorData> {
2271        let store = &self.bridge.store;
2272        let result = tokio::task::block_in_place(|| {
2273            Handle::current()
2274                .block_on(store.ingest_document(&title, &content, &namespace, None, None))
2275        });
2276
2277        match result {
2278            Ok(doc_id) => {
2279                let chunk_count = tokio::task::block_in_place(|| {
2280                    Handle::current().block_on(store.count_chunks_for_document(&doc_id))
2281                })
2282                .unwrap_or(0);
2283                json_to_output(&serde_json::json!({
2284                    "ok": true,
2285                    "receipt": mcp_receipt("sm_ingest_document"),
2286                    "document_id": doc_id,
2287                    "title": title,
2288                    "chunk_count": chunk_count,
2289                    "message": "Document ingested successfully",
2290                }))
2291            }
2292            Err(e) => Err(ErrorData::internal_error(
2293                format!("Error ingesting document: {e}"),
2294                None,
2295            )),
2296        }
2297    }
2298
2299    #[tool(
2300        description = "Get knowledge base statistics: fact/chunk/document/session counts, DB size, embedding model, and graph edge count.",
2301        annotations(read_only_hint = true)
2302    )]
2303    fn sm_stats(&self) -> Result<Json<StatsOutput>, ErrorData> {
2304        let store = &self.bridge.store;
2305        let core = tokio::task::block_in_place(|| Handle::current().block_on(store.stats()));
2306        let graph = tokio::task::block_in_place(|| {
2307            Handle::current().block_on(store.list_all_graph_edges())
2308        });
2309        let core_health = match &core {
2310            Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2311            Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2312        };
2313        let graph_health = match &graph {
2314            Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2315            Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2316        };
2317        let core_value = core.ok();
2318        let graph_count = graph.ok().map(|edges| edges.len());
2319        Ok(Json(StatsOutput {
2320            ok: core_value.is_some() && graph_count.is_some(),
2321            components: serde_json::json!({"core": core_health, "graph": graph_health}),
2322            facts: core_value.as_ref().map(|s| s.total_facts),
2323            chunks: core_value.as_ref().map(|s| s.total_chunks),
2324            documents: core_value.as_ref().map(|s| s.total_documents),
2325            sessions: core_value.as_ref().map(|s| s.total_sessions),
2326            messages: core_value.as_ref().map(|s| s.total_messages),
2327            graph_edges: graph_count,
2328            db_size_bytes: core_value.as_ref().map(|s| s.database_size_bytes),
2329            db_size_mb: core_value
2330                .as_ref()
2331                .map(|s| (s.database_size_bytes as f64 / 1_048_576.0 * 100.0).round() / 100.0),
2332            embedding_model: core_value.as_ref().and_then(|s| s.embedding_model.clone()),
2333            embedding_dimensions: core_value.as_ref().and_then(|s| s.embedding_dimensions),
2334        }))
2335    }
2336
2337    #[tool(
2338        description = "Find shortest path between two items in the knowledge graph. Traverses all edge types. Returns node IDs with edge evidence per hop.",
2339        annotations(read_only_hint = true)
2340    )]
2341    fn sm_graph_path(
2342        &self,
2343        Parameters(GraphPathParams {
2344            from_id,
2345            to_id,
2346            max_depth,
2347        }): Parameters<GraphPathParams>,
2348    ) -> Result<Json<StructuredOutput>, ErrorData> {
2349        let depth = max_depth.map(|v| v as usize).unwrap_or(5);
2350        let store = &self.bridge.store;
2351        let g = store.graph_view();
2352
2353        match typed_graph_path(g.as_ref(), &from_id, &to_id, depth) {
2354            Ok(GraphPathOutcome::Found(path)) => {
2355                // Build edge evidence for each hop by examining neighbors.
2356                let path_segments = build_path_segments(store, &path);
2357                json_to_output(&serde_json::json!({
2358                    "ok": true,
2359                    "outcome": "Found",
2360                    "from": from_id,
2361                    "to": to_id,
2362                    "path": path,
2363                    "path_length": path.len(),
2364                    "segments": path_segments,
2365                }))
2366            }
2367            Ok(GraphPathOutcome::NoPathWithinCompleteSearch) => {
2368                json_to_output(&serde_json::json!({
2369                    "ok": true,
2370                    "outcome": "NoPathWithinCompleteSearch",
2371                    "from": from_id,
2372                    "to": to_id,
2373                    "path": null,
2374                    "message": format!("No path found from {from_id} to {to_id} within depth {depth}"),
2375                }))
2376            }
2377            Ok(GraphPathOutcome::BudgetExceeded) => json_to_output(&serde_json::json!({
2378                "ok": false, "outcome": "BudgetExceeded", "from": from_id, "to": to_id,
2379                "path": null, "budget": {"max_depth": depth}
2380            })),
2381            Ok(GraphPathOutcome::InvalidEndpoint(endpoint)) => json_to_output(&serde_json::json!({
2382                "ok": false, "outcome": "InvalidEndpoint", "invalid_endpoint": endpoint,
2383                "from": from_id, "to": to_id, "path": null
2384            })),
2385            Err(e) => Err(ErrorData::internal_error(
2386                format!("Graph view error: {e}"),
2387                None,
2388            )),
2389        }
2390    }
2391
2392    // ── Direct read and supersession tools (v0.3.1) ──────────────────
2393
2394    #[tool(
2395        description = "Fetch one fact by id (bare UUID or prefixed 'fact:<uuid>'). Returns full content, namespace, source, timestamps, and metadata.",
2396        annotations(read_only_hint = true)
2397    )]
2398    fn sm_get_fact(
2399        &self,
2400        Parameters(GetFactParams { fact_id }): Parameters<GetFactParams>,
2401    ) -> Result<Json<StructuredOutput>, ErrorData> {
2402        let bare = fact_id
2403            .strip_prefix("fact:")
2404            .unwrap_or(&fact_id)
2405            .to_string();
2406        let store = &self.bridge.store;
2407        let result =
2408            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
2409        match result {
2410            Ok(Some(f)) => json_to_output(&serde_json::json!({
2411                "ok": true,
2412                "found": true,
2413                "fact": {
2414                    "result_id": format!("fact:{}", f.id),
2415                    "id": f.id,
2416                    "namespace": f.namespace,
2417                    "content": f.content,
2418                    "source": f.source,
2419                    "created_at": f.created_at,
2420                    "updated_at": f.updated_at,
2421                    "metadata": f.metadata,
2422                },
2423            })),
2424            Ok(None) => json_to_output(&serde_json::json!({
2425                "ok": true,
2426                "found": false,
2427                "message": format!("No fact with id '{fact_id}'"),
2428            })),
2429            Err(e) => Err(ErrorData::internal_error(
2430                format!("get_fact error: {e}"),
2431                None,
2432            )),
2433        }
2434    }
2435
2436    #[tool(
2437        description = "Enumerate facts in a namespace (newest first) with pagination. Exhaustive, not similarity-ranked — for browsing, auditing, or deduping.",
2438        annotations(read_only_hint = true)
2439    )]
2440    fn sm_list_facts(
2441        &self,
2442        Parameters(ListFactsParams {
2443            namespace,
2444            limit,
2445            offset,
2446        }): Parameters<ListFactsParams>,
2447    ) -> Result<Json<StructuredOutput>, ErrorData> {
2448        let lim = limit.map(|v| v as usize).unwrap_or(50);
2449        let off = offset.map(|v| v as usize).unwrap_or(0);
2450        let store = &self.bridge.store;
2451        let result = tokio::task::block_in_place(|| {
2452            Handle::current().block_on(store.list_facts(&namespace, lim, off))
2453        });
2454        match result {
2455            Ok(facts) => {
2456                let arr: Vec<serde_json::Value> = facts
2457                    .iter()
2458                    .map(|f| {
2459                        serde_json::json!({
2460                            "result_id": format!("fact:{}", f.id),
2461                            "id": f.id,
2462                            "namespace": f.namespace,
2463                            "content": f.content,
2464                            "source": f.source,
2465                            "updated_at": f.updated_at,
2466                        })
2467                    })
2468                    .collect();
2469                json_to_output(&serde_json::json!({
2470                    "ok": true,
2471                    "namespace": namespace,
2472                    "count": arr.len(),
2473                    "limit": lim,
2474                    "offset": off,
2475                    "facts": arr,
2476                }))
2477            }
2478            Err(e) => Err(ErrorData::internal_error(
2479                format!("list_facts error: {e}"),
2480                None,
2481            )),
2482        }
2483    }
2484
2485    #[tool(
2486        description = "List namespaces that currently contain facts. Use before sm_list_facts to discover what is stored.",
2487        annotations(read_only_hint = true)
2488    )]
2489    fn sm_list_namespaces(&self) -> Result<Json<StructuredOutput>, ErrorData> {
2490        let store = &self.bridge.store;
2491        let result = tokio::task::block_in_place(|| {
2492            Handle::current().block_on(store.list_fact_namespaces())
2493        });
2494        match result {
2495            Ok(ns) => json_to_output(&serde_json::json!({
2496                "ok": true,
2497                "count": ns.len(),
2498                "namespaces": ns,
2499            })),
2500            Err(e) => Err(ErrorData::internal_error(
2501                format!("list_namespaces error: {e}"),
2502                None,
2503            )),
2504        }
2505    }
2506
2507    #[tool(
2508        description = "Fetch a fact plus its graph neighbors WITH their content in one call. Hydrates neighbor facts for ids returned by graph tools.",
2509        annotations(read_only_hint = true)
2510    )]
2511    fn sm_get_fact_neighbors(
2512        &self,
2513        Parameters(GetFactNeighborsParams { item_id }): Parameters<GetFactNeighborsParams>,
2514    ) -> Result<Json<StructuredOutput>, ErrorData> {
2515        let node_id = if item_id.contains(':') {
2516            item_id.clone()
2517        } else {
2518            format!("fact:{item_id}")
2519        };
2520        let bare = node_id
2521            .strip_prefix("fact:")
2522            .unwrap_or(&node_id)
2523            .to_string();
2524        let store = &self.bridge.store;
2525
2526        let center =
2527            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)))
2528                .map_err(|e| ErrorData::internal_error(format!("get_fact error: {e}"), None))?;
2529        let edges = tokio::task::block_in_place(|| {
2530            Handle::current().block_on(store.list_graph_edges_for_node(&node_id))
2531        })
2532        .map_err(|e| ErrorData::internal_error(format!("list edges error: {e}"), None))?;
2533
2534        let mut neighbors: Vec<serde_json::Value> = Vec::new();
2535        for e in &edges {
2536            let outgoing = e.source == node_id;
2537            let other = if outgoing { &e.target } else { &e.source };
2538            let other_bare = other.strip_prefix("fact:").unwrap_or(other).to_string();
2539            let content = tokio::task::block_in_place(|| {
2540                Handle::current().block_on(store.get_fact(&other_bare))
2541            })
2542            .ok()
2543            .flatten()
2544            .map(|f| f.content);
2545            neighbors.push(serde_json::json!({
2546                "neighbor_id": other,
2547                "direction": if outgoing { "out" } else { "in" },
2548                "edge_type": e.edge_type,
2549                "weight": e.weight,
2550                "content": content,
2551            }));
2552        }
2553        json_to_output(&serde_json::json!({
2554            "ok": true,
2555            "item_id": node_id,
2556            "center_content": center.map(|f| f.content),
2557            "neighbor_count": neighbors.len(),
2558            "neighbors": neighbors,
2559        }))
2560    }
2561
2562    #[tool(
2563        description = "Create a replacement fact and link it to a stale fact via 'supersedes' edge. Use instead of deleting outdated facts. Returns new fact id and edge id.",
2564        annotations(idempotent_hint = true)
2565    )]
2566    fn sm_supersede_fact(
2567        &self,
2568        Parameters(SupersedeFactParams {
2569            old_fact_id,
2570            content,
2571            namespace,
2572            source,
2573            reason,
2574        }): Parameters<SupersedeFactParams>,
2575    ) -> Result<Json<StructuredOutput>, ErrorData> {
2576        use semantic_memory::GraphEdgeType;
2577
2578        let old_bare = old_fact_id
2579            .strip_prefix("fact:")
2580            .unwrap_or(&old_fact_id)
2581            .to_string();
2582        let old_node = format!("fact:{old_bare}");
2583        let store = &self.bridge.store;
2584        let old =
2585            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&old_bare)))
2586                .map_err(|e| ErrorData::internal_error(format!("get old fact error: {e}"), None))?;
2587        let Some(old_fact) = old else {
2588            return Err(ErrorData::invalid_params(
2589                format!("No fact with id '{old_fact_id}'"),
2590                None,
2591            ));
2592        };
2593
2594        let ns = namespace.unwrap_or_else(|| old_fact.namespace.clone());
2595        let new_id = tokio::task::block_in_place(|| {
2596            Handle::current().block_on(store.add_fact(&ns, &content, source.as_deref(), None))
2597        })
2598        .map_err(|e| ErrorData::internal_error(format!("add replacement fact error: {e}"), None))?;
2599        let new_node = format!("fact:{new_id}");
2600        let metadata = serde_json::json!({
2601            "reason": reason.unwrap_or_else(|| "replacement fact supersedes stale fact".to_string()),
2602            "old_fact_id": old_bare,
2603        });
2604        let edge = tokio::task::block_in_place(|| {
2605            Handle::current().block_on(store.add_graph_edge(
2606                &new_node,
2607                &old_node,
2608                GraphEdgeType::Entity {
2609                    relation: "supersedes".to_string(),
2610                },
2611                1.0,
2612                Some(metadata),
2613            ))
2614        })
2615        .map_err(|e| ErrorData::internal_error(format!("add supersedes edge error: {e}"), None))?;
2616
2617        json_to_output(&serde_json::json!({
2618            "ok": true,
2619            "receipt": mcp_receipt("sm_supersede_fact"),
2620            "new_fact_id": new_id,
2621            "new_result_id": new_node,
2622            "old_fact_id": old_bare,
2623            "old_result_id": old_node,
2624            "namespace": ns,
2625            "edge_id": edge.id,
2626            "relation": "supersedes",
2627        }))
2628    }
2629
2630    // ── Conversation / session tools (v0.3.0) ────────────────────────
2631
2632    // DEPRECATED #[tool(
2633    // description = "Create a conversation session (container for messages). Returns session id. Use to persist history recallable via sm_search_conversations.",
2634    // annotations(idempotent_hint = true)
2635    // )]
2636    #[allow(dead_code)]
2637    fn sm_create_session(
2638        &self,
2639        Parameters(CreateSessionParams { channel, metadata }): Parameters<CreateSessionParams>,
2640    ) -> Result<Json<StructuredOutput>, ErrorData> {
2641        let meta: Option<serde_json::Value> = metadata
2642            .as_deref()
2643            .and_then(|s| serde_json::from_str(s).ok());
2644        let store = &self.bridge.store;
2645        let result = tokio::task::block_in_place(|| {
2646            Handle::current().block_on(store.create_session_with_metadata(&channel, meta))
2647        });
2648        match result {
2649            Ok(id) => json_to_output(
2650                &serde_json::json!({"ok": true, "session_id": id, "channel": channel, "receipt": mcp_receipt("sm_create_session")}),
2651            ),
2652            Err(e) => Err(ErrorData::internal_error(
2653                format!("create_session error: {e}"),
2654                None,
2655            )),
2656        }
2657    }
2658
2659    // DEPRECATED #[tool(
2660    // description = "Append a message to a session. role: user|assistant|system|tool. Message is embedded and FTS-indexed. Returns message id."
2661    // )]
2662    #[allow(dead_code)]
2663    fn sm_add_message(
2664        &self,
2665        Parameters(AddMessageParams {
2666            session_id,
2667            role,
2668            content,
2669        }): Parameters<AddMessageParams>,
2670    ) -> Result<Json<StructuredOutput>, ErrorData> {
2671        let parsed_role = match role.to_lowercase().as_str() {
2672            "user" => semantic_memory::types::Role::User,
2673            "assistant" => semantic_memory::types::Role::Assistant,
2674            "system" => semantic_memory::types::Role::System,
2675            "tool" => semantic_memory::types::Role::Tool,
2676            other => {
2677                return Err(ErrorData::invalid_params(
2678                    format!("invalid role '{other}' (use user|assistant|system|tool)"),
2679                    None,
2680                ))
2681            }
2682        };
2683        let store = &self.bridge.store;
2684        let result = tokio::task::block_in_place(|| {
2685            Handle::current().block_on(store.add_message_embedded(
2686                &session_id,
2687                parsed_role,
2688                &content,
2689                None,
2690                None,
2691            ))
2692        });
2693        match result {
2694            Ok(id) => json_to_output(
2695                &serde_json::json!({"ok": true, "message_id": id, "session_id": session_id, "receipt": mcp_receipt("sm_add_message")}),
2696            ),
2697            Err(e) => Err(ErrorData::internal_error(
2698                format!("add_message error: {e}"),
2699                None,
2700            )),
2701        }
2702    }
2703
2704    #[tool(
2705        description = "List recent conversation sessions (newest first) with message counts.",
2706        annotations(read_only_hint = true)
2707    )]
2708    fn sm_list_sessions(
2709        &self,
2710        Parameters(ListSessionsParams { limit, offset }): Parameters<ListSessionsParams>,
2711    ) -> Result<Json<StructuredOutput>, ErrorData> {
2712        let lim = limit.map(|v| v as usize).unwrap_or(20);
2713        let off = offset.map(|v| v as usize).unwrap_or(0);
2714        let store = &self.bridge.store;
2715        let result = tokio::task::block_in_place(|| {
2716            Handle::current().block_on(store.list_sessions(lim, off))
2717        });
2718        match result {
2719            Ok(sessions) => json_to_output(&serde_json::json!({
2720                "ok": true,
2721                "count": sessions.len(),
2722                "sessions": sessions.iter().map(|s| serde_json::json!({
2723                    "session_id": s.id,
2724                    "channel": s.channel,
2725                    "message_count": s.message_count,
2726                    "created_at": s.created_at,
2727                    "updated_at": s.updated_at,
2728                })).collect::<Vec<_>>(),
2729            })),
2730            Err(e) => Err(ErrorData::internal_error(
2731                format!("list_sessions error: {e}"),
2732                None,
2733            )),
2734        }
2735    }
2736
2737    #[tool(
2738        description = "Get most recent messages from a session within a token budget (default 4000), chronological order. Returns role, content, timestamps.",
2739        annotations(read_only_hint = true)
2740    )]
2741    fn sm_get_messages(
2742        &self,
2743        Parameters(GetMessagesParams {
2744            session_id,
2745            max_tokens,
2746        }): Parameters<GetMessagesParams>,
2747    ) -> Result<Json<StructuredOutput>, ErrorData> {
2748        let budget = max_tokens.unwrap_or(4000);
2749        let store = &self.bridge.store;
2750        let result = tokio::task::block_in_place(|| {
2751            Handle::current().block_on(store.get_messages_within_budget(&session_id, budget))
2752        });
2753        match result {
2754            Ok(msgs) => json_to_output(&serde_json::json!({
2755                "ok": true,
2756                "session_id": session_id,
2757                "count": msgs.len(),
2758                "messages": msgs.iter().map(|m| serde_json::json!({
2759                    "id": m.id,
2760                    "role": m.role,
2761                    "content": m.content,
2762                    "token_count": m.token_count,
2763                    "created_at": m.created_at,
2764                })).collect::<Vec<_>>(),
2765            })),
2766            Err(e) => Err(ErrorData::internal_error(
2767                format!("get_messages error: {e}"),
2768                None,
2769            )),
2770        }
2771    }
2772
2773    #[tool(
2774        description = "Hybrid semantic search over stored conversation MESSAGES (not facts). Recall what was discussed in past sessions. Returns ranked messages.",
2775        annotations(read_only_hint = true)
2776    )]
2777    fn sm_search_conversations(
2778        &self,
2779        Parameters(SearchConversationsParams { query, top_k }): Parameters<
2780            SearchConversationsParams,
2781        >,
2782    ) -> Result<Json<StructuredOutput>, ErrorData> {
2783        let k = top_k.map(|v| v as usize);
2784        let store = &self.bridge.store;
2785        let result = tokio::task::block_in_place(|| {
2786            Handle::current().block_on(store.search_conversations(&query, k, None))
2787        });
2788        match result {
2789            Ok(results) => json_to_output(&serde_json::json!({
2790                "ok": true,
2791                "count": results.len(),
2792                "results": results.iter().map(|r| serde_json::json!({
2793                    "result_id": r.source.result_id(),
2794                    "content": r.content,
2795                    "score": r.score,
2796                    "cosine_similarity": r.cosine_similarity,
2797                })).collect::<Vec<_>>(),
2798            })),
2799            Err(e) => Err(ErrorData::internal_error(
2800                format!("search_conversations error: {e}"),
2801                None,
2802            )),
2803        }
2804    }
2805
2806    // ── Feature-gated tools ──────────────────────────────────────────
2807    // Note: cfg gates are removed from individual tool methods because
2808    // rmcp's #[tool_router] macro needs all tools visible at expansion
2809    // time. The `full` feature in Cargo.toml already enables the
2810    // semantic-memory sub-features these tools depend on.
2811
2812    #[tool(
2813        description = "Profile a query and get an adaptive routing decision. Determines which retrieval stages (BM25, vector, rerank, graph, decoder, discord) to activate.",
2814        annotations(read_only_hint = true)
2815    )]
2816    fn sm_route_query(
2817        &self,
2818        Parameters(RouteQueryParams { query }): Parameters<RouteQueryParams>,
2819    ) -> Result<Json<StructuredOutput>, ErrorData> {
2820        use semantic_memory::rl_routing::{is_trained, route_with_policy};
2821        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2822
2823        let router = RetrievalRouter {
2824            decoder_enabled: true,
2825            discord_enabled: true,
2826            corpus_density: 0.5,
2827            ..Default::default()
2828        };
2829
2830        let profile = QueryProfile::from_query(&query);
2831        let policy = tokio::task::block_in_place(|| {
2832            Handle::current().block_on(self.bridge.store.load_routing_policy())
2833        })
2834        .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
2835        let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2836            Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2837            None => (router.route(&profile), "heuristic"),
2838        };
2839        json_to_output(&serde_json::json!({
2840            "ok": true,
2841            "routing_source": routing_source,
2842            "bm25_coarse": decision.bm25_coarse,
2843            "vector_medium": decision.vector_medium,
2844            "rerank_fine": decision.rerank_fine,
2845            "graph_expansion": decision.graph_expansion,
2846            "decoder": decision.decoder,
2847            "discord": decision.discord,
2848            "no_retrieval": decision.no_retrieval,
2849            "reasoning": decision.reasoning,
2850        }))
2851    }
2852
2853    #[tool(
2854        description = "Adaptive search: profiles query, routes to appropriate stages, applies factor graph belief propagation if decoder is activated. Returns results with stable IDs.",
2855        annotations(read_only_hint = true)
2856    )]
2857    fn sm_search_with_routing(
2858        &self,
2859        Parameters(SearchWithRoutingParams {
2860            query,
2861            top_k,
2862            contradictions,
2863            group_by_community,
2864        }): Parameters<SearchWithRoutingParams>,
2865    ) -> Result<Json<StructuredOutput>, ErrorData> {
2866        use semantic_memory::integration::plan_execution;
2867        use semantic_memory::rl_routing::{is_trained, route_with_policy};
2868        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2869
2870        let k = top_k.map(|v| v as usize).unwrap_or(5);
2871        let allow_superseded = query_allows_superseded(&query);
2872        let search_k = if allow_superseded { k } else { (k * 4).max(20) };
2873
2874        let router = RetrievalRouter {
2875            decoder_enabled: true,
2876            discord_enabled: true,
2877            corpus_density: 0.5,
2878            ..Default::default()
2879        };
2880
2881        // Select learned routing only after enough examples have been durably
2882        // persisted. A missing or still-untrained policy uses heuristics.
2883        let store = &self.bridge.store;
2884        let policy =
2885            tokio::task::block_in_place(|| Handle::current().block_on(store.load_routing_policy()))
2886                .map_err(|e| {
2887                    ErrorData::internal_error(format!("load routing policy error: {e}"), None)
2888                })?;
2889        let profile = QueryProfile::from_query(&query);
2890        let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2891            Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2892            None => (router.route(&profile), "heuristic"),
2893        };
2894        let contras = contradictions.unwrap_or_default();
2895        let plan = plan_execution(&decision, contras.clone());
2896
2897        let store = &self.bridge.store;
2898        let search_result = tokio::task::block_in_place(|| {
2899            Handle::current().block_on(store.search(&query, Some(search_k), None, None))
2900        });
2901
2902        match search_result {
2903            Ok(results) => {
2904                let superseded_targets = if allow_superseded {
2905                    HashSet::new()
2906                } else {
2907                    load_superseded_targets(store)?
2908                };
2909                let fresh_results: Vec<_> = results
2910                    .iter()
2911                    .filter(|r| !superseded_targets.contains(&r.source.result_id()))
2912                    .collect();
2913                let result_refs: Vec<_> = if superseded_targets.is_empty() {
2914                    results.iter().collect()
2915                } else {
2916                    fresh_results
2917                };
2918                let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
2919                let json_results: Vec<serde_json::Value> = result_refs
2920                    .iter()
2921                    .take(k)
2922                    .map(|r| {
2923                        serde_json::json!({
2924                            "result_id": r.source.result_id(),
2925                            "content": r.content,
2926                            "score": r.score,
2927                        })
2928                    })
2929                    .collect();
2930
2931                let mut factor_graph_payload = serde_json::json!({
2932                    "enabled": false,
2933                });
2934
2935                let mut decoder_executed = false;
2936                let mut discord_executed = false;
2937                let mut discord_results_payload: Vec<serde_json::Value> = Vec::new();
2938
2939                if decision.decoder {
2940                    #[cfg(feature = "full")]
2941                    {
2942                        use semantic_memory::factor_graph::{
2943                            factors_from_edges, FactorGraph, FactorGraphConfig,
2944                        };
2945
2946                        let graph_edges = tokio::task::block_in_place(|| {
2947                            Handle::current().block_on(store.list_all_graph_edges())
2948                        });
2949
2950                        match graph_edges {
2951                            Ok(edges) => {
2952                                let raw_edges: Vec<(
2953                                    String,
2954                                    String,
2955                                    semantic_memory::GraphEdgeType,
2956                                    f64,
2957                                    Option<String>,
2958                                )> = edges
2959                                    .iter()
2960                                    .map(|edge| {
2961                                        let parsed_type = edge
2962                                            .edge_type_parsed
2963                                            .clone()
2964                                            .or_else(|| serde_json::from_str(&edge.edge_type).ok())
2965                                            .unwrap_or(semantic_memory::GraphEdgeType::Entity {
2966                                                relation: "unknown".to_string(),
2967                                            });
2968                                        (
2969                                            edge.source.clone(),
2970                                            edge.target.clone(),
2971                                            parsed_type,
2972                                            edge.weight,
2973                                            edge.metadata.clone(),
2974                                        )
2975                                    })
2976                                    .collect();
2977
2978                                let nodes: Vec<(String, f64)> = result_refs
2979                                    .iter()
2980                                    .map(|r| (r.source.result_id(), r.score))
2981                                    .collect();
2982                                let factors = factors_from_edges(&raw_edges);
2983                                let graph =
2984                                    FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
2985                                let propagated = graph.propagate();
2986                                let top_beliefs = propagated.top_k(k);
2987
2988                                factor_graph_payload = serde_json::json!({
2989                                    "enabled": true,
2990                                    "top_k_beliefs": top_beliefs
2991                                        .into_iter()
2992                                        .map(|(item_id, belief)| serde_json::json!({
2993                                            "item_id": item_id,
2994                                            "belief": belief,
2995                                        }))
2996                                        .collect::<Vec<_>>(),
2997                                    "iterations": propagated.iterations,
2998                                    "converged": propagated.converged,
2999                                    "elapsed_ms": propagated.elapsed_ms,
3000                                    "factor_counts": {
3001                                        "semantic": propagated.factor_counts.semantic,
3002                                        "temporal": propagated.factor_counts.temporal,
3003                                        "causal": propagated.factor_counts.causal,
3004                                        "entity": propagated.factor_counts.entity,
3005                                        "total": propagated.factor_counts.total(),
3006                                    },
3007                                });
3008                                decoder_executed = true;
3009                            }
3010                            Err(e) => {
3011                                factor_graph_payload = serde_json::json!({
3012                                    "enabled": false,
3013                                    "error": format!("factor graph analysis failed: {e}"),
3014                                });
3015                            }
3016                        }
3017                    }
3018
3019                    #[cfg(not(feature = "full"))]
3020                    {
3021                        factor_graph_payload = serde_json::json!({
3022                            "enabled": false,
3023                            "reason": "factor graph analysis requires the `full` feature",
3024                        });
3025                    }
3026
3027                    if !plan.contradictions.is_empty() {
3028                        use semantic_memory::decoder::{compute_correction, detect_syndromes};
3029                        let result_scores: Vec<(String, f64)> = result_refs
3030                            .iter()
3031                            .map(|r| (r.source.result_id(), r.score))
3032                            .collect();
3033                        let syndromes = detect_syndromes(&result_scores, &plan.contradictions);
3034                        let _ = compute_correction(&syndromes, 10.0);
3035                        decoder_executed = true;
3036                    }
3037                }
3038
3039                if plan.use_discord {
3040                    use semantic_memory::discord::DiscordScorer;
3041                    let direct_ids: Vec<String> =
3042                        result_refs.iter().map(|r| r.source.result_id()).collect();
3043                    let existing_ids: std::collections::HashSet<String> =
3044                        direct_ids.iter().cloned().collect();
3045                    if let Ok(edges) = load_neighborhood_edge_refs(&self.bridge.store, &direct_ids)
3046                    {
3047                        let scorer = DiscordScorer::with_defaults();
3048                        let discord_hits = scorer.score(&direct_ids, &edges);
3049                        for hit in &discord_hits {
3050                            if !existing_ids.contains(&hit.item_id) {
3051                                discord_results_payload.push(serde_json::json!({
3052                                    "result_id": hit.item_id,
3053                                    "discord_score": hit.discord_score,
3054                                    "anchor_ids": hit.anchor_ids,
3055                                    "relationship_types": hit.relationship_types,
3056                                }));
3057                            }
3058                        }
3059                        discord_executed = true;
3060                    }
3061                }
3062
3063                let mut matryoshka_payload = serde_json::json!({
3064                    "enabled": false,
3065                });
3066                if decision.vector_medium {
3067                    #[cfg(feature = "full")]
3068                    {
3069                        use semantic_memory::integration::multi_resolution_route;
3070                        use semantic_memory::matryoshka::MatryoshkaConfig;
3071                        use semantic_memory::routing::QueryProfile;
3072
3073                        let route_profile = QueryProfile::from_query(&query);
3074                        let route_decision =
3075                            multi_resolution_route(&route_profile, &MatryoshkaConfig::default());
3076                        matryoshka_payload = serde_json::json!({
3077                            "enabled": true,
3078                            "candidate_dim": route_decision.candidate_dim,
3079                            "heuristic_recall_estimate": route_decision.estimated_recall,
3080                            "recall_basis": "heuristic_dimensional_model_not_corpus_measured",
3081                            "embedding_dim": route_decision.embedding_dim,
3082                            "reasoning": route_decision.reasoning,
3083                        });
3084                    }
3085
3086                    #[cfg(not(feature = "full"))]
3087                    {
3088                        matryoshka_payload = serde_json::json!({
3089                            "enabled": false,
3090                            "reason": "matryoshka routing requires the `full` feature",
3091                        });
3092                    }
3093                }
3094
3095                // Community grouping (opt-in).
3096                let grouped_results_payload: serde_json::Value = if group_by_community == Some(true)
3097                {
3098                    let seed_ids: Vec<String> = result_refs
3099                        .iter()
3100                        .take(k)
3101                        .map(|r| r.source.result_id())
3102                        .collect();
3103                    let edges = load_neighborhood_edge_pairs(store, &seed_ids).unwrap_or_default();
3104                    if !edges.is_empty() {
3105                        use semantic_memory::community::detect_communities;
3106                        let communities = detect_communities(&edges, 1.0, 42);
3107                        let mut member_to_comm: std::collections::HashMap<String, String> =
3108                            std::collections::HashMap::new();
3109                        for c in &communities {
3110                            for m in &c.members {
3111                                member_to_comm.insert(m.clone(), c.id.clone());
3112                            }
3113                        }
3114                        let mut groups: std::collections::HashMap<String, Vec<serde_json::Value>> =
3115                            std::collections::HashMap::new();
3116                        let mut ungrouped: Vec<serde_json::Value> = Vec::new();
3117                        for r in &json_results {
3118                            if let Some(rid) = r.get("result_id").and_then(|v| v.as_str()) {
3119                                match member_to_comm.get(rid).cloned() {
3120                                    Some(cid) => groups.entry(cid).or_default().push(r.clone()),
3121                                    None => ungrouped.push(r.clone()),
3122                                }
3123                            }
3124                        }
3125                        let mut map = serde_json::Map::new();
3126                        for (cid, items) in groups {
3127                            map.insert(format!("community_{cid}"), serde_json::json!(items));
3128                        }
3129                        if !ungrouped.is_empty() {
3130                            map.insert("ungrouped".to_string(), serde_json::json!(ungrouped));
3131                        }
3132                        serde_json::Value::Object(map)
3133                    } else {
3134                        serde_json::Value::Null
3135                    }
3136                } else {
3137                    serde_json::Value::Null
3138                };
3139
3140                // Task 7: Auto-call topology when routing returns Class D (SYNTHESIS) and >10 results.
3141                let mut topology_payload = serde_json::json!({ "auto_called": false });
3142                {
3143                    use semantic_memory::routing::{QueryComplexityClass, QueryProfile};
3144                    let route_profile = QueryProfile::from_query(&query);
3145                    if route_profile.complexity_class == QueryComplexityClass::Synthesis
3146                        && result_refs.len() > 10
3147                    {
3148                        #[cfg(feature = "full")]
3149                        {
3150                            use semantic_memory::topology::{compute_betti_numbers, find_voids};
3151                            let edges = load_stored_edge_pairs(store).unwrap_or_default();
3152                            if !edges.is_empty() {
3153                                let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3154                                    std::collections::HashMap::new();
3155                                for (src, tgt) in &edges {
3156                                    adjacency.entry(src.clone()).or_default().push(tgt.clone());
3157                                    adjacency.entry(tgt.clone()).or_default().push(src.clone());
3158                                }
3159                                let betti = compute_betti_numbers(&adjacency);
3160                                let voids = find_voids(&edges);
3161                                topology_payload = serde_json::json!({
3162                                    "auto_called": true,
3163                                    "trigger": "synthesis_class_with_10_plus_results",
3164                                    "betti_numbers": {
3165                                        "betti_0": betti.betti_0,
3166                                        "betti_1": betti.betti_1,
3167                                    },
3168                                    "void_count": voids.len(),
3169                                    "voids": voids.iter().map(|v| serde_json::json!({
3170                                        "description": v.description,
3171                                        "void_type": format!("{:?}", v.void_type),
3172                                        "nearby_items": v.nearby_items,
3173                                        "suggested_connections": v.suggested_connections,
3174                                    })).collect::<Vec<_>>(),
3175                                });
3176                            } else {
3177                                topology_payload = serde_json::json!({
3178                                    "auto_called": true,
3179                                    "trigger": "synthesis_class_with_10_plus_results",
3180                                    "note": "no graph edges in store",
3181                                });
3182                            }
3183                        }
3184                        #[cfg(not(feature = "full"))]
3185                        {
3186                            topology_payload = serde_json::json!({
3187                                "auto_called": true,
3188                                "trigger": "synthesis_class_with_10_plus_results",
3189                                "error": "topology requires the full feature",
3190                            });
3191                        }
3192                    }
3193                }
3194
3195                json_to_output(&serde_json::json!({
3196                    "ok": true,
3197                    "routing_decision": {
3198                        "source": routing_source,
3199                        "bm25_coarse": decision.bm25_coarse,
3200                        "vector_medium": decision.vector_medium,
3201                        "rerank_fine": decision.rerank_fine,
3202                        "graph_expansion": decision.graph_expansion,
3203                        "decoder": decision.decoder,
3204                        "discord": decision.discord,
3205                        "no_retrieval": decision.no_retrieval,
3206                        "reasoning": decision.reasoning,
3207                    },
3208                    "results": json_results,
3209                    "count": json_results.len(),
3210                    "superseded_filtered_count": superseded_filtered_count,
3211                    "decoder_planned": plan.use_decoder,
3212                    "decoder_executed": decoder_executed,
3213                    "discord_planned": plan.use_discord,
3214                    "discord_executed": discord_executed,
3215                    "discord_results": discord_results_payload,
3216                    "factor_graph": factor_graph_payload,
3217                    "matryoshka": matryoshka_payload,
3218                    "grouped_results": grouped_results_payload,
3219                    "topology": topology_payload,
3220                }))
3221            }
3222            Err(e) => Err(ErrorData::internal_error(
3223                format!("Search error: {e}"),
3224                None,
3225            )),
3226        }
3227    }
3228
3229    #[tool(
3230        description = "Detect contradictions in search results. Runs syndrome detection, computes corrections, and applies belief propagation to refine confidence scores.",
3231        annotations(read_only_hint = true)
3232    )]
3233    fn sm_decoder_analyze(
3234        &self,
3235        Parameters(DecoderAnalyzeParams {
3236            results,
3237            contradictions,
3238        }): Parameters<DecoderAnalyzeParams>,
3239    ) -> Result<Json<StructuredOutput>, ErrorData> {
3240        use semantic_memory::decoder::{
3241            compute_correction, detect_syndromes, pass_messages, ConflictGraph,
3242        };
3243
3244        let contras = contradictions.unwrap_or_default();
3245        let syndromes = detect_syndromes(&results, &contras);
3246        let corrections = compute_correction(&syndromes, 10.0);
3247        let graph = ConflictGraph::from_syndromes(&results, &syndromes);
3248        let mp = pass_messages(&graph, 50, 0.001);
3249
3250        json_to_output(&serde_json::json!({
3251            "ok": true,
3252            "syndromes": syndromes.iter().map(|s| serde_json::json!({
3253                "id": s.id,
3254                "severity": format!("{:?}", s.severity),
3255                "items": s.items,
3256                "description": s.description,
3257                "type": format!("{:?}", s.syndrome_type),
3258            })).collect::<Vec<_>>(),
3259            "syndrome_count": syndromes.len(),
3260            "corrections": corrections.iter().map(|c| serde_json::json!({
3261                "id": c.id,
3262                "confidence": c.confidence,
3263                "cost": c.cost,
3264                "operations": c.operations.len(),
3265            })).collect::<Vec<_>>(),
3266            "correction_count": corrections.len(),
3267            "message_passing": {
3268                "iterations": mp.iterations,
3269                "converged": mp.converged,
3270                "elapsed_ms": mp.elapsed_ms,
3271            },
3272        }))
3273    }
3274
3275    #[tool(
3276        description = "Detect contradictions among the top results for a query from their CONTENT (numeric, value, negation, or antonym disagreement) — no pre-asserted edges required. Returns candidate conflicting pairs, each with the signals that fired and a human-readable reason. Persist a confirmed pair with sm_add_graph_edge(edge_type=\"contradicts\") so the decoder/community/factor-graph tools pick it up.",
3277        annotations(read_only_hint = true)
3278    )]
3279    fn sm_detect_contradictions(
3280        &self,
3281        Parameters(DetectContradictionsParams {
3282            query,
3283            top_k,
3284            record_to_ledger,
3285        }): Parameters<DetectContradictionsParams>,
3286    ) -> Result<Json<StructuredOutput>, ErrorData> {
3287        use semantic_memory::contradiction_detect::{detect_contradictions, DetectorConfig};
3288
3289        let k = top_k.map(|v| v as usize).unwrap_or(10);
3290        let store = &self.bridge.store;
3291        let results = tokio::task::block_in_place(|| {
3292            Handle::current().block_on(store.search(&query, Some(k), None, None))
3293        })
3294        .map_err(|e| ErrorData::internal_error(format!("search failed: {e}"), None))?;
3295
3296        let items: Vec<(String, String)> = results
3297            .iter()
3298            .map(|r| (r.source.result_id(), r.content.clone()))
3299            .collect();
3300
3301        let pairs = detect_contradictions(&items, &DetectorConfig::default());
3302
3303        // T2.4/D2: Optionally record detected contradictions as real,
3304        // hash-chained claim-ledger entries (LedgerEvent::ContradictionCandidate)
3305        // and update the in-process ClaimTrustIndex so search-time trust
3306        // lookups reflect the conflict. The trust index is a lookup cache
3307        // only — the ledger entries below are the durable record.
3308        let (ledger_recorded, ledger_entries) = if record_to_ledger.unwrap_or(false) {
3309            #[cfg(feature = "claim-integration")]
3310            {
3311                use claim_ledger::{LedgerEntryBuilder, SupportState};
3312                let mut count = 0usize;
3313                let mut entries = Vec::new();
3314                for p in &pairs {
3315                    let fact_a = p.a.strip_prefix("fact:").unwrap_or(&p.a).to_string();
3316                    let fact_b = p.b.strip_prefix("fact:").unwrap_or(&p.b).to_string();
3317                    {
3318                        let mut idx = self.claim_trust.lock().unwrap();
3319                        idx.record_judgment(fact_a.clone(), SupportState::Contradicted);
3320                        idx.record_judgment(fact_b.clone(), SupportState::Contradicted);
3321                    }
3322
3323                    let pattern = p
3324                        .signals
3325                        .iter()
3326                        .map(|s| format!("{s:?}"))
3327                        .collect::<Vec<_>>()
3328                        .join(",");
3329                    let contradiction_id =
3330                        claim_ledger::ids::contradiction_id(&fact_a, &fact_b, &pattern);
3331
3332                    let mut ledger = self.claim_ledger_store.lock().unwrap();
3333                    let sequence = ledger.next_sequence();
3334                    let previous_digest = ledger.last_digest();
3335                    let append_result = LedgerEntryBuilder::new(sequence, previous_digest)
3336                        .add_contradiction_candidate(
3337                            &contradiction_id,
3338                            vec![fact_a.clone(), fact_b.clone()],
3339                            &pattern,
3340                            &p.reason,
3341                        )
3342                        .map_err(|e| e.to_string())
3343                        .and_then(|entry| ledger.append(entry));
3344                    drop(ledger);
3345
3346                    match append_result {
3347                        Ok(entry_hash) => {
3348                            count += 1;
3349                            entries.push(serde_json::json!({
3350                                "contradiction_id": contradiction_id,
3351                                "claim_refs": [fact_a, fact_b],
3352                                "sequence": sequence,
3353                                "entry_hash": entry_hash,
3354                            }));
3355                        }
3356                        Err(e) => {
3357                            return Err(ErrorData::internal_error(
3358                                format!("failed to record contradiction to ledger: {e}"),
3359                                None,
3360                            ));
3361                        }
3362                    }
3363                }
3364                (count, entries)
3365            }
3366            #[cfg(not(feature = "claim-integration"))]
3367            {
3368                (0, Vec::new())
3369            }
3370        } else {
3371            (0, Vec::new())
3372        };
3373
3374        json_to_output(&serde_json::json!({
3375            "ok": true,
3376            "query": query,
3377            "items_scanned": items.len(),
3378            "contradictions": pairs.iter().map(|p| serde_json::json!({
3379                "a": p.a,
3380                "b": p.b,
3381                "score": p.score,
3382                "signals": p.signals.iter().map(|s| format!("{s:?}")).collect::<Vec<_>>(),
3383                "reason": p.reason,
3384            })).collect::<Vec<_>>(),
3385            "count": pairs.len(),
3386            "ledger_recorded": ledger_recorded,
3387            "ledger_entries": ledger_entries,
3388            "receipt": mcp_receipt("sm_detect_contradictions"),
3389        }))
3390    }
3391
3392    #[tool(
3393        description = "Second-order retrieval: find items related to your search results through the graph, but NOT themselves direct hits. Loads edges from store automatically.",
3394        annotations(read_only_hint = true)
3395    )]
3396    fn sm_discord_search(
3397        &self,
3398        Parameters(DiscordSearchParams { direct_result_ids }): Parameters<DiscordSearchParams>,
3399    ) -> Result<Json<StructuredOutput>, ErrorData> {
3400        use semantic_memory::discord::DiscordScorer;
3401
3402        // Use neighborhood loading: only load edges within 2 hops of the
3403        // direct result IDs instead of the entire graph.
3404        let edges = load_neighborhood_edge_refs(&self.bridge.store, &direct_result_ids)?;
3405        let scorer = DiscordScorer::with_defaults();
3406        let results = scorer.score(&direct_result_ids, &edges);
3407
3408        json_to_output(&serde_json::json!({
3409            "ok": true,
3410            "discord_results": results.iter().map(|r| serde_json::json!({
3411                "item_id": r.item_id,
3412                "discord_score": r.discord_score,
3413                "anchor_ids": r.anchor_ids,
3414                "relationship_types": r.relationship_types,
3415            })).collect::<Vec<_>>(),
3416            "count": results.len(),
3417            "edges_loaded": edges.len(),
3418            "edges_scope": "neighborhood",
3419        }))
3420    }
3421
3422    #[tool(
3423        description = "Set provenance (evidence confidence) for an item. Confidence in [0.0, 1.0] with support count. Returns a provenance receipt.",
3424        annotations(idempotent_hint = true)
3425    )]
3426    fn sm_set_provenance(
3427        &self,
3428        Parameters(SetProvenanceParams {
3429            item_id,
3430            confidence,
3431            support_count,
3432        }): Parameters<SetProvenanceParams>,
3433    ) -> Result<Json<StructuredOutput>, ErrorData> {
3434        use semantic_memory::provenance::{
3435            ConfidenceSemiring, ConfidenceValue, ProvenanceItemType,
3436        };
3437
3438        // SM-AUD-015: Validate confidence is finite and in [0, 1].
3439        if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
3440            return Err(ErrorData::invalid_params(
3441                format!("confidence must be a finite value in [0.0, 1.0], got {confidence}"),
3442                None,
3443            ));
3444        }
3445
3446        let value = ConfidenceValue::new(confidence, support_count);
3447        let store = &self.bridge.store;
3448
3449        let result = tokio::task::block_in_place(|| {
3450            Handle::current().block_on(store.set_provenance::<ConfidenceSemiring>(
3451                &ProvenanceItemType::Fact,
3452                &item_id,
3453                &value,
3454                &[],
3455                None,
3456            ))
3457        });
3458
3459        match result {
3460            Ok(receipt) => json_to_output(&serde_json::json!({
3461                "ok": true,
3462                "provenance_id": receipt.provenance_id,
3463                "item_id": receipt.item_id,
3464                "semiring_type": receipt.semiring_type,
3465                "recorded_at": receipt.recorded_at,
3466                "message": "Provenance set successfully",
3467            })),
3468            Err(e) => Err(ErrorData::internal_error(
3469                format!("Provenance error: {e}"),
3470                None,
3471            )),
3472        }
3473    }
3474
3475    #[tool(
3476        description = "Run a memory lifecycle pass: analyze items for syndromes, compute corrections, identify subtraction candidates, and check compression needs.",
3477        annotations(read_only_hint = true)
3478    )]
3479    fn sm_run_lifecycle(
3480        &self,
3481        Parameters(RunLifecycleParams { item_ids }): Parameters<RunLifecycleParams>,
3482    ) -> Result<Json<StructuredOutput>, ErrorData> {
3483        use semantic_memory::decoder::{compute_correction, detect_syndromes};
3484        use semantic_memory::integration::{
3485            corrections_to_subtraction_candidates, should_trigger_recompression,
3486        };
3487
3488        let results: Vec<(String, f64)> = item_ids.iter().map(|id| (id.clone(), 0.5)).collect();
3489        let syndromes = detect_syndromes(&results, &[]);
3490        let corrections = compute_correction(&syndromes, 10.0);
3491
3492        let sub_candidates = corrections_to_subtraction_candidates(&corrections);
3493
3494        let subtracted_count = sub_candidates.len();
3495        let remaining_count = item_ids.len().saturating_sub(subtracted_count);
3496        let recompression = should_trigger_recompression(subtracted_count, remaining_count, false);
3497
3498        let store = &self.bridge.store;
3499        let graph_edges = tokio::task::block_in_place(|| {
3500            Handle::current().block_on(store.list_all_graph_edges())
3501        });
3502        let stored_edges: Vec<(String, String)> = graph_edges
3503            .as_ref()
3504            .map(|edges| {
3505                edges
3506                    .iter()
3507                    .map(|edge| (edge.source.clone(), edge.target.clone()))
3508                    .collect()
3509            })
3510            .unwrap_or_default();
3511
3512        let mut topology_voids: Vec<serde_json::Value> = Vec::new();
3513        let mut betti = serde_json::json!({
3514            "betti_0": 0usize,
3515            "betti_1": 0usize,
3516        });
3517        #[allow(unused_mut)]
3518        let mut topology_error: Option<String> = None;
3519
3520        let mut communities: Vec<serde_json::Value> = Vec::new();
3521        let mut community_contradictions: Vec<serde_json::Value> = Vec::new();
3522        #[allow(unused_mut)]
3523        let mut community_error: Option<String> = None;
3524
3525        let mut subgraph_assessment = serde_json::json!({
3526            "subgraphs_identified": 0usize,
3527            "subgraphs_pruned": 0usize,
3528        });
3529        #[allow(unused_mut)]
3530        let mut subgraph_error: Option<String> = None;
3531
3532        #[cfg(feature = "full")]
3533        {
3534            use std::collections::HashMap;
3535
3536            if !stored_edges.is_empty() {
3537                let analysis_edges = stored_edges.clone();
3538
3539                use semantic_memory::topology::{compute_betti_numbers, find_voids};
3540
3541                let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
3542                for (left, right) in &analysis_edges {
3543                    adjacency
3544                        .entry(left.clone())
3545                        .or_default()
3546                        .push(right.clone());
3547                    adjacency
3548                        .entry(right.clone())
3549                        .or_default()
3550                        .push(left.clone());
3551                }
3552
3553                let betti_numbers = compute_betti_numbers(&adjacency);
3554                betti = serde_json::json!({
3555                    "betti_0": betti_numbers.betti_0,
3556                    "betti_1": betti_numbers.betti_1,
3557                });
3558
3559                topology_voids = find_voids(&analysis_edges)
3560                    .into_iter()
3561                    .map(|v| {
3562                        serde_json::json!({
3563                            "description": v.description,
3564                            "void_type": format!("{:?}", v.void_type),
3565                            "nearby_items": v.nearby_items,
3566                            "suggested_connections": v.suggested_connections,
3567                        })
3568                    })
3569                    .collect();
3570
3571                use semantic_memory::community::{
3572                    community_contradiction_scan, detect_communities,
3573                };
3574
3575                let detected = detect_communities(&analysis_edges, 1.0, 42);
3576                communities = detected
3577                    .iter()
3578                    .map(|c| {
3579                        serde_json::json!({
3580                            "id": c.id,
3581                            "members": c.members,
3582                            "level": c.level,
3583                            "parent": c.parent,
3584                            "member_count": c.members.len(),
3585                        })
3586                    })
3587                    .collect();
3588
3589                community_contradictions = community_contradiction_scan(&detected, &[])
3590                    .into_iter()
3591                    .map(|cc| {
3592                        serde_json::json!({
3593                            "community_id": cc.community_id,
3594                            "item_a": cc.item_a,
3595                            "item_b": cc.item_b,
3596                            "description": cc.description,
3597                        })
3598                    })
3599                    .collect();
3600
3601                use semantic_memory::integration::autonomous_subgraph_maintenance;
3602                use semantic_memory::subgraph_pruning::AccessLog;
3603                use std::collections::HashSet;
3604
3605                let mut access_items: HashSet<String> = HashSet::new();
3606                for (left, right) in &analysis_edges {
3607                    access_items.insert(left.clone());
3608                    access_items.insert(right.clone());
3609                }
3610
3611                let access_logs = access_items
3612                    .into_iter()
3613                    .map(|item| AccessLog {
3614                        item_id: item,
3615                        access_count: 1,
3616                        last_accessed: "1970-01-01T00:00:00Z".to_string(),
3617                    })
3618                    .collect::<Vec<_>>();
3619
3620                let report = autonomous_subgraph_maintenance(&analysis_edges, &access_logs, &[], 0);
3621                subgraph_assessment = serde_json::json!({
3622                    "subgraphs_identified": report.subgraphs_identified,
3623                    "subgraphs_pruned": report.subgraphs_pruned,
3624                    "summary": report.summary,
3625                });
3626            }
3627        }
3628
3629        #[cfg(not(feature = "full"))]
3630        {
3631            if !stored_edges.is_empty() {
3632                topology_error = Some(
3633                    "topology/community/subgraph phases require the `full` feature".to_string(),
3634                );
3635                community_error = Some(
3636                    "topology/community/subgraph phases require the `full` feature".to_string(),
3637                );
3638                subgraph_error = Some(
3639                    "topology/community/subgraph phases require the `full` feature".to_string(),
3640                );
3641            }
3642        }
3643
3644        #[cfg(feature = "full")]
3645        let (f32_count, compressed_count) =
3646            item_ids
3647                .iter()
3648                .fold((0usize, 0usize), |(f32_count, compressed_count), _| {
3649                    use semantic_memory::compression_governor::{
3650                        decide_quantization, QuantizationLevel,
3651                    };
3652
3653                    match decide_quantization(0.5) {
3654                        QuantizationLevel::F32 => (f32_count + 1, compressed_count),
3655                        _ => (f32_count, compressed_count + 1),
3656                    }
3657                });
3658        #[cfg(not(feature = "full"))]
3659        let (f32_count, compressed_count) = (0usize, 0usize);
3660
3661        json_to_output(&serde_json::json!({
3662            "ok": true,
3663            "items_analyzed": item_ids.len(),
3664            "syndromes_detected": syndromes.len(),
3665            "corrections_computed": corrections.len(),
3666            "subtraction_candidates": sub_candidates.iter().map(|c| serde_json::json!({
3667                "item_id": c.item_id,
3668                "structuring_score": c.structuring_score,
3669                "operation_type": c.operation_type,
3670                "reason": c.reason,
3671            })).collect::<Vec<_>>(),
3672            "recompression_triggered": recompression.triggered,
3673            "recompression_reason": recompression.reason,
3674            "topology": {
3675                "enabled": !stored_edges.is_empty(),
3676                "voids": topology_voids,
3677                "void_count": topology_voids.len(),
3678                "betti_numbers": betti,
3679                "error": topology_error,
3680            },
3681            "community_detection": {
3682                "enabled": !stored_edges.is_empty(),
3683                "communities": communities,
3684                "community_count": communities.len(),
3685                "contradictions": community_contradictions,
3686                "contradiction_count": community_contradictions.len(),
3687                "error": community_error,
3688            },
3689            "subgraph_pruning_assessment": {
3690                "enabled": !stored_edges.is_empty(),
3691                "subgraph_count": subgraph_assessment["subgraphs_identified"].as_u64().unwrap_or(0),
3692                "pruned_count": subgraph_assessment["subgraphs_pruned"].as_u64().unwrap_or(0),
3693                "summary": subgraph_assessment["summary"].as_str().unwrap_or(""),
3694                "error": subgraph_error,
3695            },
3696            "turbo_quantization_assessment": {
3697                "items_assessed": item_ids.len(),
3698                "would_retain_f32": f32_count,
3699                "would_compress": compressed_count,
3700            },
3701            "summary": format!(
3702                "Analyzed {} items: {} syndromes, {} corrections, {} subtraction candidates, recompression: {}",
3703                item_ids.len(), syndromes.len(), corrections.len(), sub_candidates.len(),
3704                if recompression.triggered { "needed" } else { "not needed" }
3705            ),
3706        }))
3707    }
3708
3709    // ── First-class graph edge tools ───────────────────────────────
3710
3711    #[tool(
3712        description = "Add a durable, typed graph edge between two nodes. Edge types: semantic, temporal, causal, entity. Idempotent — same edge returns existing ID.",
3713        annotations(idempotent_hint = true)
3714    )]
3715    fn sm_add_graph_edge(
3716        &self,
3717        Parameters(params): Parameters<AddGraphEdgeParams>,
3718    ) -> Result<Json<StructuredOutput>, ErrorData> {
3719        use semantic_memory::GraphEdgeType;
3720
3721        // SM-AUD-015: Validate numeric params are finite and in range.
3722        if let Some(cs) = params.cosine_similarity {
3723            if !cs.is_finite() || !(0.0..=1.0).contains(&cs) {
3724                return Err(ErrorData::invalid_params(
3725                    format!("cosine_similarity must be finite and in [0.0, 1.0], got {cs}"),
3726                    None,
3727                ));
3728            }
3729        }
3730        if let Some(conf) = params.confidence {
3731            if !conf.is_finite() || !(0.0..=1.0).contains(&conf) {
3732                return Err(ErrorData::invalid_params(
3733                    format!("confidence must be finite and in [0.0, 1.0], got {conf}"),
3734                    None,
3735                ));
3736            }
3737        }
3738
3739        let edge_type = match params.edge_type {
3740            EdgeType::Semantic => GraphEdgeType::Semantic {
3741                cosine_similarity: params.cosine_similarity.unwrap_or(0.5),
3742            },
3743            EdgeType::Temporal => GraphEdgeType::Temporal {
3744                delta_secs: params.delta_secs.unwrap_or(0),
3745            },
3746            EdgeType::Causal => GraphEdgeType::Causal {
3747                confidence: params.confidence.unwrap_or(0.5),
3748                evidence_ids: params.evidence_ids.unwrap_or_default(),
3749            },
3750            EdgeType::Entity => GraphEdgeType::Entity {
3751                relation: params.relation.unwrap_or_else(|| "related".to_string()),
3752            },
3753        };
3754
3755        // MCP-004: Reject malformed metadata JSON instead of silently dropping it.
3756        let metadata = match params.metadata.as_deref() {
3757            None => None,
3758            Some(s) => match serde_json::from_str::<serde_json::Value>(s) {
3759                Ok(v) => Some(v),
3760                Err(e) => {
3761                    return Err(ErrorData::invalid_params(
3762                        format!("metadata is not valid JSON: {e}"),
3763                        None,
3764                    ))
3765                }
3766            },
3767        };
3768
3769        let store = &self.bridge.store;
3770        let result = tokio::task::block_in_place(|| {
3771            Handle::current().block_on(store.add_graph_edge(
3772                &params.source,
3773                &params.target,
3774                edge_type,
3775                params.weight,
3776                metadata,
3777            ))
3778        });
3779
3780        match result {
3781            Ok(edge) => json_to_output(&serde_json::json!({
3782                "ok": true,
3783                "receipt": mcp_receipt("sm_add_graph_edge"),
3784                "id": edge.id,
3785                "source": edge.source,
3786                "target": edge.target,
3787                "edge_type": edge.edge_type,
3788                "weight": edge.weight,
3789                "content_digest": edge.content_digest,
3790                "recorded_at": edge.recorded_at,
3791                "message": "Graph edge added successfully",
3792            })),
3793            Err(e) => Err(ErrorData::internal_error(
3794                format!("Error adding graph edge: {e}"),
3795                None,
3796            )),
3797        }
3798    }
3799
3800    #[tool(
3801        description = "List graph edges for a specific node (as source or target), or all edges if no node_id. Returns non-invalidated edges only.",
3802        annotations(read_only_hint = true)
3803    )]
3804    fn sm_list_graph_edges(
3805        &self,
3806        Parameters(ListGraphEdgesParams { node_id }): Parameters<ListGraphEdgesParams>,
3807    ) -> Result<Json<StructuredOutput>, ErrorData> {
3808        let store = &self.bridge.store;
3809        let result = match node_id {
3810            Some(id) => tokio::task::block_in_place(|| {
3811                Handle::current().block_on(store.list_graph_edges_for_node(&id))
3812            }),
3813            None => tokio::task::block_in_place(|| {
3814                Handle::current().block_on(store.list_all_graph_edges())
3815            }),
3816        };
3817
3818        match result {
3819            Ok(edges) => json_to_output(&serde_json::json!({
3820                "ok": true,
3821                "edges": edges.iter().map(|e| serde_json::json!({
3822                    "id": e.id,
3823                    "source": e.source,
3824                    "target": e.target,
3825                    "edge_type": e.edge_type,
3826                    "weight": e.weight,
3827                    "metadata": e.metadata,
3828                    "recorded_at": e.recorded_at,
3829                })).collect::<Vec<_>>(),
3830                "count": edges.len(),
3831            })),
3832            Err(e) => Err(ErrorData::internal_error(
3833                format!("Error listing graph edges: {e}"),
3834                None,
3835            )),
3836        }
3837    }
3838
3839    #[tool(
3840        description = "Invalidate a stored graph edge by ID. Append-only — edge is never deleted, only marked invalidated with a reason.",
3841        annotations(idempotent_hint = true)
3842    )]
3843    fn sm_invalidate_graph_edge(
3844        &self,
3845        Parameters(InvalidateGraphEdgeParams { edge_id, reason }): Parameters<
3846            InvalidateGraphEdgeParams,
3847        >,
3848    ) -> Result<Json<StructuredOutput>, ErrorData> {
3849        let store = &self.bridge.store;
3850        let result = tokio::task::block_in_place(|| {
3851            Handle::current().block_on(store.invalidate_graph_edge(&edge_id, &reason))
3852        });
3853
3854        match result {
3855            Ok(()) => json_to_output(&serde_json::json!({
3856                "ok": true,
3857                "receipt": mcp_receipt("sm_invalidate_graph_edge"),
3858                "edge_id": edge_id,
3859                "message": "Edge invalidated successfully",
3860            })),
3861            Err(e) => Err(ErrorData::internal_error(
3862                format!("Error invalidating edge: {e}"),
3863                None,
3864            )),
3865        }
3866    }
3867
3868    // ── Factor graph, topology, and community tools ─────────────────
3869
3870    #[tool(
3871        description = "Run factor graph belief propagation on stored graph edges. Models all 4 edge types as factors. Returns unified confidence scores after convergence.",
3872        annotations(read_only_hint = true)
3873    )]
3874    fn sm_factor_graph(
3875        &self,
3876        Parameters(params): Parameters<FactorGraphParams>,
3877    ) -> Result<Json<StructuredOutput>, ErrorData> {
3878        use semantic_memory::factor_graph::{factors_from_edges, FactorGraph, FactorGraphConfig};
3879
3880        let defaults = FactorGraphConfig::default();
3881        let config = FactorGraphConfig {
3882            semantic_weight: params.semantic_weight.unwrap_or(defaults.semantic_weight),
3883            temporal_weight: params.temporal_weight.unwrap_or(defaults.temporal_weight),
3884            causal_weight: params.causal_weight.unwrap_or(defaults.causal_weight),
3885            entity_weight: params.entity_weight.unwrap_or(defaults.entity_weight),
3886            self_influence: params.self_influence.unwrap_or(defaults.self_influence),
3887            max_iterations: params
3888                .max_iterations
3889                .map(|v| v as usize)
3890                .unwrap_or(defaults.max_iterations),
3891            convergence_threshold: params
3892                .convergence_threshold
3893                .unwrap_or(defaults.convergence_threshold),
3894        };
3895
3896        // Use neighborhood loading: only load edges within 2 hops of the
3897        // node seeds instead of the entire graph.
3898        let seed_ids: Vec<String> = params.nodes.iter().map(|n| n.item_id.clone()).collect();
3899        let raw_edges = load_neighborhood_factor_edges(&self.bridge.store, &seed_ids)?;
3900        let factors = factors_from_edges(&raw_edges);
3901
3902        let nodes: Vec<(String, f64)> = params
3903            .nodes
3904            .iter()
3905            .map(|n| (n.item_id.clone(), n.initial_belief))
3906            .collect();
3907
3908        let graph = FactorGraph::new(&nodes, factors, config);
3909        let result = graph.propagate();
3910
3911        json_to_output(&serde_json::json!({
3912            "ok": true,
3913            "node_beliefs": result.node_beliefs,
3914            "iterations": result.iterations,
3915            "converged": result.converged,
3916            "elapsed_ms": result.elapsed_ms,
3917            "edges_loaded": raw_edges.len(),
3918            "edges_scope": "neighborhood",
3919            "factor_counts": {
3920                "semantic": result.factor_counts.semantic,
3921                "temporal": result.factor_counts.temporal,
3922                "causal": result.factor_counts.causal,
3923                "entity": result.factor_counts.entity,
3924                "total": result.factor_counts.total(),
3925            },
3926            "config": {
3927                "semantic_weight": result.config.semantic_weight,
3928                "temporal_weight": result.config.temporal_weight,
3929                "causal_weight": result.config.causal_weight,
3930                "entity_weight": result.config.entity_weight,
3931                "self_influence": result.config.self_influence,
3932                "max_iterations": result.config.max_iterations,
3933                "convergence_threshold": result.config.convergence_threshold,
3934            },
3935        }))
3936    }
3937
3938    #[tool(
3939        description = "Find topological voids in the knowledge graph. Computes Betti numbers (components and cycles) and detects structural gaps. Loads edges from store.",
3940        annotations(read_only_hint = true)
3941    )]
3942    fn sm_topology(
3943        &self,
3944        Parameters(_params): Parameters<TopologyParams>,
3945    ) -> Result<Json<StructuredOutput>, ErrorData> {
3946        use semantic_memory::topology::{compute_betti_numbers, find_voids, gap_report};
3947
3948        // MCP-001: Load edges from the store, not from caller-supplied params.
3949        let edges = load_stored_edge_pairs(&self.bridge.store)?;
3950
3951        let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3952            std::collections::HashMap::new();
3953        for (src, tgt) in &edges {
3954            adjacency.entry(src.clone()).or_default().push(tgt.clone());
3955            adjacency.entry(tgt.clone()).or_default().push(src.clone());
3956        }
3957
3958        let betti = compute_betti_numbers(&adjacency);
3959        let voids = find_voids(&edges);
3960        let report = gap_report(&voids);
3961
3962        json_to_output(&serde_json::json!({
3963            "ok": true,
3964            "betti_numbers": {
3965                "betti_0": betti.betti_0,
3966                "betti_1": betti.betti_1,
3967            },
3968            "voids": voids.iter().map(|v| serde_json::json!({
3969                "description": v.description,
3970                "nearby_items": v.nearby_items,
3971                "suggested_connections": v.suggested_connections,
3972                "void_type": format!("{:?}", v.void_type),
3973            })).collect::<Vec<_>>(),
3974            "void_count": voids.len(),
3975            "edges_loaded_from_store": edges.len(),
3976            "report": report,
3977        }))
3978    }
3979
3980    #[tool(
3981        description = "Detect communities in the knowledge graph (Leiden-inspired). Returns community assignments, optional contradiction scans, and compression recommendations.",
3982        annotations(read_only_hint = true)
3983    )]
3984    fn sm_community(
3985        &self,
3986        Parameters(params): Parameters<CommunityParams>,
3987    ) -> Result<Json<StructuredOutput>, ErrorData> {
3988        use semantic_memory::community::{
3989            community_aware_compression, community_contradiction_scan, detect_communities,
3990        };
3991
3992        // MCP-001: Load edges from the store, not from caller-supplied params.
3993        let edges = load_stored_edge_pairs(&self.bridge.store)?;
3994
3995        let resolution = params.resolution.unwrap_or(1.0);
3996        let seed = params.seed.unwrap_or(42);
3997
3998        let communities = detect_communities(&edges, resolution, seed);
3999
4000        let contradictions = params.contradictions.unwrap_or_default();
4001        let community_contras = community_contradiction_scan(&communities, &contradictions);
4002
4003        let importance_scores = params.importance_scores.unwrap_or_default();
4004        let compression = community_aware_compression(&communities, &importance_scores);
4005
4006        let summarize = params.summarize.unwrap_or(false);
4007        let store = &self.bridge.store;
4008        let communities_json: Vec<serde_json::Value> = communities
4009            .iter()
4010            .map(|c| {
4011                let summary: Option<String> = if summarize && !c.members.is_empty() {
4012                    let member_texts: Vec<String> = c
4013                        .members
4014                        .iter()
4015                        .filter_map(|mid| {
4016                            let bare = mid.strip_prefix("fact:").unwrap_or(mid);
4017                            tokio::task::block_in_place(|| {
4018                                Handle::current().block_on(store.get_fact(bare))
4019                            })
4020                            .ok()
4021                            .flatten()
4022                            .map(|f| f.content)
4023                        })
4024                        .collect();
4025                    if !member_texts.is_empty() {
4026                        let combined = member_texts.join("\n---\n");
4027                        let prompt = format!(
4028                            "Summarize these related facts in 1-2 sentences:\n{combined}\nSummary:"
4029                        );
4030                        let body = serde_json::json!({
4031                            "model": "granite4.1:3b",
4032                            "prompt": prompt,
4033                            "stream": false,
4034                            "options": {"temperature": 0, "num_predict": 100}
4035                        });
4036                        reqwest::blocking::Client::new()
4037                            .post("http://127.0.0.1:11434/api/generate")
4038                            .json(&body)
4039                            .send()
4040                            .ok()
4041                            .and_then(|resp| resp.json::<serde_json::Value>().ok())
4042                            .and_then(|v| {
4043                                v.get("response")
4044                                    .and_then(|r| r.as_str())
4045                                    .map(|s| s.trim().to_string())
4046                            })
4047                    } else {
4048                        None
4049                    }
4050                } else {
4051                    None
4052                };
4053                serde_json::json!({
4054                    "id": c.id,
4055                    "members": c.members,
4056                    "level": c.level,
4057                    "parent": c.parent,
4058                    "member_count": c.members.len(),
4059                    "summary": summary,
4060                })
4061            })
4062            .collect();
4063
4064        json_to_output(&serde_json::json!({
4065            "ok": true,
4066            "communities": communities_json,
4067            "community_count": communities.len(),
4068            "contradictions": community_contras.iter().map(|cc| serde_json::json!({
4069                "community_id": cc.community_id,
4070                "item_a": cc.item_a,
4071                "item_b": cc.item_b,
4072                "description": cc.description,
4073            })).collect::<Vec<_>>(),
4074            "contradiction_count": community_contras.len(),
4075            "compression_recommendations": compression.iter().map(|cr| serde_json::json!({
4076                "community_id": cr.community_id,
4077                "quantization_level": cr.quantization_level,
4078                "reason": cr.reason,
4079            })).collect::<Vec<_>>(),
4080            "compression_count": compression.len(),
4081            "edges_loaded_from_store": edges.len(),
4082        }))
4083    }
4084
4085    // ── Delete / forget tools (admin-ops) ────────────────────────────
4086    // Governed forgetting. Corrected facts should still use supersession; this
4087    // tool closes the selected fact and all derived access paths while retaining
4088    // a content-free tombstone receipt.
4089
4090    #[tool(
4091        description = "Identify and optionally prune reasoning subgraphs in the knowledge graph. Runs autonomous subgraph maintenance: identifies connected subgraphs, ranks by access frequency (least-accessed first), and optionally prunes. Dry-run by default. Returns identified subgraphs, pruning priority, and pruning receipts.",
4092        annotations(read_only_hint = true)
4093    )]
4094    fn sm_subgraph_prune(
4095        &self,
4096        Parameters(SubgraphPruneParams { dry_run, max_prune }): Parameters<SubgraphPruneParams>,
4097    ) -> Result<Json<StructuredOutput>, ErrorData> {
4098        #[cfg(feature = "subgraph-pruning")]
4099        {
4100            use semantic_memory::integration::autonomous_subgraph_maintenance;
4101            use semantic_memory::subgraph_pruning::AccessLog;
4102
4103            let dry = dry_run.unwrap_or(true);
4104            let max = max_prune.map(|v| v as usize).unwrap_or(5);
4105
4106            // Load edges from store
4107            let edges = load_stored_edge_pairs(&self.bridge.store)?;
4108
4109            // No access logs available — derive empty (all subgraphs treated as equally stale)
4110            let access_logs: Vec<AccessLog> = Vec::new();
4111
4112            // Load contradictions from contradiction graph edges
4113            let raw_edges = tokio::task::block_in_place(|| {
4114                Handle::current().block_on(self.bridge.store.list_all_graph_edges())
4115            })
4116            .map_err(|e| ErrorData::internal_error(format!("load edges failed: {e}"), None))?;
4117            let contradictions: Vec<(String, String)> = raw_edges
4118                .iter()
4119                .filter_map(|e| {
4120                    let parsed = e
4121                        .edge_type_parsed
4122                        .clone()
4123                        .or_else(|| serde_json::from_str(&e.edge_type).ok());
4124                    match parsed {
4125                        Some(semantic_memory::GraphEdgeType::Entity { relation })
4126                            if relation == "contradicts" =>
4127                        {
4128                            Some((e.source.clone(), e.target.clone()))
4129                        }
4130                        _ => None,
4131                    }
4132                })
4133                .collect();
4134
4135            let prune_count = if dry { 0 } else { max };
4136            let report =
4137                autonomous_subgraph_maintenance(&edges, &access_logs, &contradictions, prune_count);
4138
4139            json_to_output(&serde_json::json!({
4140                "ok": true,
4141                "dry_run": dry,
4142                "subgraphs_identified": report.subgraphs_identified,
4143                "subgraphs_pruned": report.subgraphs_pruned,
4144                "receipts": report.receipts.iter().map(|r| serde_json::json!({
4145                    "subgraph_root": r.subgraph_root,
4146                    "pruned_nodes": r.pruned_nodes,
4147                })).collect::<Vec<_>>(),
4148                "summary": report.summary,
4149                "receipt_field": mcp_receipt("sm_subgraph_prune"),
4150            }))
4151        }
4152        #[cfg(not(feature = "subgraph-pruning"))]
4153        {
4154            let _ = (dry_run, max_prune);
4155            json_to_output(&serde_json::json!({
4156                "ok": true,
4157                "note": "subgraph-pruning feature not enabled",
4158                "receipt": mcp_receipt("sm_subgraph_prune"),
4159            }))
4160        }
4161    }
4162
4163    #[tool(
4164        description = "Forget a single fact by id through the governed dependency-closure path. Scrubs canonical content and derived FTS/vector/graph/cache/export/replay surfaces while retaining a content-free tombstone receipt. Prefer sm_supersede_fact for corrections.",
4165        annotations(destructive_hint = true)
4166    )]
4167    fn sm_delete_fact(
4168        &self,
4169        Parameters(DeleteFactParams { fact_id }): Parameters<DeleteFactParams>,
4170    ) -> Result<Json<StructuredOutput>, ErrorData> {
4171        let bare = fact_id
4172            .strip_prefix("fact:")
4173            .unwrap_or(&fact_id)
4174            .to_string();
4175        let store = &self.bridge.store;
4176        let fact = tokio::task::block_in_place(|| {
4177            Handle::current().block_on(store.get_fact_raw_compat(&bare))
4178        })
4179        .map_err(|e| ErrorData::internal_error(format!("delete_fact lookup error: {e}"), None))?
4180        .ok_or_else(|| ErrorData::invalid_params(format!("fact not found: fact:{bare}"), None))?;
4181        let origin_record = tokio::task::block_in_place(|| {
4182            Handle::current().block_on(store.authority().get_origin_authority(&bare))
4183        })
4184        .map_err(|e| {
4185            ErrorData::internal_error(format!("delete_fact origin lookup error: {e}"), None)
4186        })?
4187        .ok_or_else(|| {
4188            ErrorData::invalid_params(
4189                format!("fact has no governed origin authority: fact:{bare}"),
4190                None,
4191            )
4192        })?;
4193        let resource_principal = origin_record.label.origin_principal;
4194        let permit = semantic_memory::AuthorityPermit::operator_system(
4195            resource_principal.clone(),
4196            "caller:sm_delete_fact",
4197            semantic_memory::AuthorityPermit::FORGET_CAPABILITY,
4198        )
4199        .with_origin(semantic_memory::OriginAuthorityLabelV1::operator_system(
4200            &resource_principal,
4201            "caller:sm_delete_fact",
4202        ));
4203        let request = semantic_memory::ForgettingClosureRequestV1::new(
4204            vec![bare.clone()],
4205            fact.namespace,
4206            "explicit MCP fact-forgetting request",
4207            4096,
4208        );
4209        let result = tokio::task::block_in_place(|| {
4210            Handle::current().block_on(store.authority().forget(
4211                permit,
4212                format!("mcp-sm-delete-fact:{}", uuid::Uuid::new_v4()),
4213                request,
4214            ))
4215        });
4216        match result {
4217            Ok(receipt) => json_to_output(&serde_json::json!({
4218                "ok": true,
4219                "deleted": false,
4220                "forgotten": true,
4221                "fact_id": format!("fact:{bare}"),
4222                "forgetting_receipt": receipt,
4223                "message": "Fact forgotten through governed dependency closure",
4224            })),
4225            Err(e) => Err(ErrorData::internal_error(
4226                format!("delete_fact forgetting error: {e}"),
4227                None,
4228            )),
4229        }
4230    }
4231
4232    #[tool(
4233        description = "Permanently delete ALL memory in a namespace — facts, documents, chunks, sessions/messages. HARD delete, irreversible. Returns per-surface deletion count.",
4234        annotations(destructive_hint = true)
4235    )]
4236    fn sm_delete_namespace(
4237        &self,
4238        Parameters(DeleteNamespaceParams { namespace }): Parameters<DeleteNamespaceParams>,
4239    ) -> Result<Json<StructuredOutput>, ErrorData> {
4240        let store = &self.bridge.store;
4241        let result = tokio::task::block_in_place(|| {
4242            Handle::current().block_on(store.delete_namespace(&namespace))
4243        });
4244        match result {
4245            Ok(r) => json_to_output(&serde_json::json!({
4246                "ok": true,
4247                "receipt": mcp_receipt("sm_delete_namespace"),
4248                "namespace": namespace,
4249                "deleted": {
4250                    "facts": r.facts,
4251                    "documents": r.documents,
4252                    "chunks": r.chunks,
4253                    "messages": r.messages,
4254                    "sessions": r.sessions,
4255                    "episodes": r.episodes,
4256                    "projection_rows": r.projection_rows,
4257                },
4258                "message": "Namespace permanently deleted",
4259            })),
4260            Err(e) => Err(ErrorData::internal_error(
4261                format!("delete_namespace error: {e}"),
4262                None,
4263            )),
4264        }
4265    }
4266
4267    #[tool(
4268        description = "Update a fact's content in-place. Re-embeds the fact and updates FTS index. Use this to correct outdated facts without deleting and re-adding.",
4269        annotations(idempotent_hint = true)
4270    )]
4271    fn sm_update_fact(
4272        &self,
4273        Parameters(UpdateFactParams { fact_id, content }): Parameters<UpdateFactParams>,
4274    ) -> Result<Json<StructuredOutput>, ErrorData> {
4275        let bare = fact_id
4276            .strip_prefix("fact:")
4277            .unwrap_or(&fact_id)
4278            .to_string();
4279        let store = &self.bridge.store;
4280        let result = tokio::task::block_in_place(|| {
4281            Handle::current().block_on(store.update_fact(&bare, &content))
4282        });
4283        match result {
4284            Ok(()) => json_to_output(&serde_json::json!({
4285                "ok": true,
4286                "receipt": mcp_receipt("sm_update_fact"),
4287                "fact_id": format!("fact:{bare}"),
4288                "message": "Fact content updated and re-embedded",
4289            })),
4290            Err(e) => Err(ErrorData::internal_error(
4291                format!("update_fact error: {e}"),
4292                None,
4293            )),
4294        }
4295    }
4296
4297    #[tool(
4298        description = "Consolidate two near-duplicate facts into one. Merges their content, updates the kept fact, and supersedes the other with a 'consolidated with' edge. Use this to clean up duplicate knowledge."
4299    )]
4300    fn sm_consolidate_facts(
4301        &self,
4302        Parameters(ConsolidateFactsParams {
4303            keep_id,
4304            supersede_id,
4305            merged_content,
4306        }): Parameters<ConsolidateFactsParams>,
4307    ) -> Result<Json<StructuredOutput>, ErrorData> {
4308        let keep_bare = keep_id
4309            .strip_prefix("fact:")
4310            .unwrap_or(&keep_id)
4311            .to_string();
4312        let sup_bare = supersede_id
4313            .strip_prefix("fact:")
4314            .unwrap_or(&supersede_id)
4315            .to_string();
4316        let store = &self.bridge.store;
4317
4318        // Get both facts to determine namespace and merge content
4319        let keep_fact =
4320            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&keep_bare)));
4321        let sup_fact =
4322            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&sup_bare)));
4323
4324        let (_namespace, final_content) = match (keep_fact, sup_fact) {
4325            (Ok(Some(k)), Ok(Some(s))) => {
4326                let ns = k.namespace.clone();
4327                let content = merged_content.unwrap_or_else(|| {
4328                    if k.content.len() >= s.content.len() {
4329                        if !k.content.contains(&s.content) {
4330                            format!("{}\n\nAdditional: {}", k.content, s.content)
4331                        } else {
4332                            k.content.clone()
4333                        }
4334                    } else if !s.content.contains(&k.content) {
4335                        format!("{}\n\nAdditional: {}", s.content, k.content)
4336                    } else {
4337                        s.content.clone()
4338                    }
4339                });
4340                (ns, content)
4341            }
4342            (Ok(Some(k)), _) => (
4343                k.namespace.clone(),
4344                merged_content.unwrap_or(k.content.clone()),
4345            ),
4346            (Err(_), _) | (Ok(None), _) => {
4347                return Err(ErrorData::internal_error(
4348                    "keep fact not found".to_string(),
4349                    None,
4350                ));
4351            }
4352        };
4353
4354        // Atomic consolidation: update kept fact + add supersession edge in one transaction
4355        let result = tokio::task::block_in_place(|| {
4356            Handle::current().block_on(store.consolidate_facts(
4357                &keep_bare,
4358                &sup_bare,
4359                &final_content,
4360            ))
4361        });
4362
4363        match result {
4364            Ok(()) => json_to_output(&serde_json::json!({
4365                "ok": true,
4366                "receipt": mcp_receipt("sm_consolidate_facts"),
4367                "kept_fact_id": format!("fact:{}", keep_bare),
4368                "superseded_fact_id": format!("fact:{}", sup_bare),
4369                "message": format!("Facts consolidated atomically: kept fact:{} supersedes fact:{}", keep_bare, sup_bare),
4370            })),
4371            Err(e) => Err(ErrorData::internal_error(
4372                format!("consolidation failed: {e}"),
4373                None,
4374            )),
4375        }
4376    }
4377
4378    // ── RL routing feedback ────────────────────────────────────────────
4379
4380    #[tool(
4381        description = "Return the current persisted RL routing policy, including weights, training example count, and last update time.",
4382        annotations(read_only_hint = true)
4383    )]
4384    fn sm_get_routing_policy(&self) -> Result<Json<StructuredOutput>, ErrorData> {
4385        use semantic_memory::rl_routing::is_trained;
4386
4387        let policy = tokio::task::block_in_place(|| {
4388            Handle::current().block_on(self.bridge.store.load_routing_policy())
4389        })
4390        .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
4391
4392        match policy {
4393            Some(policy) => json_to_output(&serde_json::json!({
4394                "ok": true,
4395                "policy": {
4396                    "weights": policy.weights,
4397                    "training_examples_count": policy.trained_examples,
4398                    "trained": is_trained(&policy),
4399                    "last_updated": policy.last_updated,
4400                }
4401            })),
4402            None => json_to_output(&serde_json::json!({
4403                "ok": true,
4404                "policy": null,
4405            })),
4406        }
4407    }
4408
4409    #[tool(
4410        description = "MUTATING: record a caller-supplied proxy feedback label for retrieval routing. This is not a verified outcome. Persists the updated tabular routing policy every 10 outcomes.",
4411        annotations(
4412            read_only_hint = false,
4413            destructive_hint = false,
4414            idempotent_hint = false
4415        )
4416    )]
4417    fn sm_record_outcome(
4418        &self,
4419        Parameters(RecordOutcomeParams { query, outcome }): Parameters<RecordOutcomeParams>,
4420    ) -> Result<Json<StructuredOutput>, ErrorData> {
4421        use semantic_memory::rl_routing::{
4422            is_trained, record_routing_outcome, route_with_policy, RoutingOutcome,
4423        };
4424        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
4425
4426        let outcome_enum = match outcome.to_lowercase().as_str() {
4427            "good" => RoutingOutcome::Good,
4428            "bad" => RoutingOutcome::Bad,
4429            "neutral" => RoutingOutcome::Neutral,
4430            _ => {
4431                return Err(ErrorData::invalid_params(
4432                    format!("outcome must be 'good', 'bad', or 'neutral', got '{outcome}'"),
4433                    None,
4434                ));
4435            }
4436        };
4437
4438        let profile = QueryProfile::from_query(&query);
4439        let router = RetrievalRouter::default();
4440        let store = &self.bridge.store;
4441        let mut batch = self
4442            .routing_policy_batch
4443            .lock()
4444            .map_err(|_| ErrorData::internal_error("routing policy batch lock poisoned", None))?;
4445        let mut policy = match batch.policy.take() {
4446            Some(policy) => policy,
4447            None => tokio::task::block_in_place(|| {
4448                Handle::current().block_on(store.load_routing_policy())
4449            })
4450            .map_err(|e| {
4451                ErrorData::internal_error(format!("load routing policy error: {e}"), None)
4452            })?
4453            .unwrap_or_default(),
4454        };
4455        let decision = if is_trained(&policy) {
4456            route_with_policy(&policy, &profile)
4457        } else {
4458            router.route(&profile)
4459        };
4460        record_routing_outcome(&mut policy, &profile, &decision, outcome_enum);
4461        batch.pending_outcomes += 1;
4462        let persisted = batch.pending_outcomes >= ROUTING_POLICY_PERSIST_BATCH;
4463        if persisted {
4464            if let Err(e) = tokio::task::block_in_place(|| {
4465                Handle::current().block_on(store.save_routing_policy(&policy))
4466            }) {
4467                batch.policy = Some(policy);
4468                return Err(ErrorData::internal_error(
4469                    format!("persist routing policy error: {e}"),
4470                    None,
4471                ));
4472            }
4473            batch.pending_outcomes = 0;
4474        }
4475        let pending_outcomes = batch.pending_outcomes;
4476        batch.policy = Some(policy.clone());
4477
4478        json_to_output(&serde_json::json!({
4479            "ok": true,
4480            "receipt": mcp_receipt("sm_record_outcome"),
4481            "mutating": true,
4482            "query": query,
4483            "feedback": {"kind": "ProxyLabel", "label": outcome},
4484            "routing_decision": {
4485                "bm25_coarse": decision.bm25_coarse,
4486                "vector_medium": decision.vector_medium,
4487                "rerank_fine": decision.rerank_fine,
4488                "graph_expansion": decision.graph_expansion,
4489                "decoder": decision.decoder,
4490                "discord": decision.discord,
4491                "no_retrieval": decision.no_retrieval,
4492                "reasoning": decision.reasoning,
4493            },
4494            "policy_state": {
4495                "trained_examples": policy.trained_examples,
4496                "baseline": policy.baseline,
4497                "weights": policy.weights,
4498                "last_updated": policy.last_updated,
4499                "persisted": persisted,
4500                "pending_outcomes": pending_outcomes,
4501            },
4502            "message": if persisted {
4503                "Routing outcome recorded and policy batch persisted to DB"
4504            } else {
4505                "Routing outcome recorded; policy persistence batch pending"
4506            },
4507        }))
4508    }
4509
4510    // ─── Claim-ledger integration ──────────────────────────────────────
4511
4512    #[cfg(feature = "claim-integration")]
4513    #[tool(
4514        description = "Verify and checkpoint the claim ledger when configured entry/byte thresholds are exceeded. Defaults to dry_run=true. A successful rotation atomically activates a digest-verified snapshot and retained tail, emits a compaction receipt bound to the prior ledger head, and keeps bounded backups.",
4515        annotations(read_only_hint = false, destructive_hint = true)
4516    )]
4517    fn sm_compact_claim_ledger(
4518        &self,
4519        Parameters(CompactClaimLedgerParams {
4520            dry_run,
4521            max_entries,
4522            max_bytes,
4523            retain_tail_entries,
4524            max_backups,
4525        }): Parameters<CompactClaimLedgerParams>,
4526    ) -> Result<Json<StructuredOutput>, ErrorData> {
4527        let mut ledger = self.claim_ledger_store.lock().unwrap();
4528        let result = ledger
4529            .compact(ClaimLedgerCompactionConfig {
4530                dry_run: dry_run.unwrap_or(true),
4531                max_entries: max_entries.unwrap_or(10_000),
4532                max_bytes: max_bytes.unwrap_or(16 * 1024 * 1024),
4533                retain_tail_entries: retain_tail_entries.unwrap_or(256),
4534                max_backups: max_backups.unwrap_or(3),
4535            })
4536            .map_err(|error| ErrorData::internal_error(error, None))?;
4537        if result["compacted"].as_bool().unwrap_or(false) {
4538            let mut index = self.claim_trust.lock().unwrap();
4539            index.disable();
4540            if ledger.trust_enabled {
4541                index.enabled = true;
4542                if let Some(snapshot) = &ledger.snapshot {
4543                    index.load_snapshot(snapshot);
4544                }
4545                index.rebuild_from_ledger_incremental(&ledger.entries);
4546            }
4547        }
4548        json_to_output(&result)
4549    }
4550
4551    #[cfg(feature = "claim-integration")]
4552    #[tool(
4553        description = "Create a typed Claim from a semantic-memory fact. The claim gets a source-spanned provenance record from the fact's metadata. Returns the claim ID.",
4554        annotations(read_only_hint = false, idempotent_hint = true)
4555    )]
4556    fn sm_create_claim(
4557        &self,
4558        Parameters(CreateClaimParams {
4559            fact_id,
4560            source_span,
4561        }): Parameters<CreateClaimParams>,
4562    ) -> Result<Json<StructuredOutput>, ErrorData> {
4563        use claim_ledger::Claim;
4564        let bare = fact_id
4565            .strip_prefix("fact:")
4566            .unwrap_or(&fact_id)
4567            .to_string();
4568        let store = &self.bridge.store;
4569
4570        // Get the fact content
4571        let fact =
4572            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
4573        let fact = match fact {
4574            Ok(Some(f)) => f,
4575            _ => {
4576                return Err(ErrorData::internal_error(
4577                    format!("fact not found: {fact_id}"),
4578                    None,
4579                ))
4580            }
4581        };
4582
4583        // Create a claim from the fact
4584        let source_id = format!("semantic-memory:fact:{bare}");
4585        let span_id = source_span.unwrap_or_else(|| "full".to_string());
4586        let claim = Claim::new(&source_id, &span_id, &fact.content, "fact");
4587
4588        let claim_id = claim.claim_id.clone();
4589        let normalized = &claim.normalized_claim;
4590
4591        {
4592            let mut ledger = self.claim_ledger_store.lock().unwrap();
4593            let sequence = ledger.next_sequence();
4594            let previous_digest = ledger.last_digest();
4595            let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4596                .add_claim(&claim_id, &source_id, &span_id, normalized)
4597                .map_err(|e| {
4598                    ErrorData::internal_error(
4599                        format!("failed to build claim ledger entry: {e}"),
4600                        None,
4601                    )
4602                })?;
4603            ledger.append(entry).map_err(|e| {
4604                ErrorData::internal_error(format!("failed to record claim to ledger: {e}"), None)
4605            })?;
4606        }
4607
4608        {
4609            let mut idx = self.claim_trust.lock().unwrap();
4610            idx.link_fact(bare.clone(), claim_id.clone());
4611            idx.register_claim(claim_id.clone(), normalized.clone());
4612        }
4613
4614        json_to_output(&serde_json::json!({
4615            "ok": true,
4616            "receipt": mcp_receipt("sm_create_claim"),
4617            "claim_id": claim_id,
4618            "source_id": source_id,
4619            "span_id": span_id,
4620            "claim_text": fact.content,
4621            "normalized_claim": normalized,
4622            "claim_type": "fact",
4623            "message": "Claim created from semantic-memory fact with source-spanned provenance",
4624        }))
4625    }
4626
4627    #[cfg(feature = "claim-integration")]
4628    #[tool(
4629        description = "Add evidence to a claim. Creates an EvidenceBundle linking the evidence text to the claim. Returns the evidence bundle ID.",
4630        annotations(read_only_hint = false)
4631    )]
4632    fn sm_add_evidence(
4633        &self,
4634        Parameters(AddEvidenceParams {
4635            claim_id,
4636            evidence_text,
4637            source_type,
4638        }): Parameters<AddEvidenceParams>,
4639    ) -> Result<Json<StructuredOutput>, ErrorData> {
4640        use claim_ledger::{EvidenceBundle, EvidenceLink, EvidenceRelation};
4641        let mut bundle = EvidenceBundle::new(&claim_id);
4642        let link = EvidenceLink {
4643            relation: EvidenceRelation::Supports,
4644            source_id: source_type.unwrap_or_else(|| "semantic-memory".to_string()),
4645            span_id: "full".to_string(),
4646            quote: evidence_text.clone(),
4647            digest: claim_ledger::ids::sha256_text(&evidence_text),
4648            support_role: "supporting".to_string(),
4649        };
4650        bundle.evidence_links.push(link);
4651
4652        // Persist evidence bundle to JSONL file in the memory directory
4653        let evidence_path = self.bridge.memory_dir.join("evidence_bundles.jsonl");
4654        let line = serde_json::to_string(&bundle).map_err(|e| {
4655            ErrorData::internal_error(format!("failed to serialize evidence bundle: {e}"), None)
4656        })?;
4657        use std::io::Write;
4658        let mut file = std::fs::OpenOptions::new()
4659            .create(true)
4660            .append(true)
4661            .open(&evidence_path)
4662            .map_err(|e| {
4663                ErrorData::internal_error(
4664                    format!("failed to open evidence bundles file: {e}"),
4665                    None,
4666                )
4667            })?;
4668        writeln!(file, "{line}").map_err(|e| {
4669            ErrorData::internal_error(format!("failed to write evidence bundle: {e}"), None)
4670        })?;
4671        file.sync_data().map_err(|e| {
4672            ErrorData::internal_error(format!("failed to fsync evidence bundle: {e}"), None)
4673        })?;
4674
4675        json_to_output(&serde_json::json!({
4676            "ok": true,
4677            "receipt": mcp_receipt("sm_add_evidence"),
4678            "evidence_bundle_id": bundle.evidence_bundle_id,
4679            "claim_id": claim_id,
4680            "evidence_count": bundle.evidence_links.len(),
4681            "message": "Evidence added to claim and persisted",
4682        }))
4683    }
4684
4685    #[cfg(feature = "claim-integration")]
4686    #[tool(
4687        description = "Judge the support state of a claim. Creates a SupportJudgment (supported, unsupported, contested, or heuristic_only) with optional rationale.",
4688        annotations(read_only_hint = false)
4689    )]
4690    fn sm_judge_support(
4691        &self,
4692        Parameters(JudgeSupportParams {
4693            claim_id,
4694            judgment,
4695            rationale,
4696        }): Parameters<JudgeSupportParams>,
4697    ) -> Result<Json<StructuredOutput>, ErrorData> {
4698        use claim_ledger::{SupportJudgment, SupportState};
4699        let state = match judgment.to_lowercase().as_str() {
4700            "supported" => SupportState::Supported,
4701            "partially_supported" | "partial" => SupportState::PartiallySupported,
4702            "unsupported" => SupportState::Unsupported,
4703            "contradicted" | "contested" => SupportState::Contradicted,
4704            "heuristic_only" | "heuristic" => SupportState::HeuristicOnly,
4705            _ => return Err(ErrorData::invalid_params(
4706                format!("Invalid judgment '{judgment}'. Must be: supported, partially_supported, unsupported, contradicted, or heuristic_only"),
4707                None,
4708            )),
4709        };
4710        let j = SupportJudgment {
4711            support_judgment_id: claim_ledger::ids::ulid(),
4712            claim_id: claim_id.clone(),
4713            evidence_bundle_ref: claim_ledger::ids::evidence_bundle_id(&claim_id),
4714            support_state: state,
4715            method: "agent_judgment".to_string(),
4716            rationale: rationale.unwrap_or_default(),
4717            contradiction_refs: Vec::new(),
4718            proof_debt: Vec::new(),
4719            created_recorded_time: chrono::Utc::now(),
4720        };
4721
4722        {
4723            let mut ledger = self.claim_ledger_store.lock().unwrap();
4724            let sequence = ledger.next_sequence();
4725            let previous_digest = ledger.last_digest();
4726            let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4727                .add_support_judgment(
4728                    &j.support_judgment_id,
4729                    &claim_id,
4730                    &j.evidence_bundle_ref,
4731                    state,
4732                    &j.method,
4733                )
4734                .map_err(|e| {
4735                    ErrorData::internal_error(
4736                        format!("failed to build support judgment ledger entry: {e}"),
4737                        None,
4738                    )
4739                })?;
4740            ledger.append(entry).map_err(|e| {
4741                ErrorData::internal_error(
4742                    format!("failed to record support judgment to ledger: {e}"),
4743                    None,
4744                )
4745            })?;
4746        }
4747
4748        self.claim_trust
4749            .lock()
4750            .unwrap()
4751            .record_judgment(claim_id.clone(), state);
4752
4753        json_to_output(&serde_json::json!({
4754            "ok": true,
4755            "receipt": mcp_receipt("sm_judge_support"),
4756            "support_judgment_id": j.support_judgment_id,
4757            "claim_id": claim_id,
4758            "state": judgment.to_lowercase(),
4759            "message": "Support judgment recorded",
4760        }))
4761    }
4762
4763    // ─── Bitemporal search ─────────────────────────────────────────────
4764
4765    #[tool(
4766        description = "Search facts that were valid (not superseded) as of a specific date. Uses bitemporal fields to filter results to only include facts that existed on the specified date.",
4767        annotations(read_only_hint = true)
4768    )]
4769    fn sm_search_as_of(
4770        &self,
4771        Parameters(SearchAsOfParams {
4772            query,
4773            as_of_date,
4774            top_k,
4775            namespace,
4776        }): Parameters<SearchAsOfParams>,
4777    ) -> Result<Json<StructuredOutput>, ErrorData> {
4778        let store = &self.bridge.store;
4779        let k = top_k.unwrap_or(5);
4780        let ns_slice: Option<Vec<&str>> = namespace.as_ref().map(|n| vec![n.as_str()]);
4781
4782        // Parse the as-of date
4783        let _as_of = chrono::DateTime::parse_from_rfc3339(&as_of_date)
4784            .map_err(|e| ErrorData::invalid_params(
4785                format!("Invalid as_of_date '{as_of_date}': {e}. Use ISO 8601 format like 2026-01-15T00:00:00Z"),
4786                None,
4787            ))?
4788            .with_timezone(&chrono::Utc);
4789
4790        // Search normally, then filter by date
4791        let results = tokio::task::block_in_place(|| {
4792            Handle::current().block_on(store.search_with_view(
4793                &query,
4794                Some(k * 2),
4795                ns_slice.as_deref(),
4796                None,
4797                semantic_memory::StateView::HistoricalAt(as_of_date.clone()),
4798            ))
4799        })
4800        .map_err(|e| ErrorData::internal_error(format!("search error: {e}"), None))?;
4801
4802        let filtered: Vec<_> = results.into_iter().take(k).collect();
4803
4804        let result_json: Vec<serde_json::Value> = filtered
4805            .iter()
4806            .map(|r| {
4807                serde_json::json!({
4808                    "result_id": r.source.result_id(),
4809                    "content": r.content,
4810                    "score": r.score,
4811                })
4812            })
4813            .collect();
4814
4815        json_to_output(&serde_json::json!({
4816            "ok": true,
4817            "query": query,
4818            "as_of_date": as_of_date,
4819            "results": result_json,
4820            "count": filtered.len(),
4821            "message": format!("Found {} facts valid as of {}", filtered.len(), as_of_date),
4822        }))
4823    }
4824
4825    // ─── Verification gate ─────────────────────────────────────────────
4826
4827    #[tool(
4828        description = "Verify a claim against risk class requirements. Low/medium claims need cheap checks. High claims need falsification. Critical claims need replay AND falsification. Returns disposition: promote, reject, quarantine, or defer.",
4829        annotations(read_only_hint = true)
4830    )]
4831    fn sm_verify_claim(
4832        &self,
4833        Parameters(VerifyClaimParams {
4834            claim,
4835            risk_class,
4836            evidence_refs,
4837            refutation_attempted,
4838        }): Parameters<VerifyClaimParams>,
4839    ) -> Result<Json<StructuredOutput>, ErrorData> {
4840        let risk = risk_class.to_lowercase();
4841        let has_evidence = evidence_refs
4842            .as_ref()
4843            .map(|v| !v.is_empty())
4844            .unwrap_or(false);
4845        let refuted = refutation_attempted.unwrap_or(false);
4846
4847        // Required checks by risk class
4848        let (needs_replay, needs_falsification, disposition, rationale) = match risk.as_str() {
4849            "low" => (
4850                false,
4851                false,
4852                "promote",
4853                "Low risk: cheap checks only, claim can be promoted",
4854            ),
4855            "medium" => (
4856                true,
4857                false,
4858                "promote",
4859                "Medium risk: replay check required, claim can be promoted",
4860            ),
4861            "high" => (
4862                true,
4863                true,
4864                if refuted {
4865                    "quarantine"
4866                } else if has_evidence {
4867                    "promote"
4868                } else {
4869                    "defer"
4870                },
4871                if refuted {
4872                    "High risk: refutation attempted, claim quarantined"
4873                } else if has_evidence {
4874                    "High risk: falsification passed with evidence, claim promoted"
4875                } else {
4876                    "High risk: no evidence provided, claim deferred"
4877                },
4878            ),
4879            "critical" => (
4880                true,
4881                true,
4882                if refuted {
4883                    "quarantine"
4884                } else if has_evidence && refutation_attempted == Some(true) {
4885                    "promote"
4886                } else {
4887                    "defer"
4888                },
4889                if refuted {
4890                    "Critical risk: refutation found, claim quarantined"
4891                } else if has_evidence && refutation_attempted == Some(true) {
4892                    "Critical risk: replay + falsification passed, claim promoted"
4893                } else {
4894                    "Critical risk: requires evidence AND refutation, claim deferred"
4895                },
4896            ),
4897            _ => {
4898                return Err(ErrorData::invalid_params(
4899                    format!("Invalid risk_class '{risk}'. Must be: low, medium, high, or critical"),
4900                    None,
4901                ))
4902            }
4903        };
4904
4905        json_to_output(&serde_json::json!({
4906            "ok": true,
4907            "claim": claim,
4908            "risk_class": risk,
4909            "required_checks": {
4910                "cheap_checks": true,
4911                "replay_checks": needs_replay,
4912                "falsification_checks": needs_falsification,
4913            },
4914            "has_evidence": has_evidence,
4915            "refutation_attempted": refuted,
4916            "disposition": disposition,
4917            "rationale": rationale,
4918            "can_promote": disposition == "promote",
4919        }))
4920    }
4921
4922    // ─── Search receipt tools (GAP #6-7) ────────────────────────────
4923
4924    #[tool(
4925        description = "Load a durable search receipt by receipt/request ID. Returns the stored receipt with evaluation time, retrieval family, result IDs, and digests.",
4926        annotations(read_only_hint = true)
4927    )]
4928    fn sm_get_search_receipt(
4929        &self,
4930        Parameters(GetSearchReceiptParams { receipt_id }): Parameters<GetSearchReceiptParams>,
4931    ) -> Result<Json<StructuredOutput>, ErrorData> {
4932        let store = &self.bridge.store;
4933        let result = tokio::task::block_in_place(|| {
4934            Handle::current().block_on(store.get_search_receipt(&receipt_id))
4935        });
4936        match result {
4937            Ok(Some(receipt)) => json_to_output(&serde_json::json!({
4938                "ok": true,
4939                "receipt": {
4940                    "receipt_id": receipt.receipt_id,
4941                    "trace_id": receipt.trace_id,
4942                    "search_profile": receipt.search_profile,
4943                    "evaluation_time": receipt.evaluation_time,
4944                    "result_ids": receipt.result_ids,
4945                    "query_embedding_digest": receipt.query_embedding_digest,
4946                    "query_text_digest": receipt.query_text_digest,
4947                    "query_input_digest": receipt.query_input_digest,
4948                    "filter_digest": receipt.filter_digest,
4949                    "redaction_state": receipt.redaction_state,
4950                    "approximate": receipt.approximate,
4951                    "attempt_family_id": receipt.attempt_family_id,
4952                    "budget_id": receipt.budget_id,
4953                },
4954            })),
4955            Ok(None) => json_to_output(&serde_json::json!({
4956                "ok": true,
4957                "found": false,
4958                "receipt_id": receipt_id,
4959                "message": "No receipt found with that ID",
4960            })),
4961            Err(e) => Err(ErrorData::internal_error(
4962                format!("get_search_receipt error: {e}"),
4963                None,
4964            )),
4965        }
4966    }
4967
4968    #[tool(
4969        description = "Replay a durable search receipt with caller-supplied query text and filters. Compares original results to replay results, reporting matches, missing IDs, and added IDs.",
4970        annotations(read_only_hint = true)
4971    )]
4972    fn sm_replay_search_receipt(
4973        &self,
4974        Parameters(ReplaySearchReceiptParams {
4975            receipt_id,
4976            query,
4977            top_k,
4978            namespaces,
4979        }): Parameters<ReplaySearchReceiptParams>,
4980    ) -> Result<Json<StructuredOutput>, ErrorData> {
4981        let store = &self.bridge.store;
4982        let k = top_k.map(|v| v as usize);
4983        let ns_slice: Option<Vec<&str>> = namespaces
4984            .as_ref()
4985            .map(|v| v.iter().map(|s| s.as_str()).collect());
4986
4987        let result = tokio::task::block_in_place(|| {
4988            Handle::current().block_on(store.replay_search_receipt(
4989                &receipt_id,
4990                &query,
4991                k,
4992                ns_slice.as_deref(),
4993                None,
4994            ))
4995        });
4996        match result {
4997            Ok(report) => json_to_output(&serde_json::json!({
4998                "ok": true,
4999                "receipt_id": report.receipt_id,
5000                "replay_receipt_id": report.replay_receipt_id,
5001                "query_embedding_digest_matches": report.query_embedding_digest_matches,
5002                "result_ids_match": report.result_ids_match,
5003                "missing_result_ids": report.missing_result_ids,
5004                "added_result_ids": report.added_result_ids,
5005                "original_receipt": {
5006                    "receipt_id": report.original_receipt.receipt_id,
5007                    "result_ids": report.original_receipt.result_ids,
5008                    "search_profile": report.original_receipt.search_profile,
5009                    "evaluation_time": report.original_receipt.evaluation_time,
5010                },
5011                "replay_receipt": {
5012                    "receipt_id": report.replay_receipt.receipt_id,
5013                    "result_ids": report.replay_receipt.result_ids,
5014                    "search_profile": report.replay_receipt.search_profile,
5015                    "evaluation_time": report.replay_receipt.evaluation_time,
5016                },
5017            })),
5018            Err(e) => Err(ErrorData::internal_error(
5019                format!("replay_search_receipt error: {e}"),
5020                None,
5021            )),
5022        }
5023    }
5024
5025    #[tool(
5026        description = "Replay a durable search receipt using query text and filters retained by explicit opt-in. Returns the replay comparison report.",
5027        annotations(read_only_hint = true)
5028    )]
5029    fn sm_replay_search(
5030        &self,
5031        Parameters(ReplayStoredSearchParams { receipt_id }): Parameters<ReplayStoredSearchParams>,
5032    ) -> Result<Json<StructuredOutput>, ErrorData> {
5033        let result = tokio::task::block_in_place(|| {
5034            Handle::current().block_on(
5035                self.bridge
5036                    .store
5037                    .replay_search_from_stored_inputs(&receipt_id),
5038            )
5039        });
5040        match result {
5041            Ok(report) => json_to_output(&serde_json::json!({
5042                "ok": true,
5043                "receipt_id": report.receipt_id,
5044                "replay_receipt_id": report.replay_receipt_id,
5045                "query_embedding_digest_matches": report.query_embedding_digest_matches,
5046                "result_ids_match": report.result_ids_match,
5047                "missing_result_ids": report.missing_result_ids,
5048                "added_result_ids": report.added_result_ids,
5049                "original_result_ids": report.original_receipt.result_ids,
5050                "replay_result_ids": report.replay_receipt.result_ids,
5051            })),
5052            Err(e) => Err(ErrorData::internal_error(
5053                format!("replay_search error: {e}"),
5054                None,
5055            )),
5056        }
5057    }
5058
5059    // ─── Reconcile tool (GAP #8) ────────────────────────────────────
5060
5061    #[tool(
5062        description = "Reconcile detected integrity issues. Actions: report_only (just check), rebuild_fts (rebuild FTS indexes), re_embed (re-embed all content). Returns an integrity report after the action.",
5063        annotations(idempotent_hint = true)
5064    )]
5065    fn sm_reconcile(
5066        &self,
5067        Parameters(ReconcileParams { action }): Parameters<ReconcileParams>,
5068    ) -> Result<Json<StructuredOutput>, ErrorData> {
5069        let action_enum = match action.to_lowercase().as_str() {
5070            "report_only" | "report-only" => semantic_memory::ReconcileAction::ReportOnly,
5071            "rebuild_fts" | "rebuild-fts" => semantic_memory::ReconcileAction::RebuildFts,
5072            "re_embed" | "re-embed" | "reembed" => semantic_memory::ReconcileAction::ReEmbed,
5073            _ => {
5074                return Err(ErrorData::invalid_params(
5075                    format!("action must be 'report_only', 'rebuild_fts', or 're_embed', got '{action}'"),
5076                    None,
5077                ));
5078            }
5079        };
5080        let store = &self.bridge.store;
5081        let result = tokio::task::block_in_place(|| {
5082            Handle::current().block_on(store.reconcile(action_enum))
5083        });
5084        match result {
5085            Ok(report) => json_to_output(&serde_json::json!({
5086                "ok": report.ok,
5087                "schema_version": report.schema_version,
5088                "fact_count": report.fact_count,
5089                "chunk_count": report.chunk_count,
5090                "message_count": report.message_count,
5091                "facts_missing_embeddings": report.facts_missing_embeddings,
5092                "chunks_missing_embeddings": report.chunks_missing_embeddings,
5093                "issues": report.issues,
5094                "issue_count": report.issues.len(),
5095                "action": action,
5096            })),
5097            Err(e) => Err(ErrorData::internal_error(
5098                format!("reconcile error: {e}"),
5099                None,
5100            )),
5101        }
5102    }
5103
5104    // ─── Maintenance tools (GAP #9) ─────────────────────────────────
5105
5106    #[tool(
5107        description = "Vacuum the database to reclaim space after deletions. This is a maintenance operation that may take a moment.",
5108        annotations(idempotent_hint = true)
5109    )]
5110    fn sm_vacuum(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5111        let store = &self.bridge.store;
5112        let result = tokio::task::block_in_place(|| Handle::current().block_on(store.vacuum()));
5113        match result {
5114            Ok(()) => json_to_output(&serde_json::json!({
5115                "ok": true,
5116                "receipt": mcp_receipt("sm_vacuum"),
5117                "message": "Database vacuumed successfully",
5118            })),
5119            Err(e) => Err(ErrorData::internal_error(
5120                format!("vacuum error: {e}"),
5121                None,
5122            )),
5123        }
5124    }
5125
5126    #[tool(
5127        description = "Re-embed all facts, chunks, messages, and episodes. Call after changing embedding models. Returns the count of items re-embedded.",
5128        annotations(idempotent_hint = true)
5129    )]
5130    fn sm_reembed_all(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5131        let store = &self.bridge.store;
5132        let result =
5133            tokio::task::block_in_place(|| Handle::current().block_on(store.reembed_all()));
5134        match result {
5135            Ok(count) => json_to_output(&serde_json::json!({
5136                "ok": true,
5137                "receipt": mcp_receipt("sm_reembed_all"),
5138                "reembedded_count": count,
5139                "message": format!("Re-embedded {count} items"),
5140            })),
5141            Err(e) => Err(ErrorData::internal_error(
5142                format!("reembed_all error: {e}"),
5143                None,
5144            )),
5145        }
5146    }
5147
5148    #[tool(
5149        description = "Check if embeddings need re-generation after a model change. Returns true if the embedding model or dimensions have changed since the last embedding was stored.",
5150        annotations(read_only_hint = true)
5151    )]
5152    fn sm_embeddings_are_dirty(
5153        &self,
5154        Parameters(_params): Parameters<EmbeddingsAreDirtyParams>,
5155    ) -> Result<Json<StructuredOutput>, ErrorData> {
5156        let store = &self.bridge.store;
5157        let result = tokio::task::block_in_place(|| {
5158            Handle::current().block_on(store.embeddings_are_dirty())
5159        });
5160        match result {
5161            Ok(dirty) => json_to_output(&serde_json::json!({
5162                "ok": true,
5163                "dirty": dirty,
5164                "message": if dirty { "Embeddings are dirty and need re-generation. Call sm_reembed_all." } else { "Embeddings are up to date" },
5165            })),
5166            Err(e) => Err(ErrorData::internal_error(
5167                format!("embeddings_are_dirty error: {e}"),
5168                None,
5169            )),
5170        }
5171    }
5172
5173    // ─── Projection query tools (GAP #10) ───────────────────────────
5174
5175    #[tool(
5176        description = "Query imported claim projection rows. Filters by scope, text, valid-time, and claim state. Returns claim version rows with full provenance.",
5177        annotations(read_only_hint = true)
5178    )]
5179    fn sm_query_claim_versions(
5180        &self,
5181        Parameters(params): Parameters<ProjectionQueryParams>,
5182    ) -> Result<Json<StructuredOutput>, ErrorData> {
5183        let store = &self.bridge.store;
5184        let query = build_projection_query(params);
5185        let result = tokio::task::block_in_place(|| {
5186            Handle::current().block_on(store.query_claim_versions(query))
5187        });
5188        match result {
5189            Ok(rows) => json_to_output(&serde_json::json!({
5190                "ok": true,
5191                "results": serde_json::to_value(&rows).unwrap_or_else(|_| serde_json::json!([])),
5192                "count": rows.len(),
5193            })),
5194            Err(e) => Err(ErrorData::internal_error(
5195                format!("query_claim_versions error: {e}"),
5196                None,
5197            )),
5198        }
5199    }
5200
5201    #[tool(
5202        description = "Query imported relation projection rows. Filters by scope, text, valid-time, and subject entity. Returns relation version rows with full provenance.",
5203        annotations(read_only_hint = true)
5204    )]
5205    fn sm_query_relation_versions(
5206        &self,
5207        Parameters(params): Parameters<ProjectionQueryParams>,
5208    ) -> Result<Json<StructuredOutput>, ErrorData> {
5209        let store = &self.bridge.store;
5210        let query = build_projection_query(params);
5211        let result = tokio::task::block_in_place(|| {
5212            Handle::current().block_on(store.query_relation_versions(query))
5213        });
5214        match result {
5215            Ok(rows) => json_to_output(&serde_json::json!({
5216                "ok": true,
5217                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5218                "count": rows.len(),
5219            })),
5220            Err(e) => Err(ErrorData::internal_error(
5221                format!("query_relation_versions error: {e}"),
5222                None,
5223            )),
5224        }
5225    }
5226
5227    #[tool(
5228        description = "Query imported episode projection rows. Filters by scope and text. Returns episode rows with cause/effect and outcome data.",
5229        annotations(read_only_hint = true)
5230    )]
5231    fn sm_query_episodes(
5232        &self,
5233        Parameters(params): Parameters<ProjectionQueryParams>,
5234    ) -> Result<Json<StructuredOutput>, ErrorData> {
5235        let store = &self.bridge.store;
5236        let query = build_projection_query(params);
5237        let result =
5238            tokio::task::block_in_place(|| Handle::current().block_on(store.query_episodes(query)));
5239        match result {
5240            Ok(rows) => json_to_output(&serde_json::json!({
5241                "ok": true,
5242                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5243                "count": rows.len(),
5244            })),
5245            Err(e) => Err(ErrorData::internal_error(
5246                format!("query_episodes error: {e}"),
5247                None,
5248            )),
5249        }
5250    }
5251
5252    #[tool(
5253        description = "Query imported entity-alias rows. Filters by scope, canonical entity, and text. Returns alias rows with merge and review state.",
5254        annotations(read_only_hint = true)
5255    )]
5256    fn sm_query_entity_aliases(
5257        &self,
5258        Parameters(params): Parameters<ProjectionQueryParams>,
5259    ) -> Result<Json<StructuredOutput>, ErrorData> {
5260        let store = &self.bridge.store;
5261        let query = build_projection_query(params);
5262        let result = tokio::task::block_in_place(|| {
5263            Handle::current().block_on(store.query_entity_aliases(query))
5264        });
5265        match result {
5266            Ok(rows) => json_to_output(&serde_json::json!({
5267                "ok": true,
5268                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5269                "count": rows.len(),
5270            })),
5271            Err(e) => Err(ErrorData::internal_error(
5272                format!("query_entity_aliases error: {e}"),
5273                None,
5274            )),
5275        }
5276    }
5277
5278    #[tool(
5279        description = "Query imported evidence-reference rows. Filters by scope, claim, and claim version. Returns evidence reference rows with fetch handles and source authority.",
5280        annotations(read_only_hint = true)
5281    )]
5282    fn sm_query_evidence_refs(
5283        &self,
5284        Parameters(params): Parameters<ProjectionQueryParams>,
5285    ) -> Result<Json<StructuredOutput>, ErrorData> {
5286        let store = &self.bridge.store;
5287        let query = build_projection_query(params);
5288        let result = tokio::task::block_in_place(|| {
5289            Handle::current().block_on(store.query_evidence_refs(query))
5290        });
5291        match result {
5292            Ok(rows) => json_to_output(&serde_json::json!({
5293                "ok": true,
5294                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5295                "count": rows.len(),
5296            })),
5297            Err(e) => Err(ErrorData::internal_error(
5298                format!("query_evidence_refs error: {e}"),
5299                None,
5300            )),
5301        }
5302    }
5303
5304    // ─── Import tools (GAP #11) ─────────────────────────────────────
5305
5306    #[tool(
5307        description = "Import a projection envelope atomically. All records are committed in a single transaction or the entire import is rolled back. Pass the envelope as a JSON string.",
5308        annotations(idempotent_hint = true)
5309    )]
5310    #[allow(deprecated)]
5311    fn sm_import_envelope(
5312        &self,
5313        Parameters(ImportEnvelopeParams { envelope_json }): Parameters<ImportEnvelopeParams>,
5314    ) -> Result<Json<StructuredOutput>, ErrorData> {
5315        let envelope: semantic_memory::projection_import::ImportEnvelope =
5316            serde_json::from_str(&envelope_json).map_err(|e| {
5317                ErrorData::invalid_params(format!("Failed to parse envelope JSON: {e}"), None)
5318            })?;
5319        envelope.validate().map_err(|e| {
5320            ErrorData::invalid_params(format!("Envelope validation failed: {e}"), None)
5321        })?;
5322        let store = &self.bridge.store;
5323        let result = tokio::task::block_in_place(|| {
5324            Handle::current().block_on(store.import_envelope(&envelope))
5325        });
5326        match result {
5327            Ok(receipt) => json_to_output(&serde_json::json!({
5328                "ok": true,
5329                "envelope_id": receipt.envelope_id,
5330                "was_duplicate": receipt.was_duplicate,
5331                "imported_count": receipt.record_count,
5332                "receipt_id": receipt.envelope_id,
5333            })),
5334            Err(e) => Err(ErrorData::internal_error(
5335                format!("import_envelope error: {e}"),
5336                None,
5337            )),
5338        }
5339    }
5340
5341    #[tool(
5342        description = "Check whether an envelope has already been imported. Returns import receipts for the given envelope ID.",
5343        annotations(read_only_hint = true)
5344    )]
5345    #[allow(deprecated)]
5346    fn sm_import_status(
5347        &self,
5348        Parameters(ImportStatusParams { envelope_id }): Parameters<ImportStatusParams>,
5349    ) -> Result<Json<StructuredOutput>, ErrorData> {
5350        use semantic_memory::projection_import::EnvelopeId;
5351        let store = &self.bridge.store;
5352        let env_id = EnvelopeId::new(&envelope_id);
5353        let result = tokio::task::block_in_place(|| {
5354            Handle::current().block_on(store.import_status(&env_id))
5355        });
5356        match result {
5357            Ok(receipts) => json_to_output(&serde_json::json!({
5358                "ok": true,
5359                "envelope_id": envelope_id,
5360                "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5361                "count": receipts.len(),
5362            })),
5363            Err(e) => Err(ErrorData::internal_error(
5364                format!("import_status error: {e}"),
5365                None,
5366            )),
5367        }
5368    }
5369
5370    #[tool(
5371        description = "List recent imports, optionally filtered by namespace. Returns import receipt records.",
5372        annotations(read_only_hint = true)
5373    )]
5374    #[allow(deprecated)]
5375    fn sm_list_imports(
5376        &self,
5377        Parameters(ListImportsParams { namespace, limit }): Parameters<ListImportsParams>,
5378    ) -> Result<Json<StructuredOutput>, ErrorData> {
5379        let store = &self.bridge.store;
5380        let lim = limit.unwrap_or(20) as usize;
5381        let result = tokio::task::block_in_place(|| {
5382            Handle::current().block_on(store.list_imports(namespace.as_deref(), lim))
5383        });
5384        match result {
5385            Ok(receipts) => json_to_output(&serde_json::json!({
5386                "ok": true,
5387                "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5388                "count": receipts.len(),
5389            })),
5390            Err(e) => Err(ErrorData::internal_error(
5391                format!("list_imports error: {e}"),
5392                None,
5393            )),
5394        }
5395    }
5396    // ── LLM output parser tools ─────────────────────────────────────────
5397
5398    #[cfg(feature = "llm-parser")]
5399    #[tool(
5400        description = "Parse JSON from raw LLM output. Handles think blocks, markdown fences, trailing text, and common JSON errors without an additional LLM call. Returns the extracted JSON as a string.",
5401        annotations(read_only_hint = true)
5402    )]
5403    fn sm_parse_json(
5404        &self,
5405        Parameters(ParseJsonParams { raw_output }): Parameters<ParseJsonParams>,
5406    ) -> Result<Json<StructuredOutput>, ErrorData> {
5407        match llm_output_parser::parse_json::<serde_json::Value>(&raw_output) {
5408            Ok(value) => Ok(structured_output(value)),
5409            Err(e) => Ok(json_to_output(&serde_json::json!({
5410                "ok": false,
5411                "error": e.to_string(),
5412                "input_preview": &raw_output.chars().take(200).collect::<String>(),
5413            }))?),
5414        }
5415    }
5416
5417    #[cfg(feature = "llm-parser")]
5418    #[tool(
5419        description = "Parse JSON from raw LLM output as an untyped serde_json::Value. Useful when the expected schema is unknown.",
5420        annotations(read_only_hint = true)
5421    )]
5422    fn sm_parse_json_value(
5423        &self,
5424        Parameters(ParseJsonValueParams { raw_output }): Parameters<ParseJsonValueParams>,
5425    ) -> Result<Json<StructuredOutput>, ErrorData> {
5426        match llm_output_parser::parse_json_value(&raw_output) {
5427            Ok(value) => Ok(structured_output(value)),
5428            Err(e) => Ok(json_to_output(&serde_json::json!({
5429                "ok": false,
5430                "error": e.to_string(),
5431            }))?),
5432        }
5433    }
5434
5435    #[cfg(feature = "llm-parser")]
5436    #[tool(
5437        description = "Strip </think> blocks from text. Removes chain-of-thought reasoning that some models emit. Returns cleaned text.",
5438        annotations(read_only_hint = true)
5439    )]
5440    fn sm_strip_think_tags(
5441        &self,
5442        Parameters(StripThinkTagsParams { text }): Parameters<StripThinkTagsParams>,
5443    ) -> Result<Json<StructuredOutput>, ErrorData> {
5444        Ok(structured_output(serde_json::json!({
5445            "ok": true,
5446            "text": llm_output_parser::strip_think_tags(&text),
5447        })))
5448    }
5449
5450    #[cfg(feature = "llm-parser")]
5451    #[tool(
5452        description = "Attempt to repair common LLM JSON errors: trailing commas, unquoted keys, single quotes, missing brackets. Returns the repaired JSON string or an error.",
5453        annotations(read_only_hint = true)
5454    )]
5455    fn sm_repair_json(
5456        &self,
5457        Parameters(RepairJsonParams { json_string }): Parameters<RepairJsonParams>,
5458    ) -> Result<Json<StructuredOutput>, ErrorData> {
5459        match llm_output_parser::try_repair_json(&json_string) {
5460            Some(repaired) => Ok(structured_output(serde_json::json!({
5461                "ok": true,
5462                "repaired_json": repaired,
5463            }))),
5464            None => Ok(json_to_output(&serde_json::json!({
5465                "ok": false,
5466                "error": "Could not repair JSON. The input may not be valid JSON even after common fixes.",
5467            }))?),
5468        }
5469    }
5470
5471    #[cfg(feature = "llm-parser")]
5472    #[tool(
5473        description = "Parse a string list from raw LLM output. Handles markdown bullet lists, numbered lists, comma-separated values, and JSON arrays. Returns a JSON array of cleaned strings.",
5474        annotations(read_only_hint = true)
5475    )]
5476    fn sm_parse_string_list(
5477        &self,
5478        Parameters(ParseStringListParams { raw_output }): Parameters<ParseStringListParams>,
5479    ) -> Result<Json<StructuredOutput>, ErrorData> {
5480        match llm_output_parser::parse_string_list(&raw_output) {
5481            Ok(list) => Ok(structured_output(serde_json::json!({
5482                "ok": true,
5483                "values": list,
5484            }))),
5485            Err(e) => Ok(json_to_output(&serde_json::json!({
5486                "ok": false,
5487                "error": e.to_string(),
5488            }))?),
5489        }
5490    }
5491
5492    #[cfg(feature = "llm-parser")]
5493    #[tool(
5494        description = "Parse a choice from raw LLM output given a list of valid options. Handles extra text, casing differences, and partial matches. Returns the matched option or an error.",
5495        annotations(read_only_hint = true)
5496    )]
5497    fn sm_parse_choice(
5498        &self,
5499        Parameters(ParseChoiceParams {
5500            raw_output,
5501            options,
5502        }): Parameters<ParseChoiceParams>,
5503    ) -> Result<Json<StructuredOutput>, ErrorData> {
5504        let opt_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
5505        match llm_output_parser::parse_choice(&raw_output, &opt_refs) {
5506            Ok(choice) => Ok(structured_output(serde_json::json!({
5507                "ok": true,
5508                "choice": choice,
5509            }))),
5510            Err(e) => Ok(json_to_output(&serde_json::json!({
5511                "ok": false,
5512                "error": e.to_string(),
5513                "options": options,
5514            }))?),
5515        }
5516    }
5517
5518    #[cfg(feature = "llm-parser")]
5519    #[tool(
5520        description = "Parse a number from raw LLM output. Handles text like 'The answer is 42' or 'Score: 0.85'. Returns the number as a string.",
5521        annotations(read_only_hint = true)
5522    )]
5523    fn sm_parse_number(
5524        &self,
5525        Parameters(ParseNumberParams { raw_output }): Parameters<ParseNumberParams>,
5526    ) -> Result<Json<StructuredOutput>, ErrorData> {
5527        match llm_output_parser::parse_number::<f64>(&raw_output) {
5528            Ok(n) if n.is_finite() => Ok(structured_output(serde_json::json!({
5529                "ok": true,
5530                "number": n,
5531            }))),
5532            Ok(_) => Ok(structured_output(serde_json::json!({
5533                "ok": false,
5534                "error": "Parsed number must be finite",
5535            }))),
5536            Err(e) => Ok(json_to_output(&serde_json::json!({
5537                "ok": false,
5538                "error": e.to_string(),
5539            }))?),
5540        }
5541    }
5542}
5543
5544#[derive(Debug, Clone, PartialEq, Eq)]
5545pub enum GraphPathOutcome {
5546    Found(Vec<String>),
5547    NoPathWithinCompleteSearch,
5548    BudgetExceeded,
5549    InvalidEndpoint(String),
5550}
5551
5552/// Adapter-owned BFS whose terminal states are explicit. The underlying graph
5553/// API returns `None` for both exhausted and bounded searches, so callers must
5554/// not consume it directly when correctness depends on that distinction.
5555fn typed_graph_path(
5556    graph: &dyn semantic_memory::GraphView,
5557    from: &str,
5558    to: &str,
5559    max_depth: usize,
5560) -> Result<GraphPathOutcome, semantic_memory::MemoryError> {
5561    use semantic_memory::GraphDirection;
5562    let from_edges = graph.neighbors(from, GraphDirection::Both, 1)?;
5563    if from_edges.is_empty() {
5564        return Ok(GraphPathOutcome::InvalidEndpoint(from.to_string()));
5565    }
5566    let to_edges = graph.neighbors(to, GraphDirection::Both, 1)?;
5567    if to_edges.is_empty() {
5568        return Ok(GraphPathOutcome::InvalidEndpoint(to.to_string()));
5569    }
5570    if from == to {
5571        return Ok(GraphPathOutcome::Found(vec![from.to_string()]));
5572    }
5573
5574    let mut visited = HashSet::from([from.to_string()]);
5575    let mut parents = HashMap::<String, String>::new();
5576    let mut queue = VecDeque::from([(from.to_string(), 0usize)]);
5577    let mut hit_depth_budget = false;
5578    while let Some((node, depth)) = queue.pop_front() {
5579        let edges = graph.neighbors(&node, GraphDirection::Both, 1)?;
5580        for edge in edges {
5581            let next = if edge.source == node {
5582                edge.target
5583            } else {
5584                edge.source
5585            };
5586            if visited.contains(&next) {
5587                continue;
5588            }
5589            if depth >= max_depth {
5590                hit_depth_budget = true;
5591                continue;
5592            }
5593            visited.insert(next.clone());
5594            parents.insert(next.clone(), node.clone());
5595            if next == to {
5596                let mut path = vec![to.to_string()];
5597                let mut cursor = to.to_string();
5598                while let Some(parent) = parents.get(&cursor) {
5599                    path.push(parent.clone());
5600                    if parent == from {
5601                        break;
5602                    }
5603                    cursor = parent.clone();
5604                }
5605                path.reverse();
5606                return Ok(GraphPathOutcome::Found(path));
5607            }
5608            if visited.len() >= 500 {
5609                return Ok(GraphPathOutcome::BudgetExceeded);
5610            }
5611            queue.push_back((next, depth + 1));
5612        }
5613    }
5614    Ok(if hit_depth_budget {
5615        GraphPathOutcome::BudgetExceeded
5616    } else {
5617        GraphPathOutcome::NoPathWithinCompleteSearch
5618    })
5619}
5620
5621#[cfg(test)]
5622#[allow(clippy::items_after_test_module)]
5623mod correctness_contract_tests {
5624    use super::*;
5625    use crate::bridge::{BridgeConfig, EmbedderBackend};
5626
5627    fn structured_value(body: &Json<StructuredOutput>) -> serde_json::Value {
5628        serde_json::to_value(&body.0).expect("structured tool output JSON")
5629    }
5630
5631    #[test]
5632    fn structured_output_preserves_object_shape_and_populates_protocol_field() {
5633        use rmcp::handler::server::tool::IntoCallToolResult;
5634
5635        let output = structured_output(serde_json::json!({
5636            "ok": true,
5637            "fact_id": "fact-1",
5638        }));
5639        let result = output.into_call_tool_result().expect("tool result");
5640
5641        assert_eq!(
5642            result.structured_content,
5643            Some(serde_json::json!({"ok": true, "fact_id": "fact-1"}))
5644        );
5645        assert_eq!(
5646            result.content.len(),
5647            1,
5648            "rmcp emits compatibility text content"
5649        );
5650        assert_eq!(result.is_error, Some(false));
5651    }
5652
5653    #[test]
5654    fn structured_output_wraps_non_object_values() {
5655        assert_eq!(
5656            structured_value(&structured_output(serde_json::json!(["a", "b"]))),
5657            serde_json::json!({"value": ["a", "b"]})
5658        );
5659    }
5660
5661    #[cfg(feature = "llm-parser")]
5662    #[test]
5663    fn parse_number_rejects_non_finite_values_without_panicking() {
5664        let dir = tempfile::tempdir().unwrap();
5665        let server = SemanticMemoryServer::new(
5666            MemoryBridge::open(BridgeConfig {
5667                memory_dir: dir.path().to_path_buf(),
5668                embedder_backend: EmbedderBackend::Mock,
5669                embedding_url: String::new(),
5670                embedding_model: "mock".into(),
5671                embedding_dims: 768,
5672                turbo_quant_enabled: false,
5673                turbo_quant_bits: None,
5674                turbo_quant_projections: None,
5675            })
5676            .unwrap(),
5677            "full",
5678        );
5679
5680        let output = server
5681            .sm_parse_number(Parameters(ParseNumberParams {
5682                raw_output: "NaN".into(),
5683            }))
5684            .expect("non-finite parse must return a structured result");
5685        let value = structured_value(&output);
5686
5687        assert_eq!(value["ok"], false);
5688        assert!(value["error"]
5689            .as_str()
5690            .is_some_and(|error| error.contains("finite")));
5691    }
5692
5693    #[test]
5694    fn every_registered_tool_declares_object_output_schema() {
5695        let tools = SemanticMemoryServer::tool_router().list_all();
5696        assert!(!tools.is_empty(), "tool router must register tools");
5697
5698        let missing: Vec<_> = tools
5699            .iter()
5700            .filter_map(|tool| {
5701                let schema = tool.output_schema.as_deref()?;
5702                (schema.get("type").and_then(serde_json::Value::as_str) != Some("object"))
5703                    .then(|| tool.name.to_string())
5704            })
5705            .chain(
5706                tools
5707                    .iter()
5708                    .filter(|tool| tool.output_schema.is_none())
5709                    .map(|tool| tool.name.to_string()),
5710            )
5711            .collect();
5712
5713        assert!(
5714            missing.is_empty(),
5715            "every tool must expose a root object outputSchema; missing/invalid: {missing:?}"
5716        );
5717    }
5718    use semantic_memory::{GraphDirection, GraphEdge, GraphEdgeType, GraphView, MemoryError};
5719
5720    struct TestGraph(Vec<(&'static str, &'static str)>);
5721
5722    fn add_fact_params(content: &str, idempotency_key: Option<&str>) -> AddFactParams {
5723        AddFactParams {
5724            content: content.into(),
5725            namespace: "authority-test".into(),
5726            source: Some("tests/authority-source.md".into()),
5727            extract_entities: Some(false),
5728            memory_kind: Some("durable_fact".into()),
5729            sensitivity: Some("internal".into()),
5730            evidence_refs: Some(vec!["evidence:authority-test".into()]),
5731            idempotency_key: idempotency_key.map(str::to_string),
5732        }
5733    }
5734
5735    fn invoke_add_fact(
5736        runtime: &tokio::runtime::Runtime,
5737        server: &SemanticMemoryServer,
5738        params: AddFactParams,
5739    ) -> Result<Json<StructuredOutput>, ErrorData> {
5740        runtime.block_on(async {
5741            tokio::task::block_in_place(|| server.sm_add_fact(Parameters(params)))
5742        })
5743    }
5744
5745    fn invoke_record_outcome(
5746        runtime: &tokio::runtime::Runtime,
5747        server: &SemanticMemoryServer,
5748        query: &str,
5749        outcome: &str,
5750    ) -> serde_json::Value {
5751        let body = runtime
5752            .block_on(async {
5753                tokio::task::block_in_place(|| {
5754                    server.sm_record_outcome(Parameters(RecordOutcomeParams {
5755                        query: query.to_string(),
5756                        outcome: outcome.to_string(),
5757                    }))
5758                })
5759            })
5760            .expect("record routing outcome");
5761        structured_value(&body)
5762    }
5763
5764    #[test]
5765    fn routing_feedback_batch_persists_and_survives_restart() {
5766        use semantic_memory::rl_routing::{is_trained, route_with_policy};
5767        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
5768
5769        let dir = tempfile::tempdir().unwrap();
5770        let memory_dir = dir.path().to_path_buf();
5771        let make_config = || BridgeConfig {
5772            memory_dir: memory_dir.clone(),
5773            embedder_backend: EmbedderBackend::Mock,
5774            embedding_url: String::new(),
5775            embedding_model: "mock".into(),
5776            embedding_dims: 768,
5777            turbo_quant_enabled: false,
5778            turbo_quant_bits: None,
5779            turbo_quant_projections: None,
5780        };
5781        let runtime = tokio::runtime::Runtime::new().unwrap();
5782        let server = SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5783        let query = "compare rust vs python performance";
5784
5785        for index in 0..ROUTING_POLICY_PERSIST_BATCH {
5786            let receipt = invoke_record_outcome(&runtime, &server, query, "bad");
5787            assert_eq!(
5788                receipt["policy_state"]["persisted"],
5789                index + 1 == ROUTING_POLICY_PERSIST_BATCH
5790            );
5791        }
5792
5793        let persisted = runtime
5794            .block_on(server.bridge.store.load_routing_policy())
5795            .unwrap()
5796            .expect("batched routing policy persisted");
5797        assert_eq!(persisted.trained_examples, ROUTING_POLICY_PERSIST_BATCH);
5798        assert!(persisted.last_updated.is_some());
5799        assert!(is_trained(&persisted));
5800
5801        let profile = QueryProfile::from_query(query);
5802        let heuristic = RetrievalRouter::default().route(&profile);
5803        let before_restart = route_with_policy(&persisted, &profile);
5804        assert_ne!(before_restart.rerank_fine, heuristic.rerank_fine);
5805
5806        let policy_json = runtime
5807            .block_on(async { tokio::task::block_in_place(|| server.sm_get_routing_policy()) })
5808            .unwrap();
5809        let policy_json: serde_json::Value = structured_value(&policy_json);
5810        assert_eq!(
5811            policy_json["policy"]["training_examples_count"],
5812            ROUTING_POLICY_PERSIST_BATCH
5813        );
5814        assert!(policy_json["policy"]["last_updated"].is_string());
5815
5816        drop(server);
5817        let restarted =
5818            SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5819        let loaded = runtime
5820            .block_on(restarted.bridge.store.load_routing_policy())
5821            .unwrap()
5822            .expect("routing policy loaded after restart");
5823        let after_restart = route_with_policy(&loaded, &profile);
5824        assert_eq!(after_restart.bm25_coarse, before_restart.bm25_coarse);
5825        assert_eq!(after_restart.vector_medium, before_restart.vector_medium);
5826        assert_eq!(after_restart.rerank_fine, before_restart.rerank_fine);
5827        assert_eq!(after_restart.decoder, before_restart.decoder);
5828    }
5829
5830    fn governed_decision_server(
5831        scopes: semantic_memory::AuthorityScopesV1,
5832    ) -> (SemanticMemoryServer, tokio::runtime::Runtime, String) {
5833        use semantic_memory::{
5834            AuthorityPermit, ElevationRequirementV1, NamespaceScopeV1, OriginAuthorityLabelV1,
5835            OriginClassV1, OriginRiskV1, RevocationStatusV1, SubjectPrincipalV1,
5836        };
5837
5838        let dir = tempfile::tempdir().unwrap();
5839        let bridge = MemoryBridge::open(BridgeConfig {
5840            memory_dir: dir.path().to_path_buf(),
5841            embedder_backend: EmbedderBackend::Mock,
5842            embedding_url: String::new(),
5843            embedding_model: "mock".into(),
5844            embedding_dims: 768,
5845            turbo_quant_enabled: false,
5846            turbo_quant_bits: None,
5847            turbo_quant_projections: None,
5848        })
5849        .unwrap();
5850        let runtime = tokio::runtime::Runtime::new().unwrap();
5851        let origin = OriginAuthorityLabelV1::new(
5852            OriginClassV1::UserStatement,
5853            "principal:writer",
5854            "governed-decision-test",
5855            "blake3:governed-decision-source",
5856            OriginRiskV1::Low,
5857            scopes,
5858            ElevationRequirementV1::ExplicitOperatorApproval,
5859            None,
5860            RevocationStatusV1::Active,
5861            vec!["principal:alice".into(), "team:medical".into()],
5862        )
5863        .unwrap()
5864        .with_subject_principal(SubjectPrincipalV1::new("principal:patient").unwrap())
5865        .with_resource_scope(NamespaceScopeV1::exact("medical"));
5866        let fact_id = runtime
5867            .block_on(
5868                bridge.store.authority().append(
5869                    AuthorityPermit::with_evidence(
5870                        "principal:writer",
5871                        "governed-decision-test",
5872                        AuthorityPermit::APPEND_CAPABILITY,
5873                        vec!["evidence:test".into()],
5874                    )
5875                    .with_origin(origin),
5876                    "governed-decision".into(),
5877                    "medical".into(),
5878                    "CONFIDENTIAL_MEMORY_SENTINEL".into(),
5879                    None,
5880                ),
5881            )
5882            .unwrap()
5883            .affected_ids[0]
5884            .clone();
5885        (SemanticMemoryServer::new(bridge, "full"), runtime, fact_id)
5886    }
5887
5888    fn decision_params(fact_id: &str) -> GovernedDecisionParams {
5889        GovernedDecisionParams {
5890            fact_id: fact_id.into(),
5891            caller: "principal:alice".into(),
5892            subject: "principal:patient".into(),
5893            audiences: vec!["principal:alice".into(), "team:medical".into()],
5894            scope: GovernedNamespaceScopeParams {
5895                namespace: "medical".into(),
5896                domain: None,
5897                workspace_id: None,
5898                repo_id: None,
5899            },
5900            delegation_or_elevation: None,
5901        }
5902    }
5903
5904    fn invoke_decision(
5905        runtime: &tokio::runtime::Runtime,
5906        server: &SemanticMemoryServer,
5907        purpose: semantic_memory::GovernedAccessPurposeV1,
5908        params: GovernedDecisionParams,
5909    ) -> serde_json::Value {
5910        let body = runtime
5911            .block_on(async {
5912                tokio::task::block_in_place(|| match purpose {
5913                    semantic_memory::GovernedAccessPurposeV1::Assertion => {
5914                        server.sm_decide_assertion_authority(Parameters(params))
5915                    }
5916                    semantic_memory::GovernedAccessPurposeV1::Action => {
5917                        server.sm_decide_action_authority(Parameters(params))
5918                    }
5919                    _ => unreachable!(),
5920                })
5921            })
5922            .unwrap();
5923        structured_value(&body)
5924    }
5925
5926    #[test]
5927    fn recall_authority_does_not_imply_assertion_or_action_authority_and_denials_omit_content() {
5928        use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5929        let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5930            recall: AuthorityScopeV1::Audience,
5931            assertion: AuthorityScopeV1::Denied,
5932            action: AuthorityScopeV1::Denied,
5933        });
5934
5935        for purpose in [
5936            GovernedAccessPurposeV1::Assertion,
5937            GovernedAccessPurposeV1::Action,
5938        ] {
5939            let receipt = invoke_decision(&runtime, &server, purpose, decision_params(&fact_id));
5940            assert_eq!(receipt["schema_version"], "origin_authority_decision_v1");
5941            assert_eq!(receipt["allowed"], false);
5942            assert_eq!(
5943                receipt["purpose"],
5944                match purpose {
5945                    GovernedAccessPurposeV1::Assertion => "assertion",
5946                    GovernedAccessPurposeV1::Action => "action",
5947                    _ => unreachable!(),
5948                }
5949            );
5950            assert!(receipt["reasons"]
5951                .as_array()
5952                .unwrap()
5953                .iter()
5954                .any(|reason| { reason == "scope_or_principal_denied" }));
5955            let serialized = receipt.to_string();
5956            assert!(!serialized.contains("CONFIDENTIAL_MEMORY_SENTINEL"));
5957            for forbidden in ["fact", "content", "origin", "memory"] {
5958                assert!(receipt.get(forbidden).is_none(), "leaked field {forbidden}");
5959            }
5960        }
5961    }
5962
5963    #[test]
5964    fn assertion_decision_honors_delegation_expiry_audience_and_namespace() {
5965        use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5966        let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5967            recall: AuthorityScopeV1::Audience,
5968            assertion: AuthorityScopeV1::Audience,
5969            action: AuthorityScopeV1::Audience,
5970        });
5971        let mut params = decision_params(&fact_id);
5972        params.delegation_or_elevation = Some(GovernedLeaseParams {
5973            lease_id: "lease:assertion".into(),
5974            delegator: "principal:patient".into(),
5975            delegatee: "principal:alice".into(),
5976            purposes: vec![GovernedAccessPurposeParam::Assertion],
5977            scope: params.scope.clone(),
5978            audiences: vec!["team:medical".into()],
5979            expires_at: "2999-01-01T00:00:00Z".into(),
5980            revoked: false,
5981            elevation: false,
5982        });
5983        assert_eq!(
5984            invoke_decision(
5985                &runtime,
5986                &server,
5987                GovernedAccessPurposeV1::Assertion,
5988                params.clone()
5989            )["allowed"],
5990            true
5991        );
5992
5993        let mut expired = params.clone();
5994        expired.delegation_or_elevation.as_mut().unwrap().expires_at =
5995            "2000-01-01T00:00:00Z".into();
5996        let receipt = invoke_decision(
5997            &runtime,
5998            &server,
5999            GovernedAccessPurposeV1::Assertion,
6000            expired,
6001        );
6002        assert_eq!(receipt["allowed"], false);
6003        assert!(receipt["reasons"]
6004            .as_array()
6005            .unwrap()
6006            .contains(&serde_json::json!("delegation_expired")));
6007
6008        let mut wrong_audience = params.clone();
6009        wrong_audience.audiences = vec!["team:other".into()];
6010        let receipt = invoke_decision(
6011            &runtime,
6012            &server,
6013            GovernedAccessPurposeV1::Assertion,
6014            wrong_audience,
6015        );
6016        assert_eq!(receipt["allowed"], false);
6017        assert!(receipt["reasons"]
6018            .as_array()
6019            .unwrap()
6020            .contains(&serde_json::json!("audience_intersection_empty")));
6021
6022        let mut wrong_namespace = params;
6023        wrong_namespace.scope.namespace = "other".into();
6024        let receipt = invoke_decision(
6025            &runtime,
6026            &server,
6027            GovernedAccessPurposeV1::Assertion,
6028            wrong_namespace,
6029        );
6030        assert_eq!(receipt["allowed"], false);
6031        assert!(receipt["reasons"]
6032            .as_array()
6033            .unwrap()
6034            .contains(&serde_json::json!("namespace_scope_mismatch")));
6035    }
6036
6037    #[test]
6038    fn action_decision_honors_live_elevation_without_converting_it_to_admin_access() {
6039        use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
6040        let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
6041            recall: AuthorityScopeV1::Audience,
6042            assertion: AuthorityScopeV1::Denied,
6043            action: AuthorityScopeV1::Audience,
6044        });
6045        let mut params = decision_params(&fact_id);
6046        params.delegation_or_elevation = Some(GovernedLeaseParams {
6047            lease_id: "lease:elevation".into(),
6048            delegator: "principal:patient".into(),
6049            delegatee: "principal:alice".into(),
6050            purposes: vec![GovernedAccessPurposeParam::Admin],
6051            scope: params.scope.clone(),
6052            audiences: vec![],
6053            expires_at: "2999-01-01T00:00:00Z".into(),
6054            revoked: false,
6055            elevation: true,
6056        });
6057        let receipt = invoke_decision(&runtime, &server, GovernedAccessPurposeV1::Action, params);
6058        assert_eq!(receipt["allowed"], true);
6059        assert_eq!(receipt["purpose"], "action");
6060        assert_eq!(receipt["lease_id"], "lease:elevation");
6061    }
6062    impl GraphView for TestGraph {
6063        fn neighbors(
6064            &self,
6065            node: &str,
6066            _: GraphDirection,
6067            _: usize,
6068        ) -> Result<Vec<GraphEdge>, MemoryError> {
6069            Ok(self
6070                .0
6071                .iter()
6072                .filter(|(a, b)| *a == node || *b == node)
6073                .map(|(a, b)| GraphEdge {
6074                    source: (*a).into(),
6075                    target: (*b).into(),
6076                    edge_type: GraphEdgeType::Entity {
6077                        relation: "test".into(),
6078                    },
6079                    weight: 1.0,
6080                    metadata: None,
6081                })
6082                .collect())
6083        }
6084        fn path(&self, _: &str, _: &str, _: usize) -> Result<Option<Vec<String>>, MemoryError> {
6085            unreachable!()
6086        }
6087    }
6088
6089    #[test]
6090    fn graph_path_outcomes_do_not_conflate_exhaustion_and_budget() {
6091        let graph = TestGraph(vec![("a", "b"), ("b", "c"), ("x", "y")]);
6092        assert_eq!(
6093            typed_graph_path(&graph, "a", "c", 2).unwrap(),
6094            GraphPathOutcome::Found(vec!["a".into(), "b".into(), "c".into()])
6095        );
6096        assert_eq!(
6097            typed_graph_path(&graph, "a", "c", 1).unwrap(),
6098            GraphPathOutcome::BudgetExceeded
6099        );
6100        assert_eq!(
6101            typed_graph_path(&graph, "a", "x", 5).unwrap(),
6102            GraphPathOutcome::NoPathWithinCompleteSearch
6103        );
6104        assert_eq!(
6105            typed_graph_path(&graph, "missing", "x", 5).unwrap(),
6106            GraphPathOutcome::InvalidEndpoint("missing".into())
6107        );
6108    }
6109
6110    #[test]
6111    fn sm_add_fact_uses_authority_append_and_preserves_output_contract() {
6112        let dir = tempfile::tempdir().unwrap();
6113        let server = SemanticMemoryServer::new(
6114            MemoryBridge::open(BridgeConfig {
6115                memory_dir: dir.path().to_path_buf(),
6116                embedder_backend: EmbedderBackend::Mock,
6117                embedding_url: String::new(),
6118                embedding_model: "mock".into(),
6119                embedding_dims: 768,
6120                turbo_quant_enabled: false,
6121                turbo_quant_bits: None,
6122                turbo_quant_projections: None,
6123            })
6124            .unwrap(),
6125            "full",
6126        );
6127        let runtime = tokio::runtime::Runtime::new().unwrap();
6128        server
6129            .bridge
6130            .store
6131            .authority()
6132            .set_fault(Some(semantic_memory::AuthorityFaultStage::BeforeAppend));
6133
6134        let error = invoke_add_fact(
6135            &runtime,
6136            &server,
6137            add_fact_params("must pass through authority", Some("mcp-authority-fault")),
6138        )
6139        .err()
6140        .expect("expected tool error");
6141        assert!(error.message.contains("authority fault injected"));
6142        assert_eq!(
6143            runtime
6144                .block_on(server.bridge.store.stats())
6145                .unwrap()
6146                .total_facts,
6147            0
6148        );
6149
6150        let body = invoke_add_fact(
6151            &runtime,
6152            &server,
6153            add_fact_params("must pass through authority", Some("mcp-authority-success")),
6154        )
6155        .unwrap();
6156        let json: serde_json::Value = structured_value(&body);
6157        assert_eq!(json["ok"], true);
6158        assert_eq!(json["namespace"], "authority-test");
6159        assert_eq!(json["message"], "Fact added successfully");
6160        assert!(json["fact_id"].as_str().is_some());
6161        assert_eq!(json.as_object().unwrap().len(), 5);
6162        assert!(json["receipt"]["receipt_id"].as_str().is_some());
6163        assert!(json["receipt"]["recorded_at"].as_str().is_some());
6164        assert_eq!(json["receipt"]["tool"], "sm_add_fact");
6165
6166        let fact_id = json["fact_id"].as_str().expect("fact id");
6167        let fact = runtime
6168            .block_on(server.bridge.store.get_fact(fact_id))
6169            .expect("read stored fact")
6170            .expect("stored fact exists");
6171        let metadata = fact.metadata.expect("typed metadata must persist");
6172        assert_eq!(metadata["memory_kind"], "durable_fact");
6173        assert_eq!(metadata["sensitivity"], "internal");
6174        assert_eq!(
6175            metadata["evidence_refs"],
6176            serde_json::json!(["evidence:authority-test"])
6177        );
6178        assert!(metadata["authority"]["operation_id"].as_str().is_some());
6179    }
6180
6181    #[test]
6182    fn sm_add_fact_replays_explicit_key_but_unkeyed_identical_writes_remain_distinct() {
6183        let dir = tempfile::tempdir().unwrap();
6184        let server = SemanticMemoryServer::new(
6185            MemoryBridge::open(BridgeConfig {
6186                memory_dir: dir.path().to_path_buf(),
6187                embedder_backend: EmbedderBackend::Mock,
6188                embedding_url: String::new(),
6189                embedding_model: "mock".into(),
6190                embedding_dims: 768,
6191                turbo_quant_enabled: false,
6192                turbo_quant_bits: None,
6193                turbo_quant_projections: None,
6194            })
6195            .unwrap(),
6196            "full",
6197        );
6198        let runtime = tokio::runtime::Runtime::new().unwrap();
6199
6200        let first = invoke_add_fact(
6201            &runtime,
6202            &server,
6203            add_fact_params("retry-safe", Some("caller-retry-key")),
6204        )
6205        .unwrap();
6206        let retry = invoke_add_fact(
6207            &runtime,
6208            &server,
6209            add_fact_params("retry-safe", Some("caller-retry-key")),
6210        )
6211        .unwrap();
6212        // Compare fact_id rather than full JSON: receipt_id and recorded_at
6213        // are unique per call by design, so full-string equality would fail.
6214        let first_fact = structured_value(&first)["fact_id"]
6215            .as_str()
6216            .unwrap()
6217            .to_string();
6218        let retry_fact = structured_value(&retry)["fact_id"]
6219            .as_str()
6220            .unwrap()
6221            .to_string();
6222        assert_eq!(first_fact, retry_fact);
6223
6224        let unkeyed_a = invoke_add_fact(
6225            &runtime,
6226            &server,
6227            add_fact_params("legitimate duplicate", None),
6228        )
6229        .unwrap();
6230        let unkeyed_b = invoke_add_fact(
6231            &runtime,
6232            &server,
6233            add_fact_params("legitimate duplicate", None),
6234        )
6235        .unwrap();
6236        let fact_id = |body: &Json<StructuredOutput>| {
6237            structured_value(body)["fact_id"]
6238                .as_str()
6239                .unwrap()
6240                .to_string()
6241        };
6242        assert_ne!(fact_id(&unkeyed_a), fact_id(&unkeyed_b));
6243        assert_eq!(
6244            runtime
6245                .block_on(server.bridge.store.stats())
6246                .unwrap()
6247                .total_facts,
6248            3
6249        );
6250    }
6251
6252    #[test]
6253    fn sm_delete_fact_uses_governed_forgetting_closure() {
6254        let dir = tempfile::tempdir().unwrap();
6255        let server = SemanticMemoryServer::new(
6256            MemoryBridge::open(BridgeConfig {
6257                memory_dir: dir.path().to_path_buf(),
6258                embedder_backend: EmbedderBackend::Mock,
6259                embedding_url: String::new(),
6260                embedding_model: "mock".into(),
6261                embedding_dims: 768,
6262                turbo_quant_enabled: false,
6263                turbo_quant_bits: None,
6264                turbo_quant_projections: None,
6265            })
6266            .unwrap(),
6267            "full",
6268        );
6269        let runtime = tokio::runtime::Runtime::new().unwrap();
6270        let added = invoke_add_fact(
6271            &runtime,
6272            &server,
6273            add_fact_params("forget through MCP", Some("mcp-forget-source")),
6274        )
6275        .unwrap();
6276        let fact_id = structured_value(&added)["fact_id"]
6277            .as_str()
6278            .unwrap()
6279            .to_string();
6280        let response = runtime
6281            .block_on(async {
6282                tokio::task::block_in_place(|| {
6283                    server.sm_delete_fact(Parameters(DeleteFactParams {
6284                        fact_id: fact_id.clone(),
6285                    }))
6286                })
6287            })
6288            .unwrap();
6289        let json: serde_json::Value = structured_value(&response);
6290        assert_eq!(json["forgotten"], true);
6291        assert_eq!(json["deleted"], false);
6292        assert_eq!(
6293            json["forgetting_receipt"]["schema_version"],
6294            "forgetting_closure_receipt_v1"
6295        );
6296        let raw = runtime
6297            .block_on(server.bridge.store.get_fact_raw_compat(&fact_id))
6298            .unwrap()
6299            .unwrap();
6300        assert_eq!(raw.content, "[FORGOTTEN]");
6301
6302        let http_origin = semantic_memory::OriginAuthorityLabelV1::operator_system(
6303            "principal:semantic-memory-http",
6304            "caller:http-add",
6305        );
6306        let http_fact = runtime
6307            .block_on(
6308                server.bridge.store.authority().append(
6309                    semantic_memory::AuthorityPermit::operator_system(
6310                        "principal:semantic-memory-http",
6311                        "caller:http-add",
6312                        semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6313                    )
6314                    .with_origin(http_origin),
6315                    "cross-adapter-forget".into(),
6316                    "authority-test".into(),
6317                    "cross adapter fact".into(),
6318                    None,
6319                ),
6320            )
6321            .unwrap()
6322            .affected_ids[0]
6323            .clone();
6324        let response = runtime
6325            .block_on(async {
6326                tokio::task::block_in_place(|| {
6327                    server.sm_delete_fact(Parameters(DeleteFactParams {
6328                        fact_id: http_fact.clone(),
6329                    }))
6330                })
6331            })
6332            .unwrap();
6333        let json: serde_json::Value = structured_value(&response);
6334        assert_eq!(json["forgotten"], true);
6335        assert_eq!(
6336            runtime
6337                .block_on(server.bridge.store.get_fact_raw_compat(&http_fact))
6338                .unwrap()
6339                .unwrap()
6340                .content,
6341            "[FORGOTTEN]"
6342        );
6343    }
6344
6345    #[test]
6346    fn sm_add_fact_preserves_ephemeral_evidence_refs_admission_rule() {
6347        let dir = tempfile::tempdir().unwrap();
6348        let server = SemanticMemoryServer::new(
6349            MemoryBridge::open(BridgeConfig {
6350                memory_dir: dir.path().to_path_buf(),
6351                embedder_backend: EmbedderBackend::Mock,
6352                embedding_url: String::new(),
6353                embedding_model: "mock".into(),
6354                embedding_dims: 768,
6355                turbo_quant_enabled: false,
6356                turbo_quant_bits: None,
6357                turbo_quant_projections: None,
6358            })
6359            .unwrap(),
6360            "full",
6361        );
6362        let runtime = tokio::runtime::Runtime::new().unwrap();
6363        let mut params = add_fact_params("unsupported inference", Some("ephemeral-source-only"));
6364        params.memory_kind = Some("ephemeral_inference".into());
6365        params.evidence_refs = None;
6366
6367        let error = invoke_add_fact(&runtime, &server, params)
6368            .err()
6369            .expect("expected tool error");
6370        assert!(error.message.contains("requires evidence_refs"));
6371        assert_eq!(
6372            runtime
6373                .block_on(server.bridge.store.stats())
6374                .unwrap()
6375                .total_facts,
6376            0
6377        );
6378    }
6379
6380    #[test]
6381    fn sm_search_tool_returns_only_current_supersession_head() {
6382        let dir = tempfile::tempdir().unwrap();
6383        let bridge = MemoryBridge::open(BridgeConfig {
6384            memory_dir: dir.path().to_path_buf(),
6385            embedder_backend: EmbedderBackend::Mock,
6386            embedding_url: String::new(),
6387            embedding_model: "mock".into(),
6388            embedding_dims: 768,
6389            turbo_quant_enabled: false,
6390            turbo_quant_bits: None,
6391            turbo_quant_projections: None,
6392        })
6393        .unwrap();
6394        let runtime = tokio::runtime::Runtime::new().unwrap();
6395        let old = runtime
6396            .block_on(
6397                bridge
6398                    .store
6399                    .add_fact("state", "runtime channel is violet", None, None),
6400            )
6401            .unwrap();
6402        let new = runtime
6403            .block_on(
6404                bridge
6405                    .store
6406                    .add_fact("state", "runtime channel is saffron", None, None),
6407            )
6408            .unwrap();
6409        runtime
6410            .block_on(bridge.store.add_graph_edge(
6411                &format!("fact:{new}"),
6412                &format!("fact:{old}"),
6413                GraphEdgeType::Entity {
6414                    relation: "supersedes".into(),
6415                },
6416                1.0,
6417                None,
6418            ))
6419            .unwrap();
6420        let server = SemanticMemoryServer::new(bridge, "full");
6421        let body = runtime
6422            .block_on(async {
6423                tokio::task::block_in_place(|| {
6424                    server.sm_search(Parameters(SearchParams {
6425                        query: "runtime channel".into(),
6426                        top_k: Some(10),
6427                        namespaces: Some(vec!["state".into()]),
6428                    }))
6429                })
6430            })
6431            .unwrap();
6432        let body = structured_value(&body).to_string();
6433        assert!(body.contains("saffron"));
6434        assert!(!body.contains("violet"));
6435    }
6436
6437    #[test]
6438    fn witnessed_search_hydrates_complete_honest_fact_provenance() {
6439        let dir = tempfile::tempdir().unwrap();
6440        let bridge = MemoryBridge::open(BridgeConfig {
6441            memory_dir: dir.path().to_path_buf(),
6442            embedder_backend: EmbedderBackend::Mock,
6443            embedding_url: String::new(),
6444            embedding_model: "mock".into(),
6445            embedding_dims: 768,
6446            turbo_quant_enabled: false,
6447            turbo_quant_bits: None,
6448            turbo_quant_projections: None,
6449        })
6450        .unwrap();
6451        let runtime = tokio::runtime::Runtime::new().unwrap();
6452        let fact_id = runtime
6453            .block_on(bridge.store.add_fact(
6454                "provenance-test",
6455                "witnessed facts must retain their source",
6456                Some("tests/witnessed-source.md"),
6457                None,
6458            ))
6459            .unwrap();
6460        let server = SemanticMemoryServer::new(bridge, "full");
6461        let body = runtime
6462            .block_on(async {
6463                tokio::task::block_in_place(|| {
6464                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6465                        query: "witnessed facts retain source".into(),
6466                        top_k: Some(5),
6467                        namespaces: Some(vec!["provenance-test".into()]),
6468                        request_id: Some("witnessed-provenance-test".into()),
6469                        retrieval_mode: None,
6470                        replay_mode: None,
6471                    }))
6472                })
6473            })
6474            .unwrap();
6475        let json: serde_json::Value = structured_value(&body);
6476        let hit = json["results"]
6477            .as_array()
6478            .unwrap()
6479            .iter()
6480            .find(|hit| hit["memory_id"] == format!("fact:{fact_id}"))
6481            .expect("the stored fact is an injectible witnessed result");
6482
6483        assert_eq!(hit["namespace"], "provenance-test");
6484        assert_eq!(hit["source"], "tests/witnessed-source.md");
6485        assert_eq!(hit["trust"], "persisted_unjudged");
6486        assert_eq!(hit["state"], "current");
6487        assert_eq!(
6488            hit["retrieval_receipt_ref"],
6489            format!("receipt:{}", json["receipt_id"].as_str().unwrap())
6490        );
6491    }
6492
6493    #[test]
6494    fn witnessed_search_omits_noninjectible_fact_without_source() {
6495        let dir = tempfile::tempdir().unwrap();
6496        let bridge = MemoryBridge::open(BridgeConfig {
6497            memory_dir: dir.path().to_path_buf(),
6498            embedder_backend: EmbedderBackend::Mock,
6499            embedding_url: String::new(),
6500            embedding_model: "mock".into(),
6501            embedding_dims: 768,
6502            turbo_quant_enabled: false,
6503            turbo_quant_bits: None,
6504            turbo_quant_projections: None,
6505        })
6506        .unwrap();
6507        let runtime = tokio::runtime::Runtime::new().unwrap();
6508        let _fact_id = runtime
6509            .block_on(bridge.store.add_fact(
6510                "provenance-test",
6511                "source-less facts cannot be injected autonomously",
6512                None,
6513                None,
6514            ))
6515            .unwrap();
6516        let server = SemanticMemoryServer::new(bridge, "full");
6517        let body = runtime
6518            .block_on(async {
6519                tokio::task::block_in_place(|| {
6520                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6521                        query: "source-less facts autonomous injection".into(),
6522                        top_k: Some(5),
6523                        namespaces: Some(vec!["provenance-test".into()]),
6524                        request_id: Some("witnessed-source-less-test".into()),
6525                        retrieval_mode: None,
6526                        replay_mode: None,
6527                    }))
6528                })
6529            })
6530            .unwrap();
6531        let json: serde_json::Value = structured_value(&body);
6532        assert!(json["results"].as_array().unwrap().is_empty());
6533    }
6534
6535    #[test]
6536    fn sm_search_witnessed_exposes_authority_state_and_vector_evidence() {
6537        let dir = tempfile::tempdir().unwrap();
6538        let bridge = MemoryBridge::open(BridgeConfig {
6539            memory_dir: dir.path().to_path_buf(),
6540            embedder_backend: EmbedderBackend::Mock,
6541            embedding_url: String::new(),
6542            embedding_model: "mock".into(),
6543            embedding_dims: 768,
6544            turbo_quant_enabled: false,
6545            turbo_quant_bits: None,
6546            turbo_quant_projections: None,
6547        })
6548        .unwrap();
6549        let runtime = tokio::runtime::Runtime::new().unwrap();
6550        let permit = semantic_memory::AuthorityPermit::operator_system(
6551            "witness-principal",
6552            "witness-caller",
6553            semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6554        );
6555        let receipt = runtime
6556            .block_on(bridge.store.authority().append(
6557                permit,
6558                "witnessed-state-1".into(),
6559                "stateful".into(),
6560                "governed witnessed state should expose".into(),
6561                Some("tests/witnessed-state.md".into()),
6562            ))
6563            .unwrap();
6564
6565        let server = SemanticMemoryServer::new(bridge, "lean");
6566        let body = runtime
6567            .block_on(async {
6568                tokio::task::block_in_place(|| {
6569                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6570                        query: "governed witnessed state should expose".into(),
6571                        top_k: Some(5),
6572                        namespaces: Some(vec!["stateful".into()]),
6573                        request_id: Some("witnessed-state-request".into()),
6574                        retrieval_mode: None,
6575                        replay_mode: None,
6576                    }))
6577                })
6578            })
6579            .unwrap();
6580        let json: serde_json::Value = structured_value(&body);
6581
6582        assert_eq!(json["retrieval_mode"], "hybrid");
6583        assert!(json["authority"]["snapshot_id"].as_str().is_some());
6584        assert!(
6585            json["authority"]["retrieval_epoch"].as_u64().is_some()
6586                || json["authority"]["retrieval_epoch"].as_str().is_some(),
6587            "authoritative retrieval epoch should be surfaced"
6588        );
6589        assert!(json["current_snapshot_id"].as_str().is_some());
6590        assert!(
6591            json["retrieval_epoch"].as_u64().is_some()
6592                || json["retrieval_epoch"].as_str().is_some(),
6593            "top-level retrieval epoch should be surfaced"
6594        );
6595
6596        assert_eq!(json["authority"]["status"], "Applied");
6597        assert_eq!(
6598            json["stage_outcomes"]["authority_snapshot"]["outcome"],
6599            "Applied"
6600        );
6601
6602        let hit = json["results"]
6603            .as_array()
6604            .unwrap()
6605            .iter()
6606            .find(|hit| {
6607                hit.get("content")
6608                    .and_then(|value| value.as_str())
6609                    .is_some_and(|content| content.contains("governed witnessed state"))
6610            })
6611            .expect("governed witnessed result should be returned");
6612
6613        assert!(hit["cosine_similarity"].as_f64().is_some());
6614        assert_eq!(
6615            hit["source"],
6616            serde_json::json!("tests/witnessed-state.md"),
6617            "witnessed path should retain source"
6618        );
6619        assert_eq!(
6620            json["ordered_results"][0]["result_id"],
6621            format!("fact:{}", receipt.affected_ids[0])
6622        );
6623    }
6624
6625    #[test]
6626    fn sm_search_witnessed_accepts_general_retrieval_modes() {
6627        let dir = tempfile::tempdir().unwrap();
6628        let bridge = MemoryBridge::open(BridgeConfig {
6629            memory_dir: dir.path().to_path_buf(),
6630            embedder_backend: EmbedderBackend::Mock,
6631            embedding_url: String::new(),
6632            embedding_model: "mock".into(),
6633            embedding_dims: 768,
6634            turbo_quant_enabled: false,
6635            turbo_quant_bits: None,
6636            turbo_quant_projections: None,
6637        })
6638        .unwrap();
6639        let rt = tokio::runtime::Runtime::new().unwrap();
6640        rt.block_on(
6641            bridge
6642                .store
6643                .add_fact("modes", "alpha beta gamma", Some("test"), None),
6644        )
6645        .unwrap();
6646        let server = SemanticMemoryServer::new(bridge, "full");
6647        for retrieval_mode in [
6648            RetrievalModeParam::Hybrid,
6649            RetrievalModeParam::FtsOnly,
6650            RetrievalModeParam::VectorOnly,
6651        ] {
6652            let body = rt
6653                .block_on(async {
6654                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6655                        query: "alpha beta".into(),
6656                        top_k: Some(5),
6657                        namespaces: Some(vec!["modes".into()]),
6658                        request_id: None,
6659                        retrieval_mode: Some(retrieval_mode),
6660                        replay_mode: None,
6661                    }))
6662                })
6663                .unwrap();
6664            let json: serde_json::Value = structured_value(&body);
6665            assert_eq!(json["ok"], true);
6666            assert!(json["receipt_id"].is_string());
6667            assert_eq!(
6668                json["retrieval_mode"],
6669                serde_json::to_value(retrieval_mode).unwrap()
6670            );
6671        }
6672    }
6673
6674    #[test]
6675    fn witnessed_search_opt_in_enables_complete_replay() {
6676        let dir = tempfile::tempdir().unwrap();
6677        let bridge = MemoryBridge::open(BridgeConfig {
6678            memory_dir: dir.path().to_path_buf(),
6679            embedder_backend: EmbedderBackend::Mock,
6680            embedding_url: String::new(),
6681            embedding_model: "mock".into(),
6682            embedding_dims: 768,
6683            turbo_quant_enabled: false,
6684            turbo_quant_bits: None,
6685            turbo_quant_projections: None,
6686        })
6687        .unwrap();
6688        let runtime = tokio::runtime::Runtime::new().unwrap();
6689        runtime
6690            .block_on(bridge.store.add_fact(
6691                "stored-replay",
6692                "complete replay uses explicitly retained inputs",
6693                Some("tests/stored-replay.md"),
6694                None,
6695            ))
6696            .unwrap();
6697        let server = SemanticMemoryServer::new(bridge, "full");
6698        let body = runtime
6699            .block_on(async {
6700                server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6701                    query: "complete replay explicitly retained inputs".into(),
6702                    top_k: Some(1),
6703                    namespaces: Some(vec!["stored-replay".into()]),
6704                    request_id: Some("mcp-stored-replay".into()),
6705                    retrieval_mode: None,
6706                    replay_mode: Some(ReplayModeParam::StoreInputs),
6707                }))
6708            })
6709            .unwrap();
6710        let witnessed: serde_json::Value = structured_value(&body);
6711        assert_eq!(witnessed["complete_replay_available"], true);
6712        assert_eq!(witnessed["stage_outcomes"]["replay"]["outcome"], "Applied");
6713
6714        let replay = runtime
6715            .block_on(async {
6716                server.sm_replay_search(Parameters(ReplayStoredSearchParams {
6717                    receipt_id: "mcp-stored-replay".into(),
6718                }))
6719            })
6720            .unwrap();
6721        let replay: serde_json::Value = structured_value(&replay);
6722        assert_eq!(replay["result_ids_match"], true);
6723        assert_eq!(replay["query_embedding_digest_matches"], true);
6724    }
6725
6726    #[cfg(feature = "claim-integration")]
6727    fn claim_test_entries() -> Vec<claim_ledger::LedgerEntry> {
6728        let first = claim_ledger::LedgerEntryBuilder::new(1, None)
6729            .add_claim(
6730                "claim-1",
6731                "semantic-memory:fact:fact-1",
6732                "full",
6733                "verified claim",
6734            )
6735            .unwrap();
6736        let second = claim_ledger::LedgerEntryBuilder::new(2, Some(first.entry_digest.clone()))
6737            .add_support_judgment(
6738                "judgment-1",
6739                "claim-1",
6740                "bundle-1",
6741                claim_ledger::SupportState::Supported,
6742                "test",
6743            )
6744            .unwrap();
6745        vec![first, second]
6746    }
6747
6748    #[cfg(feature = "claim-integration")]
6749    fn write_claim_jsonl(path: &std::path::Path, entries: &[claim_ledger::LedgerEntry]) {
6750        let mut contents = String::new();
6751        for entry in entries {
6752            contents.push_str(&claim_ledger::serialize_entry(entry).unwrap());
6753            contents.push('\n');
6754        }
6755        std::fs::write(path, contents).unwrap();
6756    }
6757
6758    #[cfg(feature = "claim-integration")]
6759    #[test]
6760    fn claim_ledger_startup_rebuilds_from_verified_snapshot_and_tail() {
6761        let dir = tempfile::tempdir().unwrap();
6762        let legacy = dir.path().join("claim_ledger.jsonl");
6763        let entries = claim_test_entries();
6764        write_claim_jsonl(&legacy, &entries);
6765        let mut store = ClaimLedgerStore::open(legacy.clone());
6766        let result = store
6767            .compact(ClaimLedgerCompactionConfig {
6768                dry_run: false,
6769                max_entries: 0,
6770                max_bytes: 0,
6771                retain_tail_entries: 1,
6772                max_backups: 2,
6773            })
6774            .unwrap();
6775        assert_eq!(result["compacted"], true);
6776
6777        let reopened = ClaimLedgerStore::open(legacy);
6778        assert!(reopened.trust_enabled);
6779        assert!(reopened.snapshot.is_some());
6780        assert_eq!(reopened.entries.len(), 1);
6781        let mut index = ClaimTrustIndex::default();
6782        index.load_snapshot(reopened.snapshot.as_ref().unwrap());
6783        index.rebuild_from_ledger_incremental(&reopened.entries);
6784        assert_eq!(index.trust_for_fact("fact-1"), "supported");
6785        assert_eq!(index.last_processed_sequence, 2);
6786    }
6787
6788    #[cfg(feature = "claim-integration")]
6789    #[test]
6790    fn interrupted_compaction_files_leave_old_ledger_usable() {
6791        let dir = tempfile::tempdir().unwrap();
6792        let legacy = dir.path().join("claim_ledger.jsonl");
6793        let entries = claim_test_entries();
6794        write_claim_jsonl(&legacy, &entries);
6795        let interrupted = dir.path().join("claim_ledger_generations/.tmp-interrupted");
6796        std::fs::create_dir_all(&interrupted).unwrap();
6797        std::fs::write(interrupted.join("snapshot.json"), b"incomplete").unwrap();
6798
6799        let reopened = ClaimLedgerStore::open(legacy);
6800        assert!(reopened.trust_enabled);
6801        assert!(reopened.snapshot.is_none());
6802        assert_eq!(reopened.entries.len(), entries.len());
6803    }
6804
6805    #[cfg(feature = "claim-integration")]
6806    #[test]
6807    fn tampered_active_snapshot_disables_claim_trust() {
6808        let dir = tempfile::tempdir().unwrap();
6809        let legacy = dir.path().join("claim_ledger.jsonl");
6810        write_claim_jsonl(&legacy, &claim_test_entries());
6811        let mut store = ClaimLedgerStore::open(legacy.clone());
6812        store
6813            .compact(ClaimLedgerCompactionConfig {
6814                dry_run: false,
6815                max_entries: 0,
6816                max_bytes: 0,
6817                retain_tail_entries: 1,
6818                max_backups: 2,
6819            })
6820            .unwrap();
6821        let snapshot_path = store.path.parent().unwrap().join("snapshot.json");
6822        let mut snapshot: serde_json::Value =
6823            serde_json::from_slice(&std::fs::read(&snapshot_path).unwrap()).unwrap();
6824        snapshot["claims"][0]["normalized_claim"] = serde_json::json!("tampered");
6825        std::fs::write(&snapshot_path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
6826
6827        let reopened = ClaimLedgerStore::open(legacy);
6828        assert!(!reopened.trust_enabled);
6829        assert!(reopened.entries.is_empty());
6830    }
6831
6832    #[cfg(feature = "claim-integration")]
6833    #[test]
6834    fn compact_claim_ledger_defaults_to_dry_run_without_writes() {
6835        let dir = tempfile::tempdir().unwrap();
6836        let bridge = MemoryBridge::open(BridgeConfig {
6837            memory_dir: dir.path().to_path_buf(),
6838            embedder_backend: EmbedderBackend::Mock,
6839            embedding_url: String::new(),
6840            embedding_model: "mock".into(),
6841            embedding_dims: 768,
6842            turbo_quant_enabled: false,
6843            turbo_quant_bits: None,
6844            turbo_quant_projections: None,
6845        })
6846        .unwrap();
6847        let server = SemanticMemoryServer::new(bridge, "full");
6848        assert!(server.exposes_tool("sm_compact_claim_ledger"));
6849        {
6850            let mut store = server.claim_ledger_store.lock().unwrap();
6851            for entry in claim_test_entries() {
6852                store.append(entry).unwrap();
6853            }
6854        }
6855        let before = std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap();
6856        let response = server
6857            .sm_compact_claim_ledger(Parameters(CompactClaimLedgerParams {
6858                dry_run: None,
6859                max_entries: Some(0),
6860                max_bytes: Some(0),
6861                retain_tail_entries: Some(1),
6862                max_backups: Some(2),
6863            }))
6864            .unwrap();
6865        let response: serde_json::Value = structured_value(&response);
6866        assert_eq!(response["dry_run"], true);
6867        assert_eq!(response["compacted"], false);
6868        assert_eq!(
6869            before,
6870            std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap()
6871        );
6872        assert!(!dir
6873            .path()
6874            .join("claim_ledger.active_compaction.json")
6875            .exists());
6876        assert!(!dir.path().join("claim_ledger_generations").exists());
6877    }
6878}
6879
6880/// Build path segments with edge evidence for each hop in a path.
6881/// SM-AUD-011: Include edge type, weight, and metadata for each hop.
6882fn build_path_segments(
6883    store: &semantic_memory::MemoryStore,
6884    path: &[String],
6885) -> Vec<serde_json::Value> {
6886    let mut segments = Vec::new();
6887    if path.len() < 2 {
6888        return segments;
6889    }
6890
6891    for i in 0..path.len() - 1 {
6892        let from = &path[i];
6893        let to = &path[i + 1];
6894
6895        // Get neighbors of the current node to find the edge to the next node.
6896        let g = store.graph_view();
6897        match g.neighbors(from, semantic_memory::GraphDirection::Both, 1) {
6898            Ok(edges) => {
6899                // Find the edge that connects from -> to.
6900                let connecting = edges.iter().find(|e| {
6901                    (e.source == *from && e.target == *to) || (e.source == *to && e.target == *from)
6902                });
6903
6904                if let Some(edge) = connecting {
6905                    let edge_type_str = match &edge.edge_type {
6906                        semantic_memory::GraphEdgeType::Semantic { cosine_similarity } => {
6907                            serde_json::json!({
6908                                "type": "semantic",
6909                                "cosine_similarity": cosine_similarity,
6910                            })
6911                        }
6912                        semantic_memory::GraphEdgeType::Temporal { delta_secs } => {
6913                            serde_json::json!({
6914                                "type": "temporal",
6915                                "delta_secs": delta_secs,
6916                            })
6917                        }
6918                        semantic_memory::GraphEdgeType::Causal {
6919                            confidence,
6920                            evidence_ids,
6921                        } => {
6922                            serde_json::json!({
6923                                "type": "causal",
6924                                "confidence": confidence,
6925                                "evidence_ids": evidence_ids,
6926                            })
6927                        }
6928                        semantic_memory::GraphEdgeType::Entity { relation } => {
6929                            serde_json::json!({
6930                                "type": "entity",
6931                                "relation": relation,
6932                            })
6933                        }
6934                    };
6935
6936                    segments.push(serde_json::json!({
6937                        "source": from,
6938                        "target": to,
6939                        "edge_type": edge_type_str,
6940                        "weight": edge.weight,
6941                        "metadata": edge.metadata,
6942                    }));
6943                } else {
6944                    // No edge found between consecutive path nodes — shouldn't
6945                    // happen but handle gracefully.
6946                    segments.push(serde_json::json!({
6947                        "source": from,
6948                        "target": to,
6949                        "edge_type": null,
6950                        "weight": null,
6951                        "metadata": null,
6952                    }));
6953                }
6954            }
6955            Err(_) => {
6956                segments.push(serde_json::json!({
6957                    "source": from,
6958                    "target": to,
6959                    "edge_type": null,
6960                    "weight": null,
6961                    "metadata": null,
6962                }));
6963            }
6964        }
6965    }
6966
6967    segments
6968}
6969
6970#[tool_handler(
6971    router = self.tool_router,
6972    name = "semantic-memory-mcp",
6973    version = "0.5.4",
6974    instructions = "Persistent local semantic memory with hybrid search, graph reasoning, and conversation persistence. ALWAYS search first before asking the user for context. Use sm_decide_assertion_authority or sm_decide_action_authority for content-free, fixed-purpose authority decisions; recall authority never implies either purpose. In the full operator profile, use sm_search_with_routing for complex/multi-hop queries, sm_get_fact to hydrate IDs returned by graph tools, sm_supersede_fact (not delete) for stale corrections, and sm_add_graph_edge after adding facts to connect them. Read tools are safe; write tools (add/delete/supersede) should be user-approved. Search auto-filters superseded facts unless querying for history."
6975)]
6976impl ServerHandler for SemanticMemoryServer {}