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_str = serde_json::to_string(&serde_json::Value::Object(meta)).ok();
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(
2172                permit,
2173                caller_idempotency_key,
2174                namespace.clone(),
2175                content.clone(),
2176                source.clone(),
2177            ))
2178        });
2179
2180        match result {
2181            Ok(receipt) => {
2182                let id = receipt.affected_ids.first().cloned().ok_or_else(|| {
2183                    ErrorData::internal_error(
2184                        "authority append returned no affected fact id".to_string(),
2185                        None,
2186                    )
2187                })?;
2188                // D4: best-effort auto-link to an existing claim with matching
2189                // normalized content. Never fails the whole operation.
2190                self.auto_link_fact_to_claims(&id, &content);
2191                // Optional entity extraction — best-effort, never fails the whole operation.
2192                if extract_entities == Some(true) {
2193                    let prompt = format!(
2194                        "Extract entities from this text as JSON. Format: {{\"entities\": [{{\"name\": \"...\", \"type\": \"person|project|concept|tool|version|path\"}}]}}\nText: {content}\nJSON:"
2195                    );
2196                    let body = serde_json::json!({
2197                        "model": "granite4.1:3b",
2198                        "prompt": prompt,
2199                        "stream": false,
2200                        "options": {"temperature": 0, "num_predict": 200}
2201                    });
2202                    if let Ok(resp) = reqwest::blocking::Client::new()
2203                        .post("http://127.0.0.1:11434/api/generate")
2204                        .json(&body)
2205                        .send()
2206                    {
2207                        if let Ok(v) = resp.json::<serde_json::Value>() {
2208                            if let Some(response_str) = v.get("response").and_then(|r| r.as_str()) {
2209                                // Use boundary compiler for robust JSON parsing with duplicate-key rejection
2210                                let parsed_result =
2211                                    boundary_compiler::parse_with_dup_check(response_str.trim());
2212                                if let Ok(parsed) = parsed_result {
2213                                    if let Some(entities) =
2214                                        parsed.get("entities").and_then(|e| e.as_array())
2215                                    {
2216                                        let fact_node = format!("fact:{id}");
2217                                        for entity in entities {
2218                                            if let Some(name) =
2219                                                entity.get("name").and_then(|n| n.as_str())
2220                                            {
2221                                                let entity_node = format!("entity:{name}");
2222                                                let _ = tokio::task::block_in_place(|| {
2223                                                    Handle::current()
2224                                                        .block_on(store.add_graph_edge(
2225                                                        &fact_node,
2226                                                        &entity_node,
2227                                                        semantic_memory::GraphEdgeType::Entity {
2228                                                            relation: "mentions".to_string(),
2229                                                        },
2230                                                        1.0,
2231                                                        None,
2232                                                    ))
2233                                                });
2234                                            }
2235                                        }
2236                                    }
2237                                }
2238                            }
2239                        }
2240                    }
2241                }
2242
2243                json_to_output(&serde_json::json!({
2244                    "ok": true,
2245                    "fact_id": id,
2246                    "namespace": namespace,
2247                    "receipt": mcp_receipt("sm_add_fact"),
2248                    "message": "Fact added successfully",
2249                }))
2250            }
2251            Err(e) => Err(ErrorData::internal_error(
2252                format!("Error adding fact: {e}"),
2253                None,
2254            )),
2255        }
2256    }
2257
2258    #[tool(
2259        description = "Ingest a document with automatic chunking. Splits into chunks, each embedded and indexed. Returns document ID and chunk count.",
2260        annotations(idempotent_hint = true)
2261    )]
2262    fn sm_ingest_document(
2263        &self,
2264        Parameters(IngestDocumentParams {
2265            content,
2266            title,
2267            namespace,
2268        }): Parameters<IngestDocumentParams>,
2269    ) -> Result<Json<StructuredOutput>, ErrorData> {
2270        let store = &self.bridge.store;
2271        let result = tokio::task::block_in_place(|| {
2272            Handle::current()
2273                .block_on(store.ingest_document(&title, &content, &namespace, None, None))
2274        });
2275
2276        match result {
2277            Ok(doc_id) => {
2278                let chunk_count = tokio::task::block_in_place(|| {
2279                    Handle::current().block_on(store.count_chunks_for_document(&doc_id))
2280                })
2281                .unwrap_or(0);
2282                json_to_output(&serde_json::json!({
2283                    "ok": true,
2284                    "receipt": mcp_receipt("sm_ingest_document"),
2285                    "document_id": doc_id,
2286                    "title": title,
2287                    "chunk_count": chunk_count,
2288                    "message": "Document ingested successfully",
2289                }))
2290            }
2291            Err(e) => Err(ErrorData::internal_error(
2292                format!("Error ingesting document: {e}"),
2293                None,
2294            )),
2295        }
2296    }
2297
2298    #[tool(
2299        description = "Get knowledge base statistics: fact/chunk/document/session counts, DB size, embedding model, and graph edge count.",
2300        annotations(read_only_hint = true)
2301    )]
2302    fn sm_stats(&self) -> Result<Json<StatsOutput>, ErrorData> {
2303        let store = &self.bridge.store;
2304        let core = tokio::task::block_in_place(|| Handle::current().block_on(store.stats()));
2305        let graph = tokio::task::block_in_place(|| {
2306            Handle::current().block_on(store.list_all_graph_edges())
2307        });
2308        let core_health = match &core {
2309            Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2310            Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2311        };
2312        let graph_health = match &graph {
2313            Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2314            Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2315        };
2316        let core_value = core.ok();
2317        let graph_count = graph.ok().map(|edges| edges.len());
2318        Ok(Json(StatsOutput {
2319            ok: core_value.is_some() && graph_count.is_some(),
2320            components: serde_json::json!({"core": core_health, "graph": graph_health}),
2321            facts: core_value.as_ref().map(|s| s.total_facts),
2322            chunks: core_value.as_ref().map(|s| s.total_chunks),
2323            documents: core_value.as_ref().map(|s| s.total_documents),
2324            sessions: core_value.as_ref().map(|s| s.total_sessions),
2325            messages: core_value.as_ref().map(|s| s.total_messages),
2326            graph_edges: graph_count,
2327            db_size_bytes: core_value.as_ref().map(|s| s.database_size_bytes),
2328            db_size_mb: core_value
2329                .as_ref()
2330                .map(|s| (s.database_size_bytes as f64 / 1_048_576.0 * 100.0).round() / 100.0),
2331            embedding_model: core_value.as_ref().and_then(|s| s.embedding_model.clone()),
2332            embedding_dimensions: core_value.as_ref().and_then(|s| s.embedding_dimensions),
2333        }))
2334    }
2335
2336    #[tool(
2337        description = "Find shortest path between two items in the knowledge graph. Traverses all edge types. Returns node IDs with edge evidence per hop.",
2338        annotations(read_only_hint = true)
2339    )]
2340    fn sm_graph_path(
2341        &self,
2342        Parameters(GraphPathParams {
2343            from_id,
2344            to_id,
2345            max_depth,
2346        }): Parameters<GraphPathParams>,
2347    ) -> Result<Json<StructuredOutput>, ErrorData> {
2348        let depth = max_depth.map(|v| v as usize).unwrap_or(5);
2349        let store = &self.bridge.store;
2350        let g = store.graph_view();
2351
2352        match typed_graph_path(g.as_ref(), &from_id, &to_id, depth) {
2353            Ok(GraphPathOutcome::Found(path)) => {
2354                // Build edge evidence for each hop by examining neighbors.
2355                let path_segments = build_path_segments(store, &path);
2356                json_to_output(&serde_json::json!({
2357                    "ok": true,
2358                    "outcome": "Found",
2359                    "from": from_id,
2360                    "to": to_id,
2361                    "path": path,
2362                    "path_length": path.len(),
2363                    "segments": path_segments,
2364                }))
2365            }
2366            Ok(GraphPathOutcome::NoPathWithinCompleteSearch) => {
2367                json_to_output(&serde_json::json!({
2368                    "ok": true,
2369                    "outcome": "NoPathWithinCompleteSearch",
2370                    "from": from_id,
2371                    "to": to_id,
2372                    "path": null,
2373                    "message": format!("No path found from {from_id} to {to_id} within depth {depth}"),
2374                }))
2375            }
2376            Ok(GraphPathOutcome::BudgetExceeded) => json_to_output(&serde_json::json!({
2377                "ok": false, "outcome": "BudgetExceeded", "from": from_id, "to": to_id,
2378                "path": null, "budget": {"max_depth": depth}
2379            })),
2380            Ok(GraphPathOutcome::InvalidEndpoint(endpoint)) => json_to_output(&serde_json::json!({
2381                "ok": false, "outcome": "InvalidEndpoint", "invalid_endpoint": endpoint,
2382                "from": from_id, "to": to_id, "path": null
2383            })),
2384            Err(e) => Err(ErrorData::internal_error(
2385                format!("Graph view error: {e}"),
2386                None,
2387            )),
2388        }
2389    }
2390
2391    // ── Direct read and supersession tools (v0.3.1) ──────────────────
2392
2393    #[tool(
2394        description = "Fetch one fact by id (bare UUID or prefixed 'fact:<uuid>'). Returns full content, namespace, source, timestamps, and metadata.",
2395        annotations(read_only_hint = true)
2396    )]
2397    fn sm_get_fact(
2398        &self,
2399        Parameters(GetFactParams { fact_id }): Parameters<GetFactParams>,
2400    ) -> Result<Json<StructuredOutput>, ErrorData> {
2401        let bare = fact_id
2402            .strip_prefix("fact:")
2403            .unwrap_or(&fact_id)
2404            .to_string();
2405        let store = &self.bridge.store;
2406        let result =
2407            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
2408        match result {
2409            Ok(Some(f)) => json_to_output(&serde_json::json!({
2410                "ok": true,
2411                "found": true,
2412                "fact": {
2413                    "result_id": format!("fact:{}", f.id),
2414                    "id": f.id,
2415                    "namespace": f.namespace,
2416                    "content": f.content,
2417                    "source": f.source,
2418                    "created_at": f.created_at,
2419                    "updated_at": f.updated_at,
2420                    "metadata": f.metadata,
2421                },
2422            })),
2423            Ok(None) => json_to_output(&serde_json::json!({
2424                "ok": true,
2425                "found": false,
2426                "message": format!("No fact with id '{fact_id}'"),
2427            })),
2428            Err(e) => Err(ErrorData::internal_error(
2429                format!("get_fact error: {e}"),
2430                None,
2431            )),
2432        }
2433    }
2434
2435    #[tool(
2436        description = "Enumerate facts in a namespace (newest first) with pagination. Exhaustive, not similarity-ranked — for browsing, auditing, or deduping.",
2437        annotations(read_only_hint = true)
2438    )]
2439    fn sm_list_facts(
2440        &self,
2441        Parameters(ListFactsParams {
2442            namespace,
2443            limit,
2444            offset,
2445        }): Parameters<ListFactsParams>,
2446    ) -> Result<Json<StructuredOutput>, ErrorData> {
2447        let lim = limit.map(|v| v as usize).unwrap_or(50);
2448        let off = offset.map(|v| v as usize).unwrap_or(0);
2449        let store = &self.bridge.store;
2450        let result = tokio::task::block_in_place(|| {
2451            Handle::current().block_on(store.list_facts(&namespace, lim, off))
2452        });
2453        match result {
2454            Ok(facts) => {
2455                let arr: Vec<serde_json::Value> = facts
2456                    .iter()
2457                    .map(|f| {
2458                        serde_json::json!({
2459                            "result_id": format!("fact:{}", f.id),
2460                            "id": f.id,
2461                            "namespace": f.namespace,
2462                            "content": f.content,
2463                            "source": f.source,
2464                            "updated_at": f.updated_at,
2465                        })
2466                    })
2467                    .collect();
2468                json_to_output(&serde_json::json!({
2469                    "ok": true,
2470                    "namespace": namespace,
2471                    "count": arr.len(),
2472                    "limit": lim,
2473                    "offset": off,
2474                    "facts": arr,
2475                }))
2476            }
2477            Err(e) => Err(ErrorData::internal_error(
2478                format!("list_facts error: {e}"),
2479                None,
2480            )),
2481        }
2482    }
2483
2484    #[tool(
2485        description = "List namespaces that currently contain facts. Use before sm_list_facts to discover what is stored.",
2486        annotations(read_only_hint = true)
2487    )]
2488    fn sm_list_namespaces(&self) -> Result<Json<StructuredOutput>, ErrorData> {
2489        let store = &self.bridge.store;
2490        let result = tokio::task::block_in_place(|| {
2491            Handle::current().block_on(store.list_fact_namespaces())
2492        });
2493        match result {
2494            Ok(ns) => json_to_output(&serde_json::json!({
2495                "ok": true,
2496                "count": ns.len(),
2497                "namespaces": ns,
2498            })),
2499            Err(e) => Err(ErrorData::internal_error(
2500                format!("list_namespaces error: {e}"),
2501                None,
2502            )),
2503        }
2504    }
2505
2506    #[tool(
2507        description = "Fetch a fact plus its graph neighbors WITH their content in one call. Hydrates neighbor facts for ids returned by graph tools.",
2508        annotations(read_only_hint = true)
2509    )]
2510    fn sm_get_fact_neighbors(
2511        &self,
2512        Parameters(GetFactNeighborsParams { item_id }): Parameters<GetFactNeighborsParams>,
2513    ) -> Result<Json<StructuredOutput>, ErrorData> {
2514        let node_id = if item_id.contains(':') {
2515            item_id.clone()
2516        } else {
2517            format!("fact:{item_id}")
2518        };
2519        let bare = node_id
2520            .strip_prefix("fact:")
2521            .unwrap_or(&node_id)
2522            .to_string();
2523        let store = &self.bridge.store;
2524
2525        let center =
2526            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)))
2527                .map_err(|e| ErrorData::internal_error(format!("get_fact error: {e}"), None))?;
2528        let edges = tokio::task::block_in_place(|| {
2529            Handle::current().block_on(store.list_graph_edges_for_node(&node_id))
2530        })
2531        .map_err(|e| ErrorData::internal_error(format!("list edges error: {e}"), None))?;
2532
2533        let mut neighbors: Vec<serde_json::Value> = Vec::new();
2534        for e in &edges {
2535            let outgoing = e.source == node_id;
2536            let other = if outgoing { &e.target } else { &e.source };
2537            let other_bare = other.strip_prefix("fact:").unwrap_or(other).to_string();
2538            let content = tokio::task::block_in_place(|| {
2539                Handle::current().block_on(store.get_fact(&other_bare))
2540            })
2541            .ok()
2542            .flatten()
2543            .map(|f| f.content);
2544            neighbors.push(serde_json::json!({
2545                "neighbor_id": other,
2546                "direction": if outgoing { "out" } else { "in" },
2547                "edge_type": e.edge_type,
2548                "weight": e.weight,
2549                "content": content,
2550            }));
2551        }
2552        json_to_output(&serde_json::json!({
2553            "ok": true,
2554            "item_id": node_id,
2555            "center_content": center.map(|f| f.content),
2556            "neighbor_count": neighbors.len(),
2557            "neighbors": neighbors,
2558        }))
2559    }
2560
2561    #[tool(
2562        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.",
2563        annotations(idempotent_hint = true)
2564    )]
2565    fn sm_supersede_fact(
2566        &self,
2567        Parameters(SupersedeFactParams {
2568            old_fact_id,
2569            content,
2570            namespace,
2571            source,
2572            reason,
2573        }): Parameters<SupersedeFactParams>,
2574    ) -> Result<Json<StructuredOutput>, ErrorData> {
2575        use semantic_memory::GraphEdgeType;
2576
2577        let old_bare = old_fact_id
2578            .strip_prefix("fact:")
2579            .unwrap_or(&old_fact_id)
2580            .to_string();
2581        let old_node = format!("fact:{old_bare}");
2582        let store = &self.bridge.store;
2583        let old =
2584            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&old_bare)))
2585                .map_err(|e| ErrorData::internal_error(format!("get old fact error: {e}"), None))?;
2586        let Some(old_fact) = old else {
2587            return Err(ErrorData::invalid_params(
2588                format!("No fact with id '{old_fact_id}'"),
2589                None,
2590            ));
2591        };
2592
2593        let ns = namespace.unwrap_or_else(|| old_fact.namespace.clone());
2594        let new_id = tokio::task::block_in_place(|| {
2595            Handle::current().block_on(store.add_fact(&ns, &content, source.as_deref(), None))
2596        })
2597        .map_err(|e| ErrorData::internal_error(format!("add replacement fact error: {e}"), None))?;
2598        let new_node = format!("fact:{new_id}");
2599        let metadata = serde_json::json!({
2600            "reason": reason.unwrap_or_else(|| "replacement fact supersedes stale fact".to_string()),
2601            "old_fact_id": old_bare,
2602        });
2603        let edge = tokio::task::block_in_place(|| {
2604            Handle::current().block_on(store.add_graph_edge(
2605                &new_node,
2606                &old_node,
2607                GraphEdgeType::Entity {
2608                    relation: "supersedes".to_string(),
2609                },
2610                1.0,
2611                Some(metadata),
2612            ))
2613        })
2614        .map_err(|e| ErrorData::internal_error(format!("add supersedes edge error: {e}"), None))?;
2615
2616        json_to_output(&serde_json::json!({
2617            "ok": true,
2618            "receipt": mcp_receipt("sm_supersede_fact"),
2619            "new_fact_id": new_id,
2620            "new_result_id": new_node,
2621            "old_fact_id": old_bare,
2622            "old_result_id": old_node,
2623            "namespace": ns,
2624            "edge_id": edge.id,
2625            "relation": "supersedes",
2626        }))
2627    }
2628
2629    // ── Conversation / session tools (v0.3.0) ────────────────────────
2630
2631    // DEPRECATED #[tool(
2632    // description = "Create a conversation session (container for messages). Returns session id. Use to persist history recallable via sm_search_conversations.",
2633    // annotations(idempotent_hint = true)
2634    // )]
2635    #[allow(dead_code)]
2636    fn sm_create_session(
2637        &self,
2638        Parameters(CreateSessionParams { channel, metadata }): Parameters<CreateSessionParams>,
2639    ) -> Result<Json<StructuredOutput>, ErrorData> {
2640        let meta: Option<serde_json::Value> = metadata
2641            .as_deref()
2642            .and_then(|s| serde_json::from_str(s).ok());
2643        let store = &self.bridge.store;
2644        let result = tokio::task::block_in_place(|| {
2645            Handle::current().block_on(store.create_session_with_metadata(&channel, meta))
2646        });
2647        match result {
2648            Ok(id) => json_to_output(
2649                &serde_json::json!({"ok": true, "session_id": id, "channel": channel, "receipt": mcp_receipt("sm_create_session")}),
2650            ),
2651            Err(e) => Err(ErrorData::internal_error(
2652                format!("create_session error: {e}"),
2653                None,
2654            )),
2655        }
2656    }
2657
2658    // DEPRECATED #[tool(
2659    // description = "Append a message to a session. role: user|assistant|system|tool. Message is embedded and FTS-indexed. Returns message id."
2660    // )]
2661    #[allow(dead_code)]
2662    fn sm_add_message(
2663        &self,
2664        Parameters(AddMessageParams {
2665            session_id,
2666            role,
2667            content,
2668        }): Parameters<AddMessageParams>,
2669    ) -> Result<Json<StructuredOutput>, ErrorData> {
2670        let parsed_role = match role.to_lowercase().as_str() {
2671            "user" => semantic_memory::types::Role::User,
2672            "assistant" => semantic_memory::types::Role::Assistant,
2673            "system" => semantic_memory::types::Role::System,
2674            "tool" => semantic_memory::types::Role::Tool,
2675            other => {
2676                return Err(ErrorData::invalid_params(
2677                    format!("invalid role '{other}' (use user|assistant|system|tool)"),
2678                    None,
2679                ))
2680            }
2681        };
2682        let store = &self.bridge.store;
2683        let result = tokio::task::block_in_place(|| {
2684            Handle::current().block_on(store.add_message_embedded(
2685                &session_id,
2686                parsed_role,
2687                &content,
2688                None,
2689                None,
2690            ))
2691        });
2692        match result {
2693            Ok(id) => json_to_output(
2694                &serde_json::json!({"ok": true, "message_id": id, "session_id": session_id, "receipt": mcp_receipt("sm_add_message")}),
2695            ),
2696            Err(e) => Err(ErrorData::internal_error(
2697                format!("add_message error: {e}"),
2698                None,
2699            )),
2700        }
2701    }
2702
2703    #[tool(
2704        description = "List recent conversation sessions (newest first) with message counts.",
2705        annotations(read_only_hint = true)
2706    )]
2707    fn sm_list_sessions(
2708        &self,
2709        Parameters(ListSessionsParams { limit, offset }): Parameters<ListSessionsParams>,
2710    ) -> Result<Json<StructuredOutput>, ErrorData> {
2711        let lim = limit.map(|v| v as usize).unwrap_or(20);
2712        let off = offset.map(|v| v as usize).unwrap_or(0);
2713        let store = &self.bridge.store;
2714        let result = tokio::task::block_in_place(|| {
2715            Handle::current().block_on(store.list_sessions(lim, off))
2716        });
2717        match result {
2718            Ok(sessions) => json_to_output(&serde_json::json!({
2719                "ok": true,
2720                "count": sessions.len(),
2721                "sessions": sessions.iter().map(|s| serde_json::json!({
2722                    "session_id": s.id,
2723                    "channel": s.channel,
2724                    "message_count": s.message_count,
2725                    "created_at": s.created_at,
2726                    "updated_at": s.updated_at,
2727                })).collect::<Vec<_>>(),
2728            })),
2729            Err(e) => Err(ErrorData::internal_error(
2730                format!("list_sessions error: {e}"),
2731                None,
2732            )),
2733        }
2734    }
2735
2736    #[tool(
2737        description = "Get most recent messages from a session within a token budget (default 4000), chronological order. Returns role, content, timestamps.",
2738        annotations(read_only_hint = true)
2739    )]
2740    fn sm_get_messages(
2741        &self,
2742        Parameters(GetMessagesParams {
2743            session_id,
2744            max_tokens,
2745        }): Parameters<GetMessagesParams>,
2746    ) -> Result<Json<StructuredOutput>, ErrorData> {
2747        let budget = max_tokens.unwrap_or(4000);
2748        let store = &self.bridge.store;
2749        let result = tokio::task::block_in_place(|| {
2750            Handle::current().block_on(store.get_messages_within_budget(&session_id, budget))
2751        });
2752        match result {
2753            Ok(msgs) => json_to_output(&serde_json::json!({
2754                "ok": true,
2755                "session_id": session_id,
2756                "count": msgs.len(),
2757                "messages": msgs.iter().map(|m| serde_json::json!({
2758                    "id": m.id,
2759                    "role": m.role,
2760                    "content": m.content,
2761                    "token_count": m.token_count,
2762                    "created_at": m.created_at,
2763                })).collect::<Vec<_>>(),
2764            })),
2765            Err(e) => Err(ErrorData::internal_error(
2766                format!("get_messages error: {e}"),
2767                None,
2768            )),
2769        }
2770    }
2771
2772    #[tool(
2773        description = "Hybrid semantic search over stored conversation MESSAGES (not facts). Recall what was discussed in past sessions. Returns ranked messages.",
2774        annotations(read_only_hint = true)
2775    )]
2776    fn sm_search_conversations(
2777        &self,
2778        Parameters(SearchConversationsParams { query, top_k }): Parameters<
2779            SearchConversationsParams,
2780        >,
2781    ) -> Result<Json<StructuredOutput>, ErrorData> {
2782        let k = top_k.map(|v| v as usize);
2783        let store = &self.bridge.store;
2784        let result = tokio::task::block_in_place(|| {
2785            Handle::current().block_on(store.search_conversations(&query, k, None))
2786        });
2787        match result {
2788            Ok(results) => json_to_output(&serde_json::json!({
2789                "ok": true,
2790                "count": results.len(),
2791                "results": results.iter().map(|r| serde_json::json!({
2792                    "result_id": r.source.result_id(),
2793                    "content": r.content,
2794                    "score": r.score,
2795                    "cosine_similarity": r.cosine_similarity,
2796                })).collect::<Vec<_>>(),
2797            })),
2798            Err(e) => Err(ErrorData::internal_error(
2799                format!("search_conversations error: {e}"),
2800                None,
2801            )),
2802        }
2803    }
2804
2805    // ── Feature-gated tools ──────────────────────────────────────────
2806    // Note: cfg gates are removed from individual tool methods because
2807    // rmcp's #[tool_router] macro needs all tools visible at expansion
2808    // time. The `full` feature in Cargo.toml already enables the
2809    // semantic-memory sub-features these tools depend on.
2810
2811    #[tool(
2812        description = "Profile a query and get an adaptive routing decision. Determines which retrieval stages (BM25, vector, rerank, graph, decoder, discord) to activate.",
2813        annotations(read_only_hint = true)
2814    )]
2815    fn sm_route_query(
2816        &self,
2817        Parameters(RouteQueryParams { query }): Parameters<RouteQueryParams>,
2818    ) -> Result<Json<StructuredOutput>, ErrorData> {
2819        use semantic_memory::rl_routing::{is_trained, route_with_policy};
2820        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2821
2822        let router = RetrievalRouter {
2823            decoder_enabled: true,
2824            discord_enabled: true,
2825            corpus_density: 0.5,
2826            ..Default::default()
2827        };
2828
2829        let profile = QueryProfile::from_query(&query);
2830        let policy = tokio::task::block_in_place(|| {
2831            Handle::current().block_on(self.bridge.store.load_routing_policy())
2832        })
2833        .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
2834        let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2835            Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2836            None => (router.route(&profile), "heuristic"),
2837        };
2838        json_to_output(&serde_json::json!({
2839            "ok": true,
2840            "routing_source": routing_source,
2841            "bm25_coarse": decision.bm25_coarse,
2842            "vector_medium": decision.vector_medium,
2843            "rerank_fine": decision.rerank_fine,
2844            "graph_expansion": decision.graph_expansion,
2845            "decoder": decision.decoder,
2846            "discord": decision.discord,
2847            "no_retrieval": decision.no_retrieval,
2848            "reasoning": decision.reasoning,
2849        }))
2850    }
2851
2852    #[tool(
2853        description = "Adaptive search: profiles query, routes to appropriate stages, applies factor graph belief propagation if decoder is activated. Returns results with stable IDs.",
2854        annotations(read_only_hint = true)
2855    )]
2856    fn sm_search_with_routing(
2857        &self,
2858        Parameters(SearchWithRoutingParams {
2859            query,
2860            top_k,
2861            contradictions,
2862            group_by_community,
2863        }): Parameters<SearchWithRoutingParams>,
2864    ) -> Result<Json<StructuredOutput>, ErrorData> {
2865        use semantic_memory::integration::plan_execution;
2866        use semantic_memory::rl_routing::{is_trained, route_with_policy};
2867        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2868
2869        let k = top_k.map(|v| v as usize).unwrap_or(5);
2870        let allow_superseded = query_allows_superseded(&query);
2871        let search_k = if allow_superseded { k } else { (k * 4).max(20) };
2872
2873        let router = RetrievalRouter {
2874            decoder_enabled: true,
2875            discord_enabled: true,
2876            corpus_density: 0.5,
2877            ..Default::default()
2878        };
2879
2880        // Select learned routing only after enough examples have been durably
2881        // persisted. A missing or still-untrained policy uses heuristics.
2882        let store = &self.bridge.store;
2883        let policy =
2884            tokio::task::block_in_place(|| Handle::current().block_on(store.load_routing_policy()))
2885                .map_err(|e| {
2886                    ErrorData::internal_error(format!("load routing policy error: {e}"), None)
2887                })?;
2888        let profile = QueryProfile::from_query(&query);
2889        let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2890            Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2891            None => (router.route(&profile), "heuristic"),
2892        };
2893        let contras = contradictions.unwrap_or_default();
2894        let plan = plan_execution(&decision, contras.clone());
2895
2896        let store = &self.bridge.store;
2897        let search_result = tokio::task::block_in_place(|| {
2898            Handle::current().block_on(store.search(&query, Some(search_k), None, None))
2899        });
2900
2901        match search_result {
2902            Ok(results) => {
2903                let superseded_targets = if allow_superseded {
2904                    HashSet::new()
2905                } else {
2906                    load_superseded_targets(store)?
2907                };
2908                let fresh_results: Vec<_> = results
2909                    .iter()
2910                    .filter(|r| !superseded_targets.contains(&r.source.result_id()))
2911                    .collect();
2912                let result_refs: Vec<_> = if superseded_targets.is_empty() {
2913                    results.iter().collect()
2914                } else {
2915                    fresh_results
2916                };
2917                let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
2918                let json_results: Vec<serde_json::Value> = result_refs
2919                    .iter()
2920                    .take(k)
2921                    .map(|r| {
2922                        serde_json::json!({
2923                            "result_id": r.source.result_id(),
2924                            "content": r.content,
2925                            "score": r.score,
2926                        })
2927                    })
2928                    .collect();
2929
2930                let mut factor_graph_payload = serde_json::json!({
2931                    "enabled": false,
2932                });
2933
2934                let mut decoder_executed = false;
2935                let mut discord_executed = false;
2936                let mut discord_results_payload: Vec<serde_json::Value> = Vec::new();
2937
2938                if decision.decoder {
2939                    #[cfg(feature = "full")]
2940                    {
2941                        use semantic_memory::factor_graph::{
2942                            factors_from_edges, FactorGraph, FactorGraphConfig,
2943                        };
2944
2945                        let graph_edges = tokio::task::block_in_place(|| {
2946                            Handle::current().block_on(store.list_all_graph_edges())
2947                        });
2948
2949                        match graph_edges {
2950                            Ok(edges) => {
2951                                let raw_edges: Vec<(
2952                                    String,
2953                                    String,
2954                                    semantic_memory::GraphEdgeType,
2955                                    f64,
2956                                    Option<String>,
2957                                )> = edges
2958                                    .iter()
2959                                    .map(|edge| {
2960                                        let parsed_type = edge
2961                                            .edge_type_parsed
2962                                            .clone()
2963                                            .or_else(|| serde_json::from_str(&edge.edge_type).ok())
2964                                            .unwrap_or(semantic_memory::GraphEdgeType::Entity {
2965                                                relation: "unknown".to_string(),
2966                                            });
2967                                        (
2968                                            edge.source.clone(),
2969                                            edge.target.clone(),
2970                                            parsed_type,
2971                                            edge.weight,
2972                                            edge.metadata.clone(),
2973                                        )
2974                                    })
2975                                    .collect();
2976
2977                                let nodes: Vec<(String, f64)> = result_refs
2978                                    .iter()
2979                                    .map(|r| (r.source.result_id(), r.score))
2980                                    .collect();
2981                                let factors = factors_from_edges(&raw_edges);
2982                                let graph =
2983                                    FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
2984                                let propagated = graph.propagate();
2985                                let top_beliefs = propagated.top_k(k);
2986
2987                                factor_graph_payload = serde_json::json!({
2988                                    "enabled": true,
2989                                    "top_k_beliefs": top_beliefs
2990                                        .into_iter()
2991                                        .map(|(item_id, belief)| serde_json::json!({
2992                                            "item_id": item_id,
2993                                            "belief": belief,
2994                                        }))
2995                                        .collect::<Vec<_>>(),
2996                                    "iterations": propagated.iterations,
2997                                    "converged": propagated.converged,
2998                                    "elapsed_ms": propagated.elapsed_ms,
2999                                    "factor_counts": {
3000                                        "semantic": propagated.factor_counts.semantic,
3001                                        "temporal": propagated.factor_counts.temporal,
3002                                        "causal": propagated.factor_counts.causal,
3003                                        "entity": propagated.factor_counts.entity,
3004                                        "total": propagated.factor_counts.total(),
3005                                    },
3006                                });
3007                                decoder_executed = true;
3008                            }
3009                            Err(e) => {
3010                                factor_graph_payload = serde_json::json!({
3011                                    "enabled": false,
3012                                    "error": format!("factor graph analysis failed: {e}"),
3013                                });
3014                            }
3015                        }
3016                    }
3017
3018                    #[cfg(not(feature = "full"))]
3019                    {
3020                        factor_graph_payload = serde_json::json!({
3021                            "enabled": false,
3022                            "reason": "factor graph analysis requires the `full` feature",
3023                        });
3024                    }
3025
3026                    if !plan.contradictions.is_empty() {
3027                        use semantic_memory::decoder::{compute_correction, detect_syndromes};
3028                        let result_scores: Vec<(String, f64)> = result_refs
3029                            .iter()
3030                            .map(|r| (r.source.result_id(), r.score))
3031                            .collect();
3032                        let syndromes = detect_syndromes(&result_scores, &plan.contradictions);
3033                        let _ = compute_correction(&syndromes, 10.0);
3034                        decoder_executed = true;
3035                    }
3036                }
3037
3038                if plan.use_discord {
3039                    use semantic_memory::discord::DiscordScorer;
3040                    let direct_ids: Vec<String> =
3041                        result_refs.iter().map(|r| r.source.result_id()).collect();
3042                    let existing_ids: std::collections::HashSet<String> =
3043                        direct_ids.iter().cloned().collect();
3044                    if let Ok(edges) = load_neighborhood_edge_refs(&self.bridge.store, &direct_ids)
3045                    {
3046                        let scorer = DiscordScorer::with_defaults();
3047                        let discord_hits = scorer.score(&direct_ids, &edges);
3048                        for hit in &discord_hits {
3049                            if !existing_ids.contains(&hit.item_id) {
3050                                discord_results_payload.push(serde_json::json!({
3051                                    "result_id": hit.item_id,
3052                                    "discord_score": hit.discord_score,
3053                                    "anchor_ids": hit.anchor_ids,
3054                                    "relationship_types": hit.relationship_types,
3055                                }));
3056                            }
3057                        }
3058                        discord_executed = true;
3059                    }
3060                }
3061
3062                let mut matryoshka_payload = serde_json::json!({
3063                    "enabled": false,
3064                });
3065                if decision.vector_medium {
3066                    #[cfg(feature = "full")]
3067                    {
3068                        use semantic_memory::integration::multi_resolution_route;
3069                        use semantic_memory::matryoshka::MatryoshkaConfig;
3070                        use semantic_memory::routing::QueryProfile;
3071
3072                        let route_profile = QueryProfile::from_query(&query);
3073                        let route_decision =
3074                            multi_resolution_route(&route_profile, &MatryoshkaConfig::default());
3075                        matryoshka_payload = serde_json::json!({
3076                            "enabled": true,
3077                            "candidate_dim": route_decision.candidate_dim,
3078                            "heuristic_recall_estimate": route_decision.estimated_recall,
3079                            "recall_basis": "heuristic_dimensional_model_not_corpus_measured",
3080                            "embedding_dim": route_decision.embedding_dim,
3081                            "reasoning": route_decision.reasoning,
3082                        });
3083                    }
3084
3085                    #[cfg(not(feature = "full"))]
3086                    {
3087                        matryoshka_payload = serde_json::json!({
3088                            "enabled": false,
3089                            "reason": "matryoshka routing requires the `full` feature",
3090                        });
3091                    }
3092                }
3093
3094                // Community grouping (opt-in).
3095                let grouped_results_payload: serde_json::Value = if group_by_community == Some(true)
3096                {
3097                    let seed_ids: Vec<String> = result_refs
3098                        .iter()
3099                        .take(k)
3100                        .map(|r| r.source.result_id())
3101                        .collect();
3102                    let edges = load_neighborhood_edge_pairs(store, &seed_ids).unwrap_or_default();
3103                    if !edges.is_empty() {
3104                        use semantic_memory::community::detect_communities;
3105                        let communities = detect_communities(&edges, 1.0, 42);
3106                        let mut member_to_comm: std::collections::HashMap<String, String> =
3107                            std::collections::HashMap::new();
3108                        for c in &communities {
3109                            for m in &c.members {
3110                                member_to_comm.insert(m.clone(), c.id.clone());
3111                            }
3112                        }
3113                        let mut groups: std::collections::HashMap<String, Vec<serde_json::Value>> =
3114                            std::collections::HashMap::new();
3115                        let mut ungrouped: Vec<serde_json::Value> = Vec::new();
3116                        for r in &json_results {
3117                            if let Some(rid) = r.get("result_id").and_then(|v| v.as_str()) {
3118                                match member_to_comm.get(rid).cloned() {
3119                                    Some(cid) => groups.entry(cid).or_default().push(r.clone()),
3120                                    None => ungrouped.push(r.clone()),
3121                                }
3122                            }
3123                        }
3124                        let mut map = serde_json::Map::new();
3125                        for (cid, items) in groups {
3126                            map.insert(format!("community_{cid}"), serde_json::json!(items));
3127                        }
3128                        if !ungrouped.is_empty() {
3129                            map.insert("ungrouped".to_string(), serde_json::json!(ungrouped));
3130                        }
3131                        serde_json::Value::Object(map)
3132                    } else {
3133                        serde_json::Value::Null
3134                    }
3135                } else {
3136                    serde_json::Value::Null
3137                };
3138
3139                // Task 7: Auto-call topology when routing returns Class D (SYNTHESIS) and >10 results.
3140                let mut topology_payload = serde_json::json!({ "auto_called": false });
3141                {
3142                    use semantic_memory::routing::{QueryComplexityClass, QueryProfile};
3143                    let route_profile = QueryProfile::from_query(&query);
3144                    if route_profile.complexity_class == QueryComplexityClass::Synthesis
3145                        && result_refs.len() > 10
3146                    {
3147                        #[cfg(feature = "full")]
3148                        {
3149                            use semantic_memory::topology::{compute_betti_numbers, find_voids};
3150                            let edges = load_stored_edge_pairs(store).unwrap_or_default();
3151                            if !edges.is_empty() {
3152                                let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3153                                    std::collections::HashMap::new();
3154                                for (src, tgt) in &edges {
3155                                    adjacency.entry(src.clone()).or_default().push(tgt.clone());
3156                                    adjacency.entry(tgt.clone()).or_default().push(src.clone());
3157                                }
3158                                let betti = compute_betti_numbers(&adjacency);
3159                                let voids = find_voids(&edges);
3160                                topology_payload = serde_json::json!({
3161                                    "auto_called": true,
3162                                    "trigger": "synthesis_class_with_10_plus_results",
3163                                    "betti_numbers": {
3164                                        "betti_0": betti.betti_0,
3165                                        "betti_1": betti.betti_1,
3166                                    },
3167                                    "void_count": voids.len(),
3168                                    "voids": voids.iter().map(|v| serde_json::json!({
3169                                        "description": v.description,
3170                                        "void_type": format!("{:?}", v.void_type),
3171                                        "nearby_items": v.nearby_items,
3172                                        "suggested_connections": v.suggested_connections,
3173                                    })).collect::<Vec<_>>(),
3174                                });
3175                            } else {
3176                                topology_payload = serde_json::json!({
3177                                    "auto_called": true,
3178                                    "trigger": "synthesis_class_with_10_plus_results",
3179                                    "note": "no graph edges in store",
3180                                });
3181                            }
3182                        }
3183                        #[cfg(not(feature = "full"))]
3184                        {
3185                            topology_payload = serde_json::json!({
3186                                "auto_called": true,
3187                                "trigger": "synthesis_class_with_10_plus_results",
3188                                "error": "topology requires the full feature",
3189                            });
3190                        }
3191                    }
3192                }
3193
3194                json_to_output(&serde_json::json!({
3195                    "ok": true,
3196                    "routing_decision": {
3197                        "source": routing_source,
3198                        "bm25_coarse": decision.bm25_coarse,
3199                        "vector_medium": decision.vector_medium,
3200                        "rerank_fine": decision.rerank_fine,
3201                        "graph_expansion": decision.graph_expansion,
3202                        "decoder": decision.decoder,
3203                        "discord": decision.discord,
3204                        "no_retrieval": decision.no_retrieval,
3205                        "reasoning": decision.reasoning,
3206                    },
3207                    "results": json_results,
3208                    "count": json_results.len(),
3209                    "superseded_filtered_count": superseded_filtered_count,
3210                    "decoder_planned": plan.use_decoder,
3211                    "decoder_executed": decoder_executed,
3212                    "discord_planned": plan.use_discord,
3213                    "discord_executed": discord_executed,
3214                    "discord_results": discord_results_payload,
3215                    "factor_graph": factor_graph_payload,
3216                    "matryoshka": matryoshka_payload,
3217                    "grouped_results": grouped_results_payload,
3218                    "topology": topology_payload,
3219                }))
3220            }
3221            Err(e) => Err(ErrorData::internal_error(
3222                format!("Search error: {e}"),
3223                None,
3224            )),
3225        }
3226    }
3227
3228    #[tool(
3229        description = "Detect contradictions in search results. Runs syndrome detection, computes corrections, and applies belief propagation to refine confidence scores.",
3230        annotations(read_only_hint = true)
3231    )]
3232    fn sm_decoder_analyze(
3233        &self,
3234        Parameters(DecoderAnalyzeParams {
3235            results,
3236            contradictions,
3237        }): Parameters<DecoderAnalyzeParams>,
3238    ) -> Result<Json<StructuredOutput>, ErrorData> {
3239        use semantic_memory::decoder::{
3240            compute_correction, detect_syndromes, pass_messages, ConflictGraph,
3241        };
3242
3243        let contras = contradictions.unwrap_or_default();
3244        let syndromes = detect_syndromes(&results, &contras);
3245        let corrections = compute_correction(&syndromes, 10.0);
3246        let graph = ConflictGraph::from_syndromes(&results, &syndromes);
3247        let mp = pass_messages(&graph, 50, 0.001);
3248
3249        json_to_output(&serde_json::json!({
3250            "ok": true,
3251            "syndromes": syndromes.iter().map(|s| serde_json::json!({
3252                "id": s.id,
3253                "severity": format!("{:?}", s.severity),
3254                "items": s.items,
3255                "description": s.description,
3256                "type": format!("{:?}", s.syndrome_type),
3257            })).collect::<Vec<_>>(),
3258            "syndrome_count": syndromes.len(),
3259            "corrections": corrections.iter().map(|c| serde_json::json!({
3260                "id": c.id,
3261                "confidence": c.confidence,
3262                "cost": c.cost,
3263                "operations": c.operations.len(),
3264            })).collect::<Vec<_>>(),
3265            "correction_count": corrections.len(),
3266            "message_passing": {
3267                "iterations": mp.iterations,
3268                "converged": mp.converged,
3269                "elapsed_ms": mp.elapsed_ms,
3270            },
3271        }))
3272    }
3273
3274    #[tool(
3275        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.",
3276        annotations(read_only_hint = true)
3277    )]
3278    fn sm_detect_contradictions(
3279        &self,
3280        Parameters(DetectContradictionsParams {
3281            query,
3282            top_k,
3283            record_to_ledger,
3284        }): Parameters<DetectContradictionsParams>,
3285    ) -> Result<Json<StructuredOutput>, ErrorData> {
3286        use semantic_memory::contradiction_detect::{detect_contradictions, DetectorConfig};
3287
3288        let k = top_k.map(|v| v as usize).unwrap_or(10);
3289        let store = &self.bridge.store;
3290        let results = tokio::task::block_in_place(|| {
3291            Handle::current().block_on(store.search(&query, Some(k), None, None))
3292        })
3293        .map_err(|e| ErrorData::internal_error(format!("search failed: {e}"), None))?;
3294
3295        let items: Vec<(String, String)> = results
3296            .iter()
3297            .map(|r| (r.source.result_id(), r.content.clone()))
3298            .collect();
3299
3300        let pairs = detect_contradictions(&items, &DetectorConfig::default());
3301
3302        // T2.4/D2: Optionally record detected contradictions as real,
3303        // hash-chained claim-ledger entries (LedgerEvent::ContradictionCandidate)
3304        // and update the in-process ClaimTrustIndex so search-time trust
3305        // lookups reflect the conflict. The trust index is a lookup cache
3306        // only — the ledger entries below are the durable record.
3307        let (ledger_recorded, ledger_entries) = if record_to_ledger.unwrap_or(false) {
3308            #[cfg(feature = "claim-integration")]
3309            {
3310                use claim_ledger::{LedgerEntryBuilder, SupportState};
3311                let mut count = 0usize;
3312                let mut entries = Vec::new();
3313                for p in &pairs {
3314                    let fact_a = p.a.strip_prefix("fact:").unwrap_or(&p.a).to_string();
3315                    let fact_b = p.b.strip_prefix("fact:").unwrap_or(&p.b).to_string();
3316                    {
3317                        let mut idx = self.claim_trust.lock().unwrap();
3318                        idx.record_judgment(fact_a.clone(), SupportState::Contradicted);
3319                        idx.record_judgment(fact_b.clone(), SupportState::Contradicted);
3320                    }
3321
3322                    let pattern = p
3323                        .signals
3324                        .iter()
3325                        .map(|s| format!("{s:?}"))
3326                        .collect::<Vec<_>>()
3327                        .join(",");
3328                    let contradiction_id =
3329                        claim_ledger::ids::contradiction_id(&fact_a, &fact_b, &pattern);
3330
3331                    let mut ledger = self.claim_ledger_store.lock().unwrap();
3332                    let sequence = ledger.next_sequence();
3333                    let previous_digest = ledger.last_digest();
3334                    let append_result = LedgerEntryBuilder::new(sequence, previous_digest)
3335                        .add_contradiction_candidate(
3336                            &contradiction_id,
3337                            vec![fact_a.clone(), fact_b.clone()],
3338                            &pattern,
3339                            &p.reason,
3340                        )
3341                        .map_err(|e| e.to_string())
3342                        .and_then(|entry| ledger.append(entry));
3343                    drop(ledger);
3344
3345                    match append_result {
3346                        Ok(entry_hash) => {
3347                            count += 1;
3348                            entries.push(serde_json::json!({
3349                                "contradiction_id": contradiction_id,
3350                                "claim_refs": [fact_a, fact_b],
3351                                "sequence": sequence,
3352                                "entry_hash": entry_hash,
3353                            }));
3354                        }
3355                        Err(e) => {
3356                            return Err(ErrorData::internal_error(
3357                                format!("failed to record contradiction to ledger: {e}"),
3358                                None,
3359                            ));
3360                        }
3361                    }
3362                }
3363                (count, entries)
3364            }
3365            #[cfg(not(feature = "claim-integration"))]
3366            {
3367                (0, Vec::new())
3368            }
3369        } else {
3370            (0, Vec::new())
3371        };
3372
3373        json_to_output(&serde_json::json!({
3374            "ok": true,
3375            "query": query,
3376            "items_scanned": items.len(),
3377            "contradictions": pairs.iter().map(|p| serde_json::json!({
3378                "a": p.a,
3379                "b": p.b,
3380                "score": p.score,
3381                "signals": p.signals.iter().map(|s| format!("{s:?}")).collect::<Vec<_>>(),
3382                "reason": p.reason,
3383            })).collect::<Vec<_>>(),
3384            "count": pairs.len(),
3385            "ledger_recorded": ledger_recorded,
3386            "ledger_entries": ledger_entries,
3387            "receipt": mcp_receipt("sm_detect_contradictions"),
3388        }))
3389    }
3390
3391    #[tool(
3392        description = "Second-order retrieval: find items related to your search results through the graph, but NOT themselves direct hits. Loads edges from store automatically.",
3393        annotations(read_only_hint = true)
3394    )]
3395    fn sm_discord_search(
3396        &self,
3397        Parameters(DiscordSearchParams { direct_result_ids }): Parameters<DiscordSearchParams>,
3398    ) -> Result<Json<StructuredOutput>, ErrorData> {
3399        use semantic_memory::discord::DiscordScorer;
3400
3401        // Use neighborhood loading: only load edges within 2 hops of the
3402        // direct result IDs instead of the entire graph.
3403        let edges = load_neighborhood_edge_refs(&self.bridge.store, &direct_result_ids)?;
3404        let scorer = DiscordScorer::with_defaults();
3405        let results = scorer.score(&direct_result_ids, &edges);
3406
3407        json_to_output(&serde_json::json!({
3408            "ok": true,
3409            "discord_results": results.iter().map(|r| serde_json::json!({
3410                "item_id": r.item_id,
3411                "discord_score": r.discord_score,
3412                "anchor_ids": r.anchor_ids,
3413                "relationship_types": r.relationship_types,
3414            })).collect::<Vec<_>>(),
3415            "count": results.len(),
3416            "edges_loaded": edges.len(),
3417            "edges_scope": "neighborhood",
3418        }))
3419    }
3420
3421    #[tool(
3422        description = "Set provenance (evidence confidence) for an item. Confidence in [0.0, 1.0] with support count. Returns a provenance receipt.",
3423        annotations(idempotent_hint = true)
3424    )]
3425    fn sm_set_provenance(
3426        &self,
3427        Parameters(SetProvenanceParams {
3428            item_id,
3429            confidence,
3430            support_count,
3431        }): Parameters<SetProvenanceParams>,
3432    ) -> Result<Json<StructuredOutput>, ErrorData> {
3433        use semantic_memory::provenance::{
3434            ConfidenceSemiring, ConfidenceValue, ProvenanceItemType,
3435        };
3436
3437        // SM-AUD-015: Validate confidence is finite and in [0, 1].
3438        if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
3439            return Err(ErrorData::invalid_params(
3440                format!("confidence must be a finite value in [0.0, 1.0], got {confidence}"),
3441                None,
3442            ));
3443        }
3444
3445        let value = ConfidenceValue::new(confidence, support_count);
3446        let store = &self.bridge.store;
3447
3448        let result = tokio::task::block_in_place(|| {
3449            Handle::current().block_on(store.set_provenance::<ConfidenceSemiring>(
3450                &ProvenanceItemType::Fact,
3451                &item_id,
3452                &value,
3453                &[],
3454                None,
3455            ))
3456        });
3457
3458        match result {
3459            Ok(receipt) => json_to_output(&serde_json::json!({
3460                "ok": true,
3461                "provenance_id": receipt.provenance_id,
3462                "item_id": receipt.item_id,
3463                "semiring_type": receipt.semiring_type,
3464                "recorded_at": receipt.recorded_at,
3465                "message": "Provenance set successfully",
3466            })),
3467            Err(e) => Err(ErrorData::internal_error(
3468                format!("Provenance error: {e}"),
3469                None,
3470            )),
3471        }
3472    }
3473
3474    #[tool(
3475        description = "Run a memory lifecycle pass: analyze items for syndromes, compute corrections, identify subtraction candidates, and check compression needs.",
3476        annotations(read_only_hint = true)
3477    )]
3478    fn sm_run_lifecycle(
3479        &self,
3480        Parameters(RunLifecycleParams { item_ids }): Parameters<RunLifecycleParams>,
3481    ) -> Result<Json<StructuredOutput>, ErrorData> {
3482        use semantic_memory::decoder::{compute_correction, detect_syndromes};
3483        use semantic_memory::integration::{
3484            corrections_to_subtraction_candidates, should_trigger_recompression,
3485        };
3486
3487        let results: Vec<(String, f64)> = item_ids.iter().map(|id| (id.clone(), 0.5)).collect();
3488        let syndromes = detect_syndromes(&results, &[]);
3489        let corrections = compute_correction(&syndromes, 10.0);
3490
3491        let sub_candidates = corrections_to_subtraction_candidates(&corrections);
3492
3493        let subtracted_count = sub_candidates.len();
3494        let remaining_count = item_ids.len().saturating_sub(subtracted_count);
3495        let recompression = should_trigger_recompression(subtracted_count, remaining_count, false);
3496
3497        let store = &self.bridge.store;
3498        let graph_edges = tokio::task::block_in_place(|| {
3499            Handle::current().block_on(store.list_all_graph_edges())
3500        });
3501        let stored_edges: Vec<(String, String)> = graph_edges
3502            .as_ref()
3503            .map(|edges| {
3504                edges
3505                    .iter()
3506                    .map(|edge| (edge.source.clone(), edge.target.clone()))
3507                    .collect()
3508            })
3509            .unwrap_or_default();
3510
3511        let mut topology_voids: Vec<serde_json::Value> = Vec::new();
3512        let mut betti = serde_json::json!({
3513            "betti_0": 0usize,
3514            "betti_1": 0usize,
3515        });
3516        #[allow(unused_mut)]
3517        let mut topology_error: Option<String> = None;
3518
3519        let mut communities: Vec<serde_json::Value> = Vec::new();
3520        let mut community_contradictions: Vec<serde_json::Value> = Vec::new();
3521        #[allow(unused_mut)]
3522        let mut community_error: Option<String> = None;
3523
3524        let mut subgraph_assessment = serde_json::json!({
3525            "subgraphs_identified": 0usize,
3526            "subgraphs_pruned": 0usize,
3527        });
3528        #[allow(unused_mut)]
3529        let mut subgraph_error: Option<String> = None;
3530
3531        #[cfg(feature = "full")]
3532        {
3533            use std::collections::HashMap;
3534
3535            if !stored_edges.is_empty() {
3536                let analysis_edges = stored_edges.clone();
3537
3538                use semantic_memory::topology::{compute_betti_numbers, find_voids};
3539
3540                let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
3541                for (left, right) in &analysis_edges {
3542                    adjacency
3543                        .entry(left.clone())
3544                        .or_default()
3545                        .push(right.clone());
3546                    adjacency
3547                        .entry(right.clone())
3548                        .or_default()
3549                        .push(left.clone());
3550                }
3551
3552                let betti_numbers = compute_betti_numbers(&adjacency);
3553                betti = serde_json::json!({
3554                    "betti_0": betti_numbers.betti_0,
3555                    "betti_1": betti_numbers.betti_1,
3556                });
3557
3558                topology_voids = find_voids(&analysis_edges)
3559                    .into_iter()
3560                    .map(|v| {
3561                        serde_json::json!({
3562                            "description": v.description,
3563                            "void_type": format!("{:?}", v.void_type),
3564                            "nearby_items": v.nearby_items,
3565                            "suggested_connections": v.suggested_connections,
3566                        })
3567                    })
3568                    .collect();
3569
3570                use semantic_memory::community::{
3571                    community_contradiction_scan, detect_communities,
3572                };
3573
3574                let detected = detect_communities(&analysis_edges, 1.0, 42);
3575                communities = detected
3576                    .iter()
3577                    .map(|c| {
3578                        serde_json::json!({
3579                            "id": c.id,
3580                            "members": c.members,
3581                            "level": c.level,
3582                            "parent": c.parent,
3583                            "member_count": c.members.len(),
3584                        })
3585                    })
3586                    .collect();
3587
3588                community_contradictions = community_contradiction_scan(&detected, &[])
3589                    .into_iter()
3590                    .map(|cc| {
3591                        serde_json::json!({
3592                            "community_id": cc.community_id,
3593                            "item_a": cc.item_a,
3594                            "item_b": cc.item_b,
3595                            "description": cc.description,
3596                        })
3597                    })
3598                    .collect();
3599
3600                use semantic_memory::integration::autonomous_subgraph_maintenance;
3601                use semantic_memory::subgraph_pruning::AccessLog;
3602                use std::collections::HashSet;
3603
3604                let mut access_items: HashSet<String> = HashSet::new();
3605                for (left, right) in &analysis_edges {
3606                    access_items.insert(left.clone());
3607                    access_items.insert(right.clone());
3608                }
3609
3610                let access_logs = access_items
3611                    .into_iter()
3612                    .map(|item| AccessLog {
3613                        item_id: item,
3614                        access_count: 1,
3615                        last_accessed: "1970-01-01T00:00:00Z".to_string(),
3616                    })
3617                    .collect::<Vec<_>>();
3618
3619                let report = autonomous_subgraph_maintenance(&analysis_edges, &access_logs, &[], 0);
3620                subgraph_assessment = serde_json::json!({
3621                    "subgraphs_identified": report.subgraphs_identified,
3622                    "subgraphs_pruned": report.subgraphs_pruned,
3623                    "summary": report.summary,
3624                });
3625            }
3626        }
3627
3628        #[cfg(not(feature = "full"))]
3629        {
3630            if !stored_edges.is_empty() {
3631                topology_error = Some(
3632                    "topology/community/subgraph phases require the `full` feature".to_string(),
3633                );
3634                community_error = Some(
3635                    "topology/community/subgraph phases require the `full` feature".to_string(),
3636                );
3637                subgraph_error = Some(
3638                    "topology/community/subgraph phases require the `full` feature".to_string(),
3639                );
3640            }
3641        }
3642
3643        #[cfg(feature = "full")]
3644        let (f32_count, compressed_count) =
3645            item_ids
3646                .iter()
3647                .fold((0usize, 0usize), |(f32_count, compressed_count), _| {
3648                    use semantic_memory::compression_governor::{
3649                        decide_quantization, QuantizationLevel,
3650                    };
3651
3652                    match decide_quantization(0.5) {
3653                        QuantizationLevel::F32 => (f32_count + 1, compressed_count),
3654                        _ => (f32_count, compressed_count + 1),
3655                    }
3656                });
3657        #[cfg(not(feature = "full"))]
3658        let (f32_count, compressed_count) = (0usize, 0usize);
3659
3660        json_to_output(&serde_json::json!({
3661            "ok": true,
3662            "items_analyzed": item_ids.len(),
3663            "syndromes_detected": syndromes.len(),
3664            "corrections_computed": corrections.len(),
3665            "subtraction_candidates": sub_candidates.iter().map(|c| serde_json::json!({
3666                "item_id": c.item_id,
3667                "structuring_score": c.structuring_score,
3668                "operation_type": c.operation_type,
3669                "reason": c.reason,
3670            })).collect::<Vec<_>>(),
3671            "recompression_triggered": recompression.triggered,
3672            "recompression_reason": recompression.reason,
3673            "topology": {
3674                "enabled": !stored_edges.is_empty(),
3675                "voids": topology_voids,
3676                "void_count": topology_voids.len(),
3677                "betti_numbers": betti,
3678                "error": topology_error,
3679            },
3680            "community_detection": {
3681                "enabled": !stored_edges.is_empty(),
3682                "communities": communities,
3683                "community_count": communities.len(),
3684                "contradictions": community_contradictions,
3685                "contradiction_count": community_contradictions.len(),
3686                "error": community_error,
3687            },
3688            "subgraph_pruning_assessment": {
3689                "enabled": !stored_edges.is_empty(),
3690                "subgraph_count": subgraph_assessment["subgraphs_identified"].as_u64().unwrap_or(0),
3691                "pruned_count": subgraph_assessment["subgraphs_pruned"].as_u64().unwrap_or(0),
3692                "summary": subgraph_assessment["summary"].as_str().unwrap_or(""),
3693                "error": subgraph_error,
3694            },
3695            "turbo_quantization_assessment": {
3696                "items_assessed": item_ids.len(),
3697                "would_retain_f32": f32_count,
3698                "would_compress": compressed_count,
3699            },
3700            "summary": format!(
3701                "Analyzed {} items: {} syndromes, {} corrections, {} subtraction candidates, recompression: {}",
3702                item_ids.len(), syndromes.len(), corrections.len(), sub_candidates.len(),
3703                if recompression.triggered { "needed" } else { "not needed" }
3704            ),
3705        }))
3706    }
3707
3708    // ── First-class graph edge tools ───────────────────────────────
3709
3710    #[tool(
3711        description = "Add a durable, typed graph edge between two nodes. Edge types: semantic, temporal, causal, entity. Idempotent — same edge returns existing ID.",
3712        annotations(idempotent_hint = true)
3713    )]
3714    fn sm_add_graph_edge(
3715        &self,
3716        Parameters(params): Parameters<AddGraphEdgeParams>,
3717    ) -> Result<Json<StructuredOutput>, ErrorData> {
3718        use semantic_memory::GraphEdgeType;
3719
3720        // SM-AUD-015: Validate numeric params are finite and in range.
3721        if let Some(cs) = params.cosine_similarity {
3722            if !cs.is_finite() || !(0.0..=1.0).contains(&cs) {
3723                return Err(ErrorData::invalid_params(
3724                    format!("cosine_similarity must be finite and in [0.0, 1.0], got {cs}"),
3725                    None,
3726                ));
3727            }
3728        }
3729        if let Some(conf) = params.confidence {
3730            if !conf.is_finite() || !(0.0..=1.0).contains(&conf) {
3731                return Err(ErrorData::invalid_params(
3732                    format!("confidence must be finite and in [0.0, 1.0], got {conf}"),
3733                    None,
3734                ));
3735            }
3736        }
3737
3738        let edge_type = match params.edge_type {
3739            EdgeType::Semantic => GraphEdgeType::Semantic {
3740                cosine_similarity: params.cosine_similarity.unwrap_or(0.5),
3741            },
3742            EdgeType::Temporal => GraphEdgeType::Temporal {
3743                delta_secs: params.delta_secs.unwrap_or(0),
3744            },
3745            EdgeType::Causal => GraphEdgeType::Causal {
3746                confidence: params.confidence.unwrap_or(0.5),
3747                evidence_ids: params.evidence_ids.unwrap_or_default(),
3748            },
3749            EdgeType::Entity => GraphEdgeType::Entity {
3750                relation: params.relation.unwrap_or_else(|| "related".to_string()),
3751            },
3752        };
3753
3754        // MCP-004: Reject malformed metadata JSON instead of silently dropping it.
3755        let metadata = match params.metadata.as_deref() {
3756            None => None,
3757            Some(s) => match serde_json::from_str::<serde_json::Value>(s) {
3758                Ok(v) => Some(v),
3759                Err(e) => {
3760                    return Err(ErrorData::invalid_params(
3761                        format!("metadata is not valid JSON: {e}"),
3762                        None,
3763                    ))
3764                }
3765            },
3766        };
3767
3768        let store = &self.bridge.store;
3769        let result = tokio::task::block_in_place(|| {
3770            Handle::current().block_on(store.add_graph_edge(
3771                &params.source,
3772                &params.target,
3773                edge_type,
3774                params.weight,
3775                metadata,
3776            ))
3777        });
3778
3779        match result {
3780            Ok(edge) => json_to_output(&serde_json::json!({
3781                "ok": true,
3782                "receipt": mcp_receipt("sm_add_graph_edge"),
3783                "id": edge.id,
3784                "source": edge.source,
3785                "target": edge.target,
3786                "edge_type": edge.edge_type,
3787                "weight": edge.weight,
3788                "content_digest": edge.content_digest,
3789                "recorded_at": edge.recorded_at,
3790                "message": "Graph edge added successfully",
3791            })),
3792            Err(e) => Err(ErrorData::internal_error(
3793                format!("Error adding graph edge: {e}"),
3794                None,
3795            )),
3796        }
3797    }
3798
3799    #[tool(
3800        description = "List graph edges for a specific node (as source or target), or all edges if no node_id. Returns non-invalidated edges only.",
3801        annotations(read_only_hint = true)
3802    )]
3803    fn sm_list_graph_edges(
3804        &self,
3805        Parameters(ListGraphEdgesParams { node_id }): Parameters<ListGraphEdgesParams>,
3806    ) -> Result<Json<StructuredOutput>, ErrorData> {
3807        let store = &self.bridge.store;
3808        let result = match node_id {
3809            Some(id) => tokio::task::block_in_place(|| {
3810                Handle::current().block_on(store.list_graph_edges_for_node(&id))
3811            }),
3812            None => tokio::task::block_in_place(|| {
3813                Handle::current().block_on(store.list_all_graph_edges())
3814            }),
3815        };
3816
3817        match result {
3818            Ok(edges) => json_to_output(&serde_json::json!({
3819                "ok": true,
3820                "edges": edges.iter().map(|e| serde_json::json!({
3821                    "id": e.id,
3822                    "source": e.source,
3823                    "target": e.target,
3824                    "edge_type": e.edge_type,
3825                    "weight": e.weight,
3826                    "metadata": e.metadata,
3827                    "recorded_at": e.recorded_at,
3828                })).collect::<Vec<_>>(),
3829                "count": edges.len(),
3830            })),
3831            Err(e) => Err(ErrorData::internal_error(
3832                format!("Error listing graph edges: {e}"),
3833                None,
3834            )),
3835        }
3836    }
3837
3838    #[tool(
3839        description = "Invalidate a stored graph edge by ID. Append-only — edge is never deleted, only marked invalidated with a reason.",
3840        annotations(idempotent_hint = true)
3841    )]
3842    fn sm_invalidate_graph_edge(
3843        &self,
3844        Parameters(InvalidateGraphEdgeParams { edge_id, reason }): Parameters<
3845            InvalidateGraphEdgeParams,
3846        >,
3847    ) -> Result<Json<StructuredOutput>, ErrorData> {
3848        let store = &self.bridge.store;
3849        let result = tokio::task::block_in_place(|| {
3850            Handle::current().block_on(store.invalidate_graph_edge(&edge_id, &reason))
3851        });
3852
3853        match result {
3854            Ok(()) => json_to_output(&serde_json::json!({
3855                "ok": true,
3856                "receipt": mcp_receipt("sm_invalidate_graph_edge"),
3857                "edge_id": edge_id,
3858                "message": "Edge invalidated successfully",
3859            })),
3860            Err(e) => Err(ErrorData::internal_error(
3861                format!("Error invalidating edge: {e}"),
3862                None,
3863            )),
3864        }
3865    }
3866
3867    // ── Factor graph, topology, and community tools ─────────────────
3868
3869    #[tool(
3870        description = "Run factor graph belief propagation on stored graph edges. Models all 4 edge types as factors. Returns unified confidence scores after convergence.",
3871        annotations(read_only_hint = true)
3872    )]
3873    fn sm_factor_graph(
3874        &self,
3875        Parameters(params): Parameters<FactorGraphParams>,
3876    ) -> Result<Json<StructuredOutput>, ErrorData> {
3877        use semantic_memory::factor_graph::{factors_from_edges, FactorGraph, FactorGraphConfig};
3878
3879        let defaults = FactorGraphConfig::default();
3880        let config = FactorGraphConfig {
3881            semantic_weight: params.semantic_weight.unwrap_or(defaults.semantic_weight),
3882            temporal_weight: params.temporal_weight.unwrap_or(defaults.temporal_weight),
3883            causal_weight: params.causal_weight.unwrap_or(defaults.causal_weight),
3884            entity_weight: params.entity_weight.unwrap_or(defaults.entity_weight),
3885            self_influence: params.self_influence.unwrap_or(defaults.self_influence),
3886            max_iterations: params
3887                .max_iterations
3888                .map(|v| v as usize)
3889                .unwrap_or(defaults.max_iterations),
3890            convergence_threshold: params
3891                .convergence_threshold
3892                .unwrap_or(defaults.convergence_threshold),
3893        };
3894
3895        // Use neighborhood loading: only load edges within 2 hops of the
3896        // node seeds instead of the entire graph.
3897        let seed_ids: Vec<String> = params.nodes.iter().map(|n| n.item_id.clone()).collect();
3898        let raw_edges = load_neighborhood_factor_edges(&self.bridge.store, &seed_ids)?;
3899        let factors = factors_from_edges(&raw_edges);
3900
3901        let nodes: Vec<(String, f64)> = params
3902            .nodes
3903            .iter()
3904            .map(|n| (n.item_id.clone(), n.initial_belief))
3905            .collect();
3906
3907        let graph = FactorGraph::new(&nodes, factors, config);
3908        let result = graph.propagate();
3909
3910        json_to_output(&serde_json::json!({
3911            "ok": true,
3912            "node_beliefs": result.node_beliefs,
3913            "iterations": result.iterations,
3914            "converged": result.converged,
3915            "elapsed_ms": result.elapsed_ms,
3916            "edges_loaded": raw_edges.len(),
3917            "edges_scope": "neighborhood",
3918            "factor_counts": {
3919                "semantic": result.factor_counts.semantic,
3920                "temporal": result.factor_counts.temporal,
3921                "causal": result.factor_counts.causal,
3922                "entity": result.factor_counts.entity,
3923                "total": result.factor_counts.total(),
3924            },
3925            "config": {
3926                "semantic_weight": result.config.semantic_weight,
3927                "temporal_weight": result.config.temporal_weight,
3928                "causal_weight": result.config.causal_weight,
3929                "entity_weight": result.config.entity_weight,
3930                "self_influence": result.config.self_influence,
3931                "max_iterations": result.config.max_iterations,
3932                "convergence_threshold": result.config.convergence_threshold,
3933            },
3934        }))
3935    }
3936
3937    #[tool(
3938        description = "Find topological voids in the knowledge graph. Computes Betti numbers (components and cycles) and detects structural gaps. Loads edges from store.",
3939        annotations(read_only_hint = true)
3940    )]
3941    fn sm_topology(
3942        &self,
3943        Parameters(_params): Parameters<TopologyParams>,
3944    ) -> Result<Json<StructuredOutput>, ErrorData> {
3945        use semantic_memory::topology::{compute_betti_numbers, find_voids, gap_report};
3946
3947        // MCP-001: Load edges from the store, not from caller-supplied params.
3948        let edges = load_stored_edge_pairs(&self.bridge.store)?;
3949
3950        let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3951            std::collections::HashMap::new();
3952        for (src, tgt) in &edges {
3953            adjacency.entry(src.clone()).or_default().push(tgt.clone());
3954            adjacency.entry(tgt.clone()).or_default().push(src.clone());
3955        }
3956
3957        let betti = compute_betti_numbers(&adjacency);
3958        let voids = find_voids(&edges);
3959        let report = gap_report(&voids);
3960
3961        json_to_output(&serde_json::json!({
3962            "ok": true,
3963            "betti_numbers": {
3964                "betti_0": betti.betti_0,
3965                "betti_1": betti.betti_1,
3966            },
3967            "voids": voids.iter().map(|v| serde_json::json!({
3968                "description": v.description,
3969                "nearby_items": v.nearby_items,
3970                "suggested_connections": v.suggested_connections,
3971                "void_type": format!("{:?}", v.void_type),
3972            })).collect::<Vec<_>>(),
3973            "void_count": voids.len(),
3974            "edges_loaded_from_store": edges.len(),
3975            "report": report,
3976        }))
3977    }
3978
3979    #[tool(
3980        description = "Detect communities in the knowledge graph (Leiden-inspired). Returns community assignments, optional contradiction scans, and compression recommendations.",
3981        annotations(read_only_hint = true)
3982    )]
3983    fn sm_community(
3984        &self,
3985        Parameters(params): Parameters<CommunityParams>,
3986    ) -> Result<Json<StructuredOutput>, ErrorData> {
3987        use semantic_memory::community::{
3988            community_aware_compression, community_contradiction_scan, detect_communities,
3989        };
3990
3991        // MCP-001: Load edges from the store, not from caller-supplied params.
3992        let edges = load_stored_edge_pairs(&self.bridge.store)?;
3993
3994        let resolution = params.resolution.unwrap_or(1.0);
3995        let seed = params.seed.unwrap_or(42);
3996
3997        let communities = detect_communities(&edges, resolution, seed);
3998
3999        let contradictions = params.contradictions.unwrap_or_default();
4000        let community_contras = community_contradiction_scan(&communities, &contradictions);
4001
4002        let importance_scores = params.importance_scores.unwrap_or_default();
4003        let compression = community_aware_compression(&communities, &importance_scores);
4004
4005        let summarize = params.summarize.unwrap_or(false);
4006        let store = &self.bridge.store;
4007        let communities_json: Vec<serde_json::Value> = communities
4008            .iter()
4009            .map(|c| {
4010                let summary: Option<String> = if summarize && !c.members.is_empty() {
4011                    let member_texts: Vec<String> = c
4012                        .members
4013                        .iter()
4014                        .filter_map(|mid| {
4015                            let bare = mid.strip_prefix("fact:").unwrap_or(mid);
4016                            tokio::task::block_in_place(|| {
4017                                Handle::current().block_on(store.get_fact(bare))
4018                            })
4019                            .ok()
4020                            .flatten()
4021                            .map(|f| f.content)
4022                        })
4023                        .collect();
4024                    if !member_texts.is_empty() {
4025                        let combined = member_texts.join("\n---\n");
4026                        let prompt = format!(
4027                            "Summarize these related facts in 1-2 sentences:\n{combined}\nSummary:"
4028                        );
4029                        let body = serde_json::json!({
4030                            "model": "granite4.1:3b",
4031                            "prompt": prompt,
4032                            "stream": false,
4033                            "options": {"temperature": 0, "num_predict": 100}
4034                        });
4035                        reqwest::blocking::Client::new()
4036                            .post("http://127.0.0.1:11434/api/generate")
4037                            .json(&body)
4038                            .send()
4039                            .ok()
4040                            .and_then(|resp| resp.json::<serde_json::Value>().ok())
4041                            .and_then(|v| {
4042                                v.get("response")
4043                                    .and_then(|r| r.as_str())
4044                                    .map(|s| s.trim().to_string())
4045                            })
4046                    } else {
4047                        None
4048                    }
4049                } else {
4050                    None
4051                };
4052                serde_json::json!({
4053                    "id": c.id,
4054                    "members": c.members,
4055                    "level": c.level,
4056                    "parent": c.parent,
4057                    "member_count": c.members.len(),
4058                    "summary": summary,
4059                })
4060            })
4061            .collect();
4062
4063        json_to_output(&serde_json::json!({
4064            "ok": true,
4065            "communities": communities_json,
4066            "community_count": communities.len(),
4067            "contradictions": community_contras.iter().map(|cc| serde_json::json!({
4068                "community_id": cc.community_id,
4069                "item_a": cc.item_a,
4070                "item_b": cc.item_b,
4071                "description": cc.description,
4072            })).collect::<Vec<_>>(),
4073            "contradiction_count": community_contras.len(),
4074            "compression_recommendations": compression.iter().map(|cr| serde_json::json!({
4075                "community_id": cr.community_id,
4076                "quantization_level": cr.quantization_level,
4077                "reason": cr.reason,
4078            })).collect::<Vec<_>>(),
4079            "compression_count": compression.len(),
4080            "edges_loaded_from_store": edges.len(),
4081        }))
4082    }
4083
4084    // ── Delete / forget tools (admin-ops) ────────────────────────────
4085    // Governed forgetting. Corrected facts should still use supersession; this
4086    // tool closes the selected fact and all derived access paths while retaining
4087    // a content-free tombstone receipt.
4088
4089    #[tool(
4090        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.",
4091        annotations(read_only_hint = true)
4092    )]
4093    fn sm_subgraph_prune(
4094        &self,
4095        Parameters(SubgraphPruneParams { dry_run, max_prune }): Parameters<SubgraphPruneParams>,
4096    ) -> Result<Json<StructuredOutput>, ErrorData> {
4097        #[cfg(feature = "subgraph-pruning")]
4098        {
4099            use semantic_memory::integration::autonomous_subgraph_maintenance;
4100            use semantic_memory::subgraph_pruning::AccessLog;
4101
4102            let dry = dry_run.unwrap_or(true);
4103            let max = max_prune.map(|v| v as usize).unwrap_or(5);
4104
4105            // Load edges from store
4106            let edges = load_stored_edge_pairs(&self.bridge.store)?;
4107
4108            // No access logs available — derive empty (all subgraphs treated as equally stale)
4109            let access_logs: Vec<AccessLog> = Vec::new();
4110
4111            // Load contradictions from contradiction graph edges
4112            let raw_edges = tokio::task::block_in_place(|| {
4113                Handle::current().block_on(self.bridge.store.list_all_graph_edges())
4114            })
4115            .map_err(|e| ErrorData::internal_error(format!("load edges failed: {e}"), None))?;
4116            let contradictions: Vec<(String, String)> = raw_edges
4117                .iter()
4118                .filter_map(|e| {
4119                    let parsed = e
4120                        .edge_type_parsed
4121                        .clone()
4122                        .or_else(|| serde_json::from_str(&e.edge_type).ok());
4123                    match parsed {
4124                        Some(semantic_memory::GraphEdgeType::Entity { relation })
4125                            if relation == "contradicts" =>
4126                        {
4127                            Some((e.source.clone(), e.target.clone()))
4128                        }
4129                        _ => None,
4130                    }
4131                })
4132                .collect();
4133
4134            let prune_count = if dry { 0 } else { max };
4135            let report =
4136                autonomous_subgraph_maintenance(&edges, &access_logs, &contradictions, prune_count);
4137
4138            json_to_output(&serde_json::json!({
4139                "ok": true,
4140                "dry_run": dry,
4141                "subgraphs_identified": report.subgraphs_identified,
4142                "subgraphs_pruned": report.subgraphs_pruned,
4143                "receipts": report.receipts.iter().map(|r| serde_json::json!({
4144                    "subgraph_root": r.subgraph_root,
4145                    "pruned_nodes": r.pruned_nodes,
4146                })).collect::<Vec<_>>(),
4147                "summary": report.summary,
4148                "receipt_field": mcp_receipt("sm_subgraph_prune"),
4149            }))
4150        }
4151        #[cfg(not(feature = "subgraph-pruning"))]
4152        {
4153            let _ = (dry_run, max_prune);
4154            json_to_output(&serde_json::json!({
4155                "ok": true,
4156                "note": "subgraph-pruning feature not enabled",
4157                "receipt": mcp_receipt("sm_subgraph_prune"),
4158            }))
4159        }
4160    }
4161
4162    #[tool(
4163        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.",
4164        annotations(destructive_hint = true)
4165    )]
4166    fn sm_delete_fact(
4167        &self,
4168        Parameters(DeleteFactParams { fact_id }): Parameters<DeleteFactParams>,
4169    ) -> Result<Json<StructuredOutput>, ErrorData> {
4170        let bare = fact_id
4171            .strip_prefix("fact:")
4172            .unwrap_or(&fact_id)
4173            .to_string();
4174        let store = &self.bridge.store;
4175        let fact = tokio::task::block_in_place(|| {
4176            Handle::current().block_on(store.get_fact_raw_compat(&bare))
4177        })
4178        .map_err(|e| ErrorData::internal_error(format!("delete_fact lookup error: {e}"), None))?
4179        .ok_or_else(|| ErrorData::invalid_params(format!("fact not found: fact:{bare}"), None))?;
4180        let origin_record = tokio::task::block_in_place(|| {
4181            Handle::current().block_on(store.authority().get_origin_authority(&bare))
4182        })
4183        .map_err(|e| {
4184            ErrorData::internal_error(format!("delete_fact origin lookup error: {e}"), None)
4185        })?
4186        .ok_or_else(|| {
4187            ErrorData::invalid_params(
4188                format!("fact has no governed origin authority: fact:{bare}"),
4189                None,
4190            )
4191        })?;
4192        let resource_principal = origin_record.label.origin_principal;
4193        let permit = semantic_memory::AuthorityPermit::operator_system(
4194            resource_principal.clone(),
4195            "caller:sm_delete_fact",
4196            semantic_memory::AuthorityPermit::FORGET_CAPABILITY,
4197        )
4198        .with_origin(semantic_memory::OriginAuthorityLabelV1::operator_system(
4199            &resource_principal,
4200            "caller:sm_delete_fact",
4201        ));
4202        let request = semantic_memory::ForgettingClosureRequestV1::new(
4203            vec![bare.clone()],
4204            fact.namespace,
4205            "explicit MCP fact-forgetting request",
4206            4096,
4207        );
4208        let result = tokio::task::block_in_place(|| {
4209            Handle::current().block_on(store.authority().forget(
4210                permit,
4211                format!("mcp-sm-delete-fact:{}", uuid::Uuid::new_v4()),
4212                request,
4213            ))
4214        });
4215        match result {
4216            Ok(receipt) => json_to_output(&serde_json::json!({
4217                "ok": true,
4218                "deleted": false,
4219                "forgotten": true,
4220                "fact_id": format!("fact:{bare}"),
4221                "forgetting_receipt": receipt,
4222                "message": "Fact forgotten through governed dependency closure",
4223            })),
4224            Err(e) => Err(ErrorData::internal_error(
4225                format!("delete_fact forgetting error: {e}"),
4226                None,
4227            )),
4228        }
4229    }
4230
4231    #[tool(
4232        description = "Permanently delete ALL memory in a namespace — facts, documents, chunks, sessions/messages. HARD delete, irreversible. Returns per-surface deletion count.",
4233        annotations(destructive_hint = true)
4234    )]
4235    fn sm_delete_namespace(
4236        &self,
4237        Parameters(DeleteNamespaceParams { namespace }): Parameters<DeleteNamespaceParams>,
4238    ) -> Result<Json<StructuredOutput>, ErrorData> {
4239        let store = &self.bridge.store;
4240        let result = tokio::task::block_in_place(|| {
4241            Handle::current().block_on(store.delete_namespace(&namespace))
4242        });
4243        match result {
4244            Ok(r) => json_to_output(&serde_json::json!({
4245                "ok": true,
4246                "receipt": mcp_receipt("sm_delete_namespace"),
4247                "namespace": namespace,
4248                "deleted": {
4249                    "facts": r.facts,
4250                    "documents": r.documents,
4251                    "chunks": r.chunks,
4252                    "messages": r.messages,
4253                    "sessions": r.sessions,
4254                    "episodes": r.episodes,
4255                    "projection_rows": r.projection_rows,
4256                },
4257                "message": "Namespace permanently deleted",
4258            })),
4259            Err(e) => Err(ErrorData::internal_error(
4260                format!("delete_namespace error: {e}"),
4261                None,
4262            )),
4263        }
4264    }
4265
4266    #[tool(
4267        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.",
4268        annotations(idempotent_hint = true)
4269    )]
4270    fn sm_update_fact(
4271        &self,
4272        Parameters(UpdateFactParams { fact_id, content }): Parameters<UpdateFactParams>,
4273    ) -> Result<Json<StructuredOutput>, ErrorData> {
4274        let bare = fact_id
4275            .strip_prefix("fact:")
4276            .unwrap_or(&fact_id)
4277            .to_string();
4278        let store = &self.bridge.store;
4279        let result = tokio::task::block_in_place(|| {
4280            Handle::current().block_on(store.update_fact(&bare, &content))
4281        });
4282        match result {
4283            Ok(()) => json_to_output(&serde_json::json!({
4284                "ok": true,
4285                "receipt": mcp_receipt("sm_update_fact"),
4286                "fact_id": format!("fact:{bare}"),
4287                "message": "Fact content updated and re-embedded",
4288            })),
4289            Err(e) => Err(ErrorData::internal_error(
4290                format!("update_fact error: {e}"),
4291                None,
4292            )),
4293        }
4294    }
4295
4296    #[tool(
4297        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."
4298    )]
4299    fn sm_consolidate_facts(
4300        &self,
4301        Parameters(ConsolidateFactsParams {
4302            keep_id,
4303            supersede_id,
4304            merged_content,
4305        }): Parameters<ConsolidateFactsParams>,
4306    ) -> Result<Json<StructuredOutput>, ErrorData> {
4307        let keep_bare = keep_id
4308            .strip_prefix("fact:")
4309            .unwrap_or(&keep_id)
4310            .to_string();
4311        let sup_bare = supersede_id
4312            .strip_prefix("fact:")
4313            .unwrap_or(&supersede_id)
4314            .to_string();
4315        let store = &self.bridge.store;
4316
4317        // Get both facts to determine namespace and merge content
4318        let keep_fact =
4319            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&keep_bare)));
4320        let sup_fact =
4321            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&sup_bare)));
4322
4323        let (_namespace, final_content) = match (keep_fact, sup_fact) {
4324            (Ok(Some(k)), Ok(Some(s))) => {
4325                let ns = k.namespace.clone();
4326                let content = merged_content.unwrap_or_else(|| {
4327                    if k.content.len() >= s.content.len() {
4328                        if !k.content.contains(&s.content) {
4329                            format!("{}\n\nAdditional: {}", k.content, s.content)
4330                        } else {
4331                            k.content.clone()
4332                        }
4333                    } else if !s.content.contains(&k.content) {
4334                        format!("{}\n\nAdditional: {}", s.content, k.content)
4335                    } else {
4336                        s.content.clone()
4337                    }
4338                });
4339                (ns, content)
4340            }
4341            (Ok(Some(k)), _) => (
4342                k.namespace.clone(),
4343                merged_content.unwrap_or(k.content.clone()),
4344            ),
4345            (Err(_), _) | (Ok(None), _) => {
4346                return Err(ErrorData::internal_error(
4347                    "keep fact not found".to_string(),
4348                    None,
4349                ));
4350            }
4351        };
4352
4353        // Atomic consolidation: update kept fact + add supersession edge in one transaction
4354        let result = tokio::task::block_in_place(|| {
4355            Handle::current().block_on(store.consolidate_facts(
4356                &keep_bare,
4357                &sup_bare,
4358                &final_content,
4359            ))
4360        });
4361
4362        match result {
4363            Ok(()) => json_to_output(&serde_json::json!({
4364                "ok": true,
4365                "receipt": mcp_receipt("sm_consolidate_facts"),
4366                "kept_fact_id": format!("fact:{}", keep_bare),
4367                "superseded_fact_id": format!("fact:{}", sup_bare),
4368                "message": format!("Facts consolidated atomically: kept fact:{} supersedes fact:{}", keep_bare, sup_bare),
4369            })),
4370            Err(e) => Err(ErrorData::internal_error(
4371                format!("consolidation failed: {e}"),
4372                None,
4373            )),
4374        }
4375    }
4376
4377    // ── RL routing feedback ────────────────────────────────────────────
4378
4379    #[tool(
4380        description = "Return the current persisted RL routing policy, including weights, training example count, and last update time.",
4381        annotations(read_only_hint = true)
4382    )]
4383    fn sm_get_routing_policy(&self) -> Result<Json<StructuredOutput>, ErrorData> {
4384        use semantic_memory::rl_routing::is_trained;
4385
4386        let policy = tokio::task::block_in_place(|| {
4387            Handle::current().block_on(self.bridge.store.load_routing_policy())
4388        })
4389        .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
4390
4391        match policy {
4392            Some(policy) => json_to_output(&serde_json::json!({
4393                "ok": true,
4394                "policy": {
4395                    "weights": policy.weights,
4396                    "training_examples_count": policy.trained_examples,
4397                    "trained": is_trained(&policy),
4398                    "last_updated": policy.last_updated,
4399                }
4400            })),
4401            None => json_to_output(&serde_json::json!({
4402                "ok": true,
4403                "policy": null,
4404            })),
4405        }
4406    }
4407
4408    #[tool(
4409        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.",
4410        annotations(
4411            read_only_hint = false,
4412            destructive_hint = false,
4413            idempotent_hint = false
4414        )
4415    )]
4416    fn sm_record_outcome(
4417        &self,
4418        Parameters(RecordOutcomeParams { query, outcome }): Parameters<RecordOutcomeParams>,
4419    ) -> Result<Json<StructuredOutput>, ErrorData> {
4420        use semantic_memory::rl_routing::{
4421            is_trained, record_routing_outcome, route_with_policy, RoutingOutcome,
4422        };
4423        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
4424
4425        let outcome_enum = match outcome.to_lowercase().as_str() {
4426            "good" => RoutingOutcome::Good,
4427            "bad" => RoutingOutcome::Bad,
4428            "neutral" => RoutingOutcome::Neutral,
4429            _ => {
4430                return Err(ErrorData::invalid_params(
4431                    format!("outcome must be 'good', 'bad', or 'neutral', got '{outcome}'"),
4432                    None,
4433                ));
4434            }
4435        };
4436
4437        let profile = QueryProfile::from_query(&query);
4438        let router = RetrievalRouter::default();
4439        let store = &self.bridge.store;
4440        let mut batch = self
4441            .routing_policy_batch
4442            .lock()
4443            .map_err(|_| ErrorData::internal_error("routing policy batch lock poisoned", None))?;
4444        let mut policy = match batch.policy.take() {
4445            Some(policy) => policy,
4446            None => tokio::task::block_in_place(|| {
4447                Handle::current().block_on(store.load_routing_policy())
4448            })
4449            .map_err(|e| {
4450                ErrorData::internal_error(format!("load routing policy error: {e}"), None)
4451            })?
4452            .unwrap_or_default(),
4453        };
4454        let decision = if is_trained(&policy) {
4455            route_with_policy(&policy, &profile)
4456        } else {
4457            router.route(&profile)
4458        };
4459        record_routing_outcome(&mut policy, &profile, &decision, outcome_enum);
4460        batch.pending_outcomes += 1;
4461        let persisted = batch.pending_outcomes >= ROUTING_POLICY_PERSIST_BATCH;
4462        if persisted {
4463            if let Err(e) = tokio::task::block_in_place(|| {
4464                Handle::current().block_on(store.save_routing_policy(&policy))
4465            }) {
4466                batch.policy = Some(policy);
4467                return Err(ErrorData::internal_error(
4468                    format!("persist routing policy error: {e}"),
4469                    None,
4470                ));
4471            }
4472            batch.pending_outcomes = 0;
4473        }
4474        let pending_outcomes = batch.pending_outcomes;
4475        batch.policy = Some(policy.clone());
4476
4477        json_to_output(&serde_json::json!({
4478            "ok": true,
4479            "receipt": mcp_receipt("sm_record_outcome"),
4480            "mutating": true,
4481            "query": query,
4482            "feedback": {"kind": "ProxyLabel", "label": outcome},
4483            "routing_decision": {
4484                "bm25_coarse": decision.bm25_coarse,
4485                "vector_medium": decision.vector_medium,
4486                "rerank_fine": decision.rerank_fine,
4487                "graph_expansion": decision.graph_expansion,
4488                "decoder": decision.decoder,
4489                "discord": decision.discord,
4490                "no_retrieval": decision.no_retrieval,
4491                "reasoning": decision.reasoning,
4492            },
4493            "policy_state": {
4494                "trained_examples": policy.trained_examples,
4495                "baseline": policy.baseline,
4496                "weights": policy.weights,
4497                "last_updated": policy.last_updated,
4498                "persisted": persisted,
4499                "pending_outcomes": pending_outcomes,
4500            },
4501            "message": if persisted {
4502                "Routing outcome recorded and policy batch persisted to DB"
4503            } else {
4504                "Routing outcome recorded; policy persistence batch pending"
4505            },
4506        }))
4507    }
4508
4509    // ─── Claim-ledger integration ──────────────────────────────────────
4510
4511    #[cfg(feature = "claim-integration")]
4512    #[tool(
4513        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.",
4514        annotations(read_only_hint = false, destructive_hint = true)
4515    )]
4516    fn sm_compact_claim_ledger(
4517        &self,
4518        Parameters(CompactClaimLedgerParams {
4519            dry_run,
4520            max_entries,
4521            max_bytes,
4522            retain_tail_entries,
4523            max_backups,
4524        }): Parameters<CompactClaimLedgerParams>,
4525    ) -> Result<Json<StructuredOutput>, ErrorData> {
4526        let mut ledger = self.claim_ledger_store.lock().unwrap();
4527        let result = ledger
4528            .compact(ClaimLedgerCompactionConfig {
4529                dry_run: dry_run.unwrap_or(true),
4530                max_entries: max_entries.unwrap_or(10_000),
4531                max_bytes: max_bytes.unwrap_or(16 * 1024 * 1024),
4532                retain_tail_entries: retain_tail_entries.unwrap_or(256),
4533                max_backups: max_backups.unwrap_or(3),
4534            })
4535            .map_err(|error| ErrorData::internal_error(error, None))?;
4536        if result["compacted"].as_bool().unwrap_or(false) {
4537            let mut index = self.claim_trust.lock().unwrap();
4538            index.disable();
4539            if ledger.trust_enabled {
4540                index.enabled = true;
4541                if let Some(snapshot) = &ledger.snapshot {
4542                    index.load_snapshot(snapshot);
4543                }
4544                index.rebuild_from_ledger_incremental(&ledger.entries);
4545            }
4546        }
4547        json_to_output(&result)
4548    }
4549
4550    #[cfg(feature = "claim-integration")]
4551    #[tool(
4552        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.",
4553        annotations(read_only_hint = false, idempotent_hint = true)
4554    )]
4555    fn sm_create_claim(
4556        &self,
4557        Parameters(CreateClaimParams {
4558            fact_id,
4559            source_span,
4560        }): Parameters<CreateClaimParams>,
4561    ) -> Result<Json<StructuredOutput>, ErrorData> {
4562        use claim_ledger::Claim;
4563        let bare = fact_id
4564            .strip_prefix("fact:")
4565            .unwrap_or(&fact_id)
4566            .to_string();
4567        let store = &self.bridge.store;
4568
4569        // Get the fact content
4570        let fact =
4571            tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
4572        let fact = match fact {
4573            Ok(Some(f)) => f,
4574            _ => {
4575                return Err(ErrorData::internal_error(
4576                    format!("fact not found: {fact_id}"),
4577                    None,
4578                ))
4579            }
4580        };
4581
4582        // Create a claim from the fact
4583        let source_id = format!("semantic-memory:fact:{bare}");
4584        let span_id = source_span.unwrap_or_else(|| "full".to_string());
4585        let claim = Claim::new(&source_id, &span_id, &fact.content, "fact");
4586
4587        let claim_id = claim.claim_id.clone();
4588        let normalized = &claim.normalized_claim;
4589
4590        {
4591            let mut ledger = self.claim_ledger_store.lock().unwrap();
4592            let sequence = ledger.next_sequence();
4593            let previous_digest = ledger.last_digest();
4594            let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4595                .add_claim(&claim_id, &source_id, &span_id, normalized)
4596                .map_err(|e| {
4597                    ErrorData::internal_error(
4598                        format!("failed to build claim ledger entry: {e}"),
4599                        None,
4600                    )
4601                })?;
4602            ledger.append(entry).map_err(|e| {
4603                ErrorData::internal_error(format!("failed to record claim to ledger: {e}"), None)
4604            })?;
4605        }
4606
4607        {
4608            let mut idx = self.claim_trust.lock().unwrap();
4609            idx.link_fact(bare.clone(), claim_id.clone());
4610            idx.register_claim(claim_id.clone(), normalized.clone());
4611        }
4612
4613        json_to_output(&serde_json::json!({
4614            "ok": true,
4615            "receipt": mcp_receipt("sm_create_claim"),
4616            "claim_id": claim_id,
4617            "source_id": source_id,
4618            "span_id": span_id,
4619            "claim_text": fact.content,
4620            "normalized_claim": normalized,
4621            "claim_type": "fact",
4622            "message": "Claim created from semantic-memory fact with source-spanned provenance",
4623        }))
4624    }
4625
4626    #[cfg(feature = "claim-integration")]
4627    #[tool(
4628        description = "Add evidence to a claim. Creates an EvidenceBundle linking the evidence text to the claim. Returns the evidence bundle ID.",
4629        annotations(read_only_hint = false)
4630    )]
4631    fn sm_add_evidence(
4632        &self,
4633        Parameters(AddEvidenceParams {
4634            claim_id,
4635            evidence_text,
4636            source_type,
4637        }): Parameters<AddEvidenceParams>,
4638    ) -> Result<Json<StructuredOutput>, ErrorData> {
4639        use claim_ledger::{EvidenceBundle, EvidenceLink, EvidenceRelation};
4640        let mut bundle = EvidenceBundle::new(&claim_id);
4641        let link = EvidenceLink {
4642            relation: EvidenceRelation::Supports,
4643            source_id: source_type.unwrap_or_else(|| "semantic-memory".to_string()),
4644            span_id: "full".to_string(),
4645            quote: evidence_text.clone(),
4646            digest: claim_ledger::ids::sha256_text(&evidence_text),
4647            support_role: "supporting".to_string(),
4648        };
4649        bundle.evidence_links.push(link);
4650
4651        // Persist evidence bundle to JSONL file in the memory directory
4652        let evidence_path = self.bridge.memory_dir.join("evidence_bundles.jsonl");
4653        let line = serde_json::to_string(&bundle).map_err(|e| {
4654            ErrorData::internal_error(format!("failed to serialize evidence bundle: {e}"), None)
4655        })?;
4656        use std::io::Write;
4657        let mut file = std::fs::OpenOptions::new()
4658            .create(true)
4659            .append(true)
4660            .open(&evidence_path)
4661            .map_err(|e| {
4662                ErrorData::internal_error(
4663                    format!("failed to open evidence bundles file: {e}"),
4664                    None,
4665                )
4666            })?;
4667        writeln!(file, "{line}").map_err(|e| {
4668            ErrorData::internal_error(format!("failed to write evidence bundle: {e}"), None)
4669        })?;
4670        file.sync_data().map_err(|e| {
4671            ErrorData::internal_error(format!("failed to fsync evidence bundle: {e}"), None)
4672        })?;
4673
4674        json_to_output(&serde_json::json!({
4675            "ok": true,
4676            "receipt": mcp_receipt("sm_add_evidence"),
4677            "evidence_bundle_id": bundle.evidence_bundle_id,
4678            "claim_id": claim_id,
4679            "evidence_count": bundle.evidence_links.len(),
4680            "message": "Evidence added to claim and persisted",
4681        }))
4682    }
4683
4684    #[cfg(feature = "claim-integration")]
4685    #[tool(
4686        description = "Judge the support state of a claim. Creates a SupportJudgment (supported, unsupported, contested, or heuristic_only) with optional rationale.",
4687        annotations(read_only_hint = false)
4688    )]
4689    fn sm_judge_support(
4690        &self,
4691        Parameters(JudgeSupportParams {
4692            claim_id,
4693            judgment,
4694            rationale,
4695        }): Parameters<JudgeSupportParams>,
4696    ) -> Result<Json<StructuredOutput>, ErrorData> {
4697        use claim_ledger::{SupportJudgment, SupportState};
4698        let state = match judgment.to_lowercase().as_str() {
4699            "supported" => SupportState::Supported,
4700            "partially_supported" | "partial" => SupportState::PartiallySupported,
4701            "unsupported" => SupportState::Unsupported,
4702            "contradicted" | "contested" => SupportState::Contradicted,
4703            "heuristic_only" | "heuristic" => SupportState::HeuristicOnly,
4704            _ => return Err(ErrorData::invalid_params(
4705                format!("Invalid judgment '{judgment}'. Must be: supported, partially_supported, unsupported, contradicted, or heuristic_only"),
4706                None,
4707            )),
4708        };
4709        let j = SupportJudgment {
4710            support_judgment_id: claim_ledger::ids::ulid(),
4711            claim_id: claim_id.clone(),
4712            evidence_bundle_ref: claim_ledger::ids::evidence_bundle_id(&claim_id),
4713            support_state: state,
4714            method: "agent_judgment".to_string(),
4715            rationale: rationale.unwrap_or_default(),
4716            contradiction_refs: Vec::new(),
4717            proof_debt: Vec::new(),
4718            created_recorded_time: chrono::Utc::now(),
4719        };
4720
4721        {
4722            let mut ledger = self.claim_ledger_store.lock().unwrap();
4723            let sequence = ledger.next_sequence();
4724            let previous_digest = ledger.last_digest();
4725            let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4726                .add_support_judgment(
4727                    &j.support_judgment_id,
4728                    &claim_id,
4729                    &j.evidence_bundle_ref,
4730                    state,
4731                    &j.method,
4732                )
4733                .map_err(|e| {
4734                    ErrorData::internal_error(
4735                        format!("failed to build support judgment ledger entry: {e}"),
4736                        None,
4737                    )
4738                })?;
4739            ledger.append(entry).map_err(|e| {
4740                ErrorData::internal_error(
4741                    format!("failed to record support judgment to ledger: {e}"),
4742                    None,
4743                )
4744            })?;
4745        }
4746
4747        self.claim_trust
4748            .lock()
4749            .unwrap()
4750            .record_judgment(claim_id.clone(), state);
4751
4752        json_to_output(&serde_json::json!({
4753            "ok": true,
4754            "receipt": mcp_receipt("sm_judge_support"),
4755            "support_judgment_id": j.support_judgment_id,
4756            "claim_id": claim_id,
4757            "state": judgment.to_lowercase(),
4758            "message": "Support judgment recorded",
4759        }))
4760    }
4761
4762    // ─── Bitemporal search ─────────────────────────────────────────────
4763
4764    #[tool(
4765        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.",
4766        annotations(read_only_hint = true)
4767    )]
4768    fn sm_search_as_of(
4769        &self,
4770        Parameters(SearchAsOfParams {
4771            query,
4772            as_of_date,
4773            top_k,
4774            namespace,
4775        }): Parameters<SearchAsOfParams>,
4776    ) -> Result<Json<StructuredOutput>, ErrorData> {
4777        let store = &self.bridge.store;
4778        let k = top_k.unwrap_or(5);
4779        let ns_slice: Option<Vec<&str>> = namespace.as_ref().map(|n| vec![n.as_str()]);
4780
4781        // Parse the as-of date
4782        let _as_of = chrono::DateTime::parse_from_rfc3339(&as_of_date)
4783            .map_err(|e| ErrorData::invalid_params(
4784                format!("Invalid as_of_date '{as_of_date}': {e}. Use ISO 8601 format like 2026-01-15T00:00:00Z"),
4785                None,
4786            ))?
4787            .with_timezone(&chrono::Utc);
4788
4789        // Search normally, then filter by date
4790        let results = tokio::task::block_in_place(|| {
4791            Handle::current().block_on(store.search_with_view(
4792                &query,
4793                Some(k * 2),
4794                ns_slice.as_deref(),
4795                None,
4796                semantic_memory::StateView::HistoricalAt(as_of_date.clone()),
4797            ))
4798        })
4799        .map_err(|e| ErrorData::internal_error(format!("search error: {e}"), None))?;
4800
4801        let filtered: Vec<_> = results.into_iter().take(k).collect();
4802
4803        let result_json: Vec<serde_json::Value> = filtered
4804            .iter()
4805            .map(|r| {
4806                serde_json::json!({
4807                    "result_id": r.source.result_id(),
4808                    "content": r.content,
4809                    "score": r.score,
4810                })
4811            })
4812            .collect();
4813
4814        json_to_output(&serde_json::json!({
4815            "ok": true,
4816            "query": query,
4817            "as_of_date": as_of_date,
4818            "results": result_json,
4819            "count": filtered.len(),
4820            "message": format!("Found {} facts valid as of {}", filtered.len(), as_of_date),
4821        }))
4822    }
4823
4824    // ─── Verification gate ─────────────────────────────────────────────
4825
4826    #[tool(
4827        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.",
4828        annotations(read_only_hint = true)
4829    )]
4830    fn sm_verify_claim(
4831        &self,
4832        Parameters(VerifyClaimParams {
4833            claim,
4834            risk_class,
4835            evidence_refs,
4836            refutation_attempted,
4837        }): Parameters<VerifyClaimParams>,
4838    ) -> Result<Json<StructuredOutput>, ErrorData> {
4839        let risk = risk_class.to_lowercase();
4840        let has_evidence = evidence_refs
4841            .as_ref()
4842            .map(|v| !v.is_empty())
4843            .unwrap_or(false);
4844        let refuted = refutation_attempted.unwrap_or(false);
4845
4846        // Required checks by risk class
4847        let (needs_replay, needs_falsification, disposition, rationale) = match risk.as_str() {
4848            "low" => (
4849                false,
4850                false,
4851                "promote",
4852                "Low risk: cheap checks only, claim can be promoted",
4853            ),
4854            "medium" => (
4855                true,
4856                false,
4857                "promote",
4858                "Medium risk: replay check required, claim can be promoted",
4859            ),
4860            "high" => (
4861                true,
4862                true,
4863                if refuted {
4864                    "quarantine"
4865                } else if has_evidence {
4866                    "promote"
4867                } else {
4868                    "defer"
4869                },
4870                if refuted {
4871                    "High risk: refutation attempted, claim quarantined"
4872                } else if has_evidence {
4873                    "High risk: falsification passed with evidence, claim promoted"
4874                } else {
4875                    "High risk: no evidence provided, claim deferred"
4876                },
4877            ),
4878            "critical" => (
4879                true,
4880                true,
4881                if refuted {
4882                    "quarantine"
4883                } else if has_evidence && refutation_attempted == Some(true) {
4884                    "promote"
4885                } else {
4886                    "defer"
4887                },
4888                if refuted {
4889                    "Critical risk: refutation found, claim quarantined"
4890                } else if has_evidence && refutation_attempted == Some(true) {
4891                    "Critical risk: replay + falsification passed, claim promoted"
4892                } else {
4893                    "Critical risk: requires evidence AND refutation, claim deferred"
4894                },
4895            ),
4896            _ => {
4897                return Err(ErrorData::invalid_params(
4898                    format!("Invalid risk_class '{risk}'. Must be: low, medium, high, or critical"),
4899                    None,
4900                ))
4901            }
4902        };
4903
4904        json_to_output(&serde_json::json!({
4905            "ok": true,
4906            "claim": claim,
4907            "risk_class": risk,
4908            "required_checks": {
4909                "cheap_checks": true,
4910                "replay_checks": needs_replay,
4911                "falsification_checks": needs_falsification,
4912            },
4913            "has_evidence": has_evidence,
4914            "refutation_attempted": refuted,
4915            "disposition": disposition,
4916            "rationale": rationale,
4917            "can_promote": disposition == "promote",
4918        }))
4919    }
4920
4921    // ─── Search receipt tools (GAP #6-7) ────────────────────────────
4922
4923    #[tool(
4924        description = "Load a durable search receipt by receipt/request ID. Returns the stored receipt with evaluation time, retrieval family, result IDs, and digests.",
4925        annotations(read_only_hint = true)
4926    )]
4927    fn sm_get_search_receipt(
4928        &self,
4929        Parameters(GetSearchReceiptParams { receipt_id }): Parameters<GetSearchReceiptParams>,
4930    ) -> Result<Json<StructuredOutput>, ErrorData> {
4931        let store = &self.bridge.store;
4932        let result = tokio::task::block_in_place(|| {
4933            Handle::current().block_on(store.get_search_receipt(&receipt_id))
4934        });
4935        match result {
4936            Ok(Some(receipt)) => json_to_output(&serde_json::json!({
4937                "ok": true,
4938                "receipt": {
4939                    "receipt_id": receipt.receipt_id,
4940                    "trace_id": receipt.trace_id,
4941                    "search_profile": receipt.search_profile,
4942                    "evaluation_time": receipt.evaluation_time,
4943                    "result_ids": receipt.result_ids,
4944                    "query_embedding_digest": receipt.query_embedding_digest,
4945                    "query_text_digest": receipt.query_text_digest,
4946                    "query_input_digest": receipt.query_input_digest,
4947                    "filter_digest": receipt.filter_digest,
4948                    "redaction_state": receipt.redaction_state,
4949                    "approximate": receipt.approximate,
4950                    "attempt_family_id": receipt.attempt_family_id,
4951                    "budget_id": receipt.budget_id,
4952                },
4953            })),
4954            Ok(None) => json_to_output(&serde_json::json!({
4955                "ok": true,
4956                "found": false,
4957                "receipt_id": receipt_id,
4958                "message": "No receipt found with that ID",
4959            })),
4960            Err(e) => Err(ErrorData::internal_error(
4961                format!("get_search_receipt error: {e}"),
4962                None,
4963            )),
4964        }
4965    }
4966
4967    #[tool(
4968        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.",
4969        annotations(read_only_hint = true)
4970    )]
4971    fn sm_replay_search_receipt(
4972        &self,
4973        Parameters(ReplaySearchReceiptParams {
4974            receipt_id,
4975            query,
4976            top_k,
4977            namespaces,
4978        }): Parameters<ReplaySearchReceiptParams>,
4979    ) -> Result<Json<StructuredOutput>, ErrorData> {
4980        let store = &self.bridge.store;
4981        let k = top_k.map(|v| v as usize);
4982        let ns_slice: Option<Vec<&str>> = namespaces
4983            .as_ref()
4984            .map(|v| v.iter().map(|s| s.as_str()).collect());
4985
4986        let result = tokio::task::block_in_place(|| {
4987            Handle::current().block_on(store.replay_search_receipt(
4988                &receipt_id,
4989                &query,
4990                k,
4991                ns_slice.as_deref(),
4992                None,
4993            ))
4994        });
4995        match result {
4996            Ok(report) => json_to_output(&serde_json::json!({
4997                "ok": true,
4998                "receipt_id": report.receipt_id,
4999                "replay_receipt_id": report.replay_receipt_id,
5000                "query_embedding_digest_matches": report.query_embedding_digest_matches,
5001                "result_ids_match": report.result_ids_match,
5002                "missing_result_ids": report.missing_result_ids,
5003                "added_result_ids": report.added_result_ids,
5004                "original_receipt": {
5005                    "receipt_id": report.original_receipt.receipt_id,
5006                    "result_ids": report.original_receipt.result_ids,
5007                    "search_profile": report.original_receipt.search_profile,
5008                    "evaluation_time": report.original_receipt.evaluation_time,
5009                },
5010                "replay_receipt": {
5011                    "receipt_id": report.replay_receipt.receipt_id,
5012                    "result_ids": report.replay_receipt.result_ids,
5013                    "search_profile": report.replay_receipt.search_profile,
5014                    "evaluation_time": report.replay_receipt.evaluation_time,
5015                },
5016            })),
5017            Err(e) => Err(ErrorData::internal_error(
5018                format!("replay_search_receipt error: {e}"),
5019                None,
5020            )),
5021        }
5022    }
5023
5024    #[tool(
5025        description = "Replay a durable search receipt using query text and filters retained by explicit opt-in. Returns the replay comparison report.",
5026        annotations(read_only_hint = true)
5027    )]
5028    fn sm_replay_search(
5029        &self,
5030        Parameters(ReplayStoredSearchParams { receipt_id }): Parameters<ReplayStoredSearchParams>,
5031    ) -> Result<Json<StructuredOutput>, ErrorData> {
5032        let result = tokio::task::block_in_place(|| {
5033            Handle::current().block_on(
5034                self.bridge
5035                    .store
5036                    .replay_search_from_stored_inputs(&receipt_id),
5037            )
5038        });
5039        match result {
5040            Ok(report) => json_to_output(&serde_json::json!({
5041                "ok": true,
5042                "receipt_id": report.receipt_id,
5043                "replay_receipt_id": report.replay_receipt_id,
5044                "query_embedding_digest_matches": report.query_embedding_digest_matches,
5045                "result_ids_match": report.result_ids_match,
5046                "missing_result_ids": report.missing_result_ids,
5047                "added_result_ids": report.added_result_ids,
5048                "original_result_ids": report.original_receipt.result_ids,
5049                "replay_result_ids": report.replay_receipt.result_ids,
5050            })),
5051            Err(e) => Err(ErrorData::internal_error(
5052                format!("replay_search error: {e}"),
5053                None,
5054            )),
5055        }
5056    }
5057
5058    // ─── Reconcile tool (GAP #8) ────────────────────────────────────
5059
5060    #[tool(
5061        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.",
5062        annotations(idempotent_hint = true)
5063    )]
5064    fn sm_reconcile(
5065        &self,
5066        Parameters(ReconcileParams { action }): Parameters<ReconcileParams>,
5067    ) -> Result<Json<StructuredOutput>, ErrorData> {
5068        let action_enum = match action.to_lowercase().as_str() {
5069            "report_only" | "report-only" => semantic_memory::ReconcileAction::ReportOnly,
5070            "rebuild_fts" | "rebuild-fts" => semantic_memory::ReconcileAction::RebuildFts,
5071            "re_embed" | "re-embed" | "reembed" => semantic_memory::ReconcileAction::ReEmbed,
5072            _ => {
5073                return Err(ErrorData::invalid_params(
5074                    format!("action must be 'report_only', 'rebuild_fts', or 're_embed', got '{action}'"),
5075                    None,
5076                ));
5077            }
5078        };
5079        let store = &self.bridge.store;
5080        let result = tokio::task::block_in_place(|| {
5081            Handle::current().block_on(store.reconcile(action_enum))
5082        });
5083        match result {
5084            Ok(report) => json_to_output(&serde_json::json!({
5085                "ok": report.ok,
5086                "schema_version": report.schema_version,
5087                "fact_count": report.fact_count,
5088                "chunk_count": report.chunk_count,
5089                "message_count": report.message_count,
5090                "facts_missing_embeddings": report.facts_missing_embeddings,
5091                "chunks_missing_embeddings": report.chunks_missing_embeddings,
5092                "issues": report.issues,
5093                "issue_count": report.issues.len(),
5094                "action": action,
5095            })),
5096            Err(e) => Err(ErrorData::internal_error(
5097                format!("reconcile error: {e}"),
5098                None,
5099            )),
5100        }
5101    }
5102
5103    // ─── Maintenance tools (GAP #9) ─────────────────────────────────
5104
5105    #[tool(
5106        description = "Vacuum the database to reclaim space after deletions. This is a maintenance operation that may take a moment.",
5107        annotations(idempotent_hint = true)
5108    )]
5109    fn sm_vacuum(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5110        let store = &self.bridge.store;
5111        let result = tokio::task::block_in_place(|| Handle::current().block_on(store.vacuum()));
5112        match result {
5113            Ok(()) => json_to_output(&serde_json::json!({
5114                "ok": true,
5115                "receipt": mcp_receipt("sm_vacuum"),
5116                "message": "Database vacuumed successfully",
5117            })),
5118            Err(e) => Err(ErrorData::internal_error(
5119                format!("vacuum error: {e}"),
5120                None,
5121            )),
5122        }
5123    }
5124
5125    #[tool(
5126        description = "Re-embed all facts, chunks, messages, and episodes. Call after changing embedding models. Returns the count of items re-embedded.",
5127        annotations(idempotent_hint = true)
5128    )]
5129    fn sm_reembed_all(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5130        let store = &self.bridge.store;
5131        let result =
5132            tokio::task::block_in_place(|| Handle::current().block_on(store.reembed_all()));
5133        match result {
5134            Ok(count) => json_to_output(&serde_json::json!({
5135                "ok": true,
5136                "receipt": mcp_receipt("sm_reembed_all"),
5137                "reembedded_count": count,
5138                "message": format!("Re-embedded {count} items"),
5139            })),
5140            Err(e) => Err(ErrorData::internal_error(
5141                format!("reembed_all error: {e}"),
5142                None,
5143            )),
5144        }
5145    }
5146
5147    #[tool(
5148        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.",
5149        annotations(read_only_hint = true)
5150    )]
5151    fn sm_embeddings_are_dirty(
5152        &self,
5153        Parameters(_params): Parameters<EmbeddingsAreDirtyParams>,
5154    ) -> Result<Json<StructuredOutput>, ErrorData> {
5155        let store = &self.bridge.store;
5156        let result = tokio::task::block_in_place(|| {
5157            Handle::current().block_on(store.embeddings_are_dirty())
5158        });
5159        match result {
5160            Ok(dirty) => json_to_output(&serde_json::json!({
5161                "ok": true,
5162                "dirty": dirty,
5163                "message": if dirty { "Embeddings are dirty and need re-generation. Call sm_reembed_all." } else { "Embeddings are up to date" },
5164            })),
5165            Err(e) => Err(ErrorData::internal_error(
5166                format!("embeddings_are_dirty error: {e}"),
5167                None,
5168            )),
5169        }
5170    }
5171
5172    // ─── Projection query tools (GAP #10) ───────────────────────────
5173
5174    #[tool(
5175        description = "Query imported claim projection rows. Filters by scope, text, valid-time, and claim state. Returns claim version rows with full provenance.",
5176        annotations(read_only_hint = true)
5177    )]
5178    fn sm_query_claim_versions(
5179        &self,
5180        Parameters(params): Parameters<ProjectionQueryParams>,
5181    ) -> Result<Json<StructuredOutput>, ErrorData> {
5182        let store = &self.bridge.store;
5183        let query = build_projection_query(params);
5184        let result = tokio::task::block_in_place(|| {
5185            Handle::current().block_on(store.query_claim_versions(query))
5186        });
5187        match result {
5188            Ok(rows) => json_to_output(&serde_json::json!({
5189                "ok": true,
5190                "results": serde_json::to_value(&rows).unwrap_or_else(|_| serde_json::json!([])),
5191                "count": rows.len(),
5192            })),
5193            Err(e) => Err(ErrorData::internal_error(
5194                format!("query_claim_versions error: {e}"),
5195                None,
5196            )),
5197        }
5198    }
5199
5200    #[tool(
5201        description = "Query imported relation projection rows. Filters by scope, text, valid-time, and subject entity. Returns relation version rows with full provenance.",
5202        annotations(read_only_hint = true)
5203    )]
5204    fn sm_query_relation_versions(
5205        &self,
5206        Parameters(params): Parameters<ProjectionQueryParams>,
5207    ) -> Result<Json<StructuredOutput>, ErrorData> {
5208        let store = &self.bridge.store;
5209        let query = build_projection_query(params);
5210        let result = tokio::task::block_in_place(|| {
5211            Handle::current().block_on(store.query_relation_versions(query))
5212        });
5213        match result {
5214            Ok(rows) => json_to_output(&serde_json::json!({
5215                "ok": true,
5216                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5217                "count": rows.len(),
5218            })),
5219            Err(e) => Err(ErrorData::internal_error(
5220                format!("query_relation_versions error: {e}"),
5221                None,
5222            )),
5223        }
5224    }
5225
5226    #[tool(
5227        description = "Query imported episode projection rows. Filters by scope and text. Returns episode rows with cause/effect and outcome data.",
5228        annotations(read_only_hint = true)
5229    )]
5230    fn sm_query_episodes(
5231        &self,
5232        Parameters(params): Parameters<ProjectionQueryParams>,
5233    ) -> Result<Json<StructuredOutput>, ErrorData> {
5234        let store = &self.bridge.store;
5235        let query = build_projection_query(params);
5236        let result =
5237            tokio::task::block_in_place(|| Handle::current().block_on(store.query_episodes(query)));
5238        match result {
5239            Ok(rows) => json_to_output(&serde_json::json!({
5240                "ok": true,
5241                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5242                "count": rows.len(),
5243            })),
5244            Err(e) => Err(ErrorData::internal_error(
5245                format!("query_episodes error: {e}"),
5246                None,
5247            )),
5248        }
5249    }
5250
5251    #[tool(
5252        description = "Query imported entity-alias rows. Filters by scope, canonical entity, and text. Returns alias rows with merge and review state.",
5253        annotations(read_only_hint = true)
5254    )]
5255    fn sm_query_entity_aliases(
5256        &self,
5257        Parameters(params): Parameters<ProjectionQueryParams>,
5258    ) -> Result<Json<StructuredOutput>, ErrorData> {
5259        let store = &self.bridge.store;
5260        let query = build_projection_query(params);
5261        let result = tokio::task::block_in_place(|| {
5262            Handle::current().block_on(store.query_entity_aliases(query))
5263        });
5264        match result {
5265            Ok(rows) => json_to_output(&serde_json::json!({
5266                "ok": true,
5267                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5268                "count": rows.len(),
5269            })),
5270            Err(e) => Err(ErrorData::internal_error(
5271                format!("query_entity_aliases error: {e}"),
5272                None,
5273            )),
5274        }
5275    }
5276
5277    #[tool(
5278        description = "Query imported evidence-reference rows. Filters by scope, claim, and claim version. Returns evidence reference rows with fetch handles and source authority.",
5279        annotations(read_only_hint = true)
5280    )]
5281    fn sm_query_evidence_refs(
5282        &self,
5283        Parameters(params): Parameters<ProjectionQueryParams>,
5284    ) -> Result<Json<StructuredOutput>, ErrorData> {
5285        let store = &self.bridge.store;
5286        let query = build_projection_query(params);
5287        let result = tokio::task::block_in_place(|| {
5288            Handle::current().block_on(store.query_evidence_refs(query))
5289        });
5290        match result {
5291            Ok(rows) => json_to_output(&serde_json::json!({
5292                "ok": true,
5293                "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5294                "count": rows.len(),
5295            })),
5296            Err(e) => Err(ErrorData::internal_error(
5297                format!("query_evidence_refs error: {e}"),
5298                None,
5299            )),
5300        }
5301    }
5302
5303    // ─── Import tools (GAP #11) ─────────────────────────────────────
5304
5305    #[tool(
5306        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.",
5307        annotations(idempotent_hint = true)
5308    )]
5309    #[allow(deprecated)]
5310    fn sm_import_envelope(
5311        &self,
5312        Parameters(ImportEnvelopeParams { envelope_json }): Parameters<ImportEnvelopeParams>,
5313    ) -> Result<Json<StructuredOutput>, ErrorData> {
5314        let envelope: semantic_memory::projection_import::ImportEnvelope =
5315            serde_json::from_str(&envelope_json).map_err(|e| {
5316                ErrorData::invalid_params(format!("Failed to parse envelope JSON: {e}"), None)
5317            })?;
5318        envelope.validate().map_err(|e| {
5319            ErrorData::invalid_params(format!("Envelope validation failed: {e}"), None)
5320        })?;
5321        let store = &self.bridge.store;
5322        let result = tokio::task::block_in_place(|| {
5323            Handle::current().block_on(store.import_envelope(&envelope))
5324        });
5325        match result {
5326            Ok(receipt) => json_to_output(&serde_json::json!({
5327                "ok": true,
5328                "envelope_id": receipt.envelope_id,
5329                "was_duplicate": receipt.was_duplicate,
5330                "imported_count": receipt.record_count,
5331                "receipt_id": receipt.envelope_id,
5332            })),
5333            Err(e) => Err(ErrorData::internal_error(
5334                format!("import_envelope error: {e}"),
5335                None,
5336            )),
5337        }
5338    }
5339
5340    #[tool(
5341        description = "Check whether an envelope has already been imported. Returns import receipts for the given envelope ID.",
5342        annotations(read_only_hint = true)
5343    )]
5344    #[allow(deprecated)]
5345    fn sm_import_status(
5346        &self,
5347        Parameters(ImportStatusParams { envelope_id }): Parameters<ImportStatusParams>,
5348    ) -> Result<Json<StructuredOutput>, ErrorData> {
5349        use semantic_memory::projection_import::EnvelopeId;
5350        let store = &self.bridge.store;
5351        let env_id = EnvelopeId::new(&envelope_id);
5352        let result = tokio::task::block_in_place(|| {
5353            Handle::current().block_on(store.import_status(&env_id))
5354        });
5355        match result {
5356            Ok(receipts) => json_to_output(&serde_json::json!({
5357                "ok": true,
5358                "envelope_id": envelope_id,
5359                "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5360                "count": receipts.len(),
5361            })),
5362            Err(e) => Err(ErrorData::internal_error(
5363                format!("import_status error: {e}"),
5364                None,
5365            )),
5366        }
5367    }
5368
5369    #[tool(
5370        description = "List recent imports, optionally filtered by namespace. Returns import receipt records.",
5371        annotations(read_only_hint = true)
5372    )]
5373    #[allow(deprecated)]
5374    fn sm_list_imports(
5375        &self,
5376        Parameters(ListImportsParams { namespace, limit }): Parameters<ListImportsParams>,
5377    ) -> Result<Json<StructuredOutput>, ErrorData> {
5378        let store = &self.bridge.store;
5379        let lim = limit.unwrap_or(20) as usize;
5380        let result = tokio::task::block_in_place(|| {
5381            Handle::current().block_on(store.list_imports(namespace.as_deref(), lim))
5382        });
5383        match result {
5384            Ok(receipts) => json_to_output(&serde_json::json!({
5385                "ok": true,
5386                "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5387                "count": receipts.len(),
5388            })),
5389            Err(e) => Err(ErrorData::internal_error(
5390                format!("list_imports error: {e}"),
5391                None,
5392            )),
5393        }
5394    }
5395    // ── LLM output parser tools ─────────────────────────────────────────
5396
5397    #[cfg(feature = "llm-parser")]
5398    #[tool(
5399        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.",
5400        annotations(read_only_hint = true)
5401    )]
5402    fn sm_parse_json(
5403        &self,
5404        Parameters(ParseJsonParams { raw_output }): Parameters<ParseJsonParams>,
5405    ) -> Result<Json<StructuredOutput>, ErrorData> {
5406        match llm_output_parser::parse_json::<serde_json::Value>(&raw_output) {
5407            Ok(value) => Ok(structured_output(value)),
5408            Err(e) => Ok(json_to_output(&serde_json::json!({
5409                "ok": false,
5410                "error": e.to_string(),
5411                "input_preview": &raw_output.chars().take(200).collect::<String>(),
5412            }))?),
5413        }
5414    }
5415
5416    #[cfg(feature = "llm-parser")]
5417    #[tool(
5418        description = "Parse JSON from raw LLM output as an untyped serde_json::Value. Useful when the expected schema is unknown.",
5419        annotations(read_only_hint = true)
5420    )]
5421    fn sm_parse_json_value(
5422        &self,
5423        Parameters(ParseJsonValueParams { raw_output }): Parameters<ParseJsonValueParams>,
5424    ) -> Result<Json<StructuredOutput>, ErrorData> {
5425        match llm_output_parser::parse_json_value(&raw_output) {
5426            Ok(value) => Ok(structured_output(value)),
5427            Err(e) => Ok(json_to_output(&serde_json::json!({
5428                "ok": false,
5429                "error": e.to_string(),
5430            }))?),
5431        }
5432    }
5433
5434    #[cfg(feature = "llm-parser")]
5435    #[tool(
5436        description = "Strip </think> blocks from text. Removes chain-of-thought reasoning that some models emit. Returns cleaned text.",
5437        annotations(read_only_hint = true)
5438    )]
5439    fn sm_strip_think_tags(
5440        &self,
5441        Parameters(StripThinkTagsParams { text }): Parameters<StripThinkTagsParams>,
5442    ) -> Result<Json<StructuredOutput>, ErrorData> {
5443        Ok(structured_output(serde_json::json!({
5444            "ok": true,
5445            "text": llm_output_parser::strip_think_tags(&text),
5446        })))
5447    }
5448
5449    #[cfg(feature = "llm-parser")]
5450    #[tool(
5451        description = "Attempt to repair common LLM JSON errors: trailing commas, unquoted keys, single quotes, missing brackets. Returns the repaired JSON string or an error.",
5452        annotations(read_only_hint = true)
5453    )]
5454    fn sm_repair_json(
5455        &self,
5456        Parameters(RepairJsonParams { json_string }): Parameters<RepairJsonParams>,
5457    ) -> Result<Json<StructuredOutput>, ErrorData> {
5458        match llm_output_parser::try_repair_json(&json_string) {
5459            Some(repaired) => Ok(structured_output(serde_json::json!({
5460                "ok": true,
5461                "repaired_json": repaired,
5462            }))),
5463            None => Ok(json_to_output(&serde_json::json!({
5464                "ok": false,
5465                "error": "Could not repair JSON. The input may not be valid JSON even after common fixes.",
5466            }))?),
5467        }
5468    }
5469
5470    #[cfg(feature = "llm-parser")]
5471    #[tool(
5472        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.",
5473        annotations(read_only_hint = true)
5474    )]
5475    fn sm_parse_string_list(
5476        &self,
5477        Parameters(ParseStringListParams { raw_output }): Parameters<ParseStringListParams>,
5478    ) -> Result<Json<StructuredOutput>, ErrorData> {
5479        match llm_output_parser::parse_string_list(&raw_output) {
5480            Ok(list) => Ok(structured_output(serde_json::json!({
5481                "ok": true,
5482                "values": list,
5483            }))),
5484            Err(e) => Ok(json_to_output(&serde_json::json!({
5485                "ok": false,
5486                "error": e.to_string(),
5487            }))?),
5488        }
5489    }
5490
5491    #[cfg(feature = "llm-parser")]
5492    #[tool(
5493        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.",
5494        annotations(read_only_hint = true)
5495    )]
5496    fn sm_parse_choice(
5497        &self,
5498        Parameters(ParseChoiceParams {
5499            raw_output,
5500            options,
5501        }): Parameters<ParseChoiceParams>,
5502    ) -> Result<Json<StructuredOutput>, ErrorData> {
5503        let opt_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
5504        match llm_output_parser::parse_choice(&raw_output, &opt_refs) {
5505            Ok(choice) => Ok(structured_output(serde_json::json!({
5506                "ok": true,
5507                "choice": choice,
5508            }))),
5509            Err(e) => Ok(json_to_output(&serde_json::json!({
5510                "ok": false,
5511                "error": e.to_string(),
5512                "options": options,
5513            }))?),
5514        }
5515    }
5516
5517    #[cfg(feature = "llm-parser")]
5518    #[tool(
5519        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.",
5520        annotations(read_only_hint = true)
5521    )]
5522    fn sm_parse_number(
5523        &self,
5524        Parameters(ParseNumberParams { raw_output }): Parameters<ParseNumberParams>,
5525    ) -> Result<Json<StructuredOutput>, ErrorData> {
5526        match llm_output_parser::parse_number::<f64>(&raw_output) {
5527            Ok(n) if n.is_finite() => Ok(structured_output(serde_json::json!({
5528                "ok": true,
5529                "number": n,
5530            }))),
5531            Ok(_) => Ok(structured_output(serde_json::json!({
5532                "ok": false,
5533                "error": "Parsed number must be finite",
5534            }))),
5535            Err(e) => Ok(json_to_output(&serde_json::json!({
5536                "ok": false,
5537                "error": e.to_string(),
5538            }))?),
5539        }
5540    }
5541}
5542
5543#[derive(Debug, Clone, PartialEq, Eq)]
5544pub enum GraphPathOutcome {
5545    Found(Vec<String>),
5546    NoPathWithinCompleteSearch,
5547    BudgetExceeded,
5548    InvalidEndpoint(String),
5549}
5550
5551/// Adapter-owned BFS whose terminal states are explicit. The underlying graph
5552/// API returns `None` for both exhausted and bounded searches, so callers must
5553/// not consume it directly when correctness depends on that distinction.
5554fn typed_graph_path(
5555    graph: &dyn semantic_memory::GraphView,
5556    from: &str,
5557    to: &str,
5558    max_depth: usize,
5559) -> Result<GraphPathOutcome, semantic_memory::MemoryError> {
5560    use semantic_memory::GraphDirection;
5561    let from_edges = graph.neighbors(from, GraphDirection::Both, 1)?;
5562    if from_edges.is_empty() {
5563        return Ok(GraphPathOutcome::InvalidEndpoint(from.to_string()));
5564    }
5565    let to_edges = graph.neighbors(to, GraphDirection::Both, 1)?;
5566    if to_edges.is_empty() {
5567        return Ok(GraphPathOutcome::InvalidEndpoint(to.to_string()));
5568    }
5569    if from == to {
5570        return Ok(GraphPathOutcome::Found(vec![from.to_string()]));
5571    }
5572
5573    let mut visited = HashSet::from([from.to_string()]);
5574    let mut parents = HashMap::<String, String>::new();
5575    let mut queue = VecDeque::from([(from.to_string(), 0usize)]);
5576    let mut hit_depth_budget = false;
5577    while let Some((node, depth)) = queue.pop_front() {
5578        let edges = graph.neighbors(&node, GraphDirection::Both, 1)?;
5579        for edge in edges {
5580            let next = if edge.source == node {
5581                edge.target
5582            } else {
5583                edge.source
5584            };
5585            if visited.contains(&next) {
5586                continue;
5587            }
5588            if depth >= max_depth {
5589                hit_depth_budget = true;
5590                continue;
5591            }
5592            visited.insert(next.clone());
5593            parents.insert(next.clone(), node.clone());
5594            if next == to {
5595                let mut path = vec![to.to_string()];
5596                let mut cursor = to.to_string();
5597                while let Some(parent) = parents.get(&cursor) {
5598                    path.push(parent.clone());
5599                    if parent == from {
5600                        break;
5601                    }
5602                    cursor = parent.clone();
5603                }
5604                path.reverse();
5605                return Ok(GraphPathOutcome::Found(path));
5606            }
5607            if visited.len() >= 500 {
5608                return Ok(GraphPathOutcome::BudgetExceeded);
5609            }
5610            queue.push_back((next, depth + 1));
5611        }
5612    }
5613    Ok(if hit_depth_budget {
5614        GraphPathOutcome::BudgetExceeded
5615    } else {
5616        GraphPathOutcome::NoPathWithinCompleteSearch
5617    })
5618}
5619
5620#[cfg(test)]
5621#[allow(clippy::items_after_test_module)]
5622mod correctness_contract_tests {
5623    use super::*;
5624    use crate::bridge::{BridgeConfig, EmbedderBackend};
5625
5626    fn structured_value(body: &Json<StructuredOutput>) -> serde_json::Value {
5627        serde_json::to_value(&body.0).expect("structured tool output JSON")
5628    }
5629
5630    #[test]
5631    fn structured_output_preserves_object_shape_and_populates_protocol_field() {
5632        use rmcp::handler::server::tool::IntoCallToolResult;
5633
5634        let output = structured_output(serde_json::json!({
5635            "ok": true,
5636            "fact_id": "fact-1",
5637        }));
5638        let result = output.into_call_tool_result().expect("tool result");
5639
5640        assert_eq!(
5641            result.structured_content,
5642            Some(serde_json::json!({"ok": true, "fact_id": "fact-1"}))
5643        );
5644        assert_eq!(
5645            result.content.len(),
5646            1,
5647            "rmcp emits compatibility text content"
5648        );
5649        assert_eq!(result.is_error, Some(false));
5650    }
5651
5652    #[test]
5653    fn structured_output_wraps_non_object_values() {
5654        assert_eq!(
5655            structured_value(&structured_output(serde_json::json!(["a", "b"]))),
5656            serde_json::json!({"value": ["a", "b"]})
5657        );
5658    }
5659
5660    #[cfg(feature = "llm-parser")]
5661    #[test]
5662    fn parse_number_rejects_non_finite_values_without_panicking() {
5663        let dir = tempfile::tempdir().unwrap();
5664        let server = SemanticMemoryServer::new(
5665            MemoryBridge::open(BridgeConfig {
5666                memory_dir: dir.path().to_path_buf(),
5667                embedder_backend: EmbedderBackend::Mock,
5668                embedding_url: String::new(),
5669                embedding_model: "mock".into(),
5670                embedding_dims: 768,
5671                turbo_quant_enabled: false,
5672                turbo_quant_bits: None,
5673                turbo_quant_projections: None,
5674            })
5675            .unwrap(),
5676            "full",
5677        );
5678
5679        let output = server
5680            .sm_parse_number(Parameters(ParseNumberParams {
5681                raw_output: "NaN".into(),
5682            }))
5683            .expect("non-finite parse must return a structured result");
5684        let value = structured_value(&output);
5685
5686        assert_eq!(value["ok"], false);
5687        assert!(value["error"]
5688            .as_str()
5689            .is_some_and(|error| error.contains("finite")));
5690    }
5691
5692    #[test]
5693    fn every_registered_tool_declares_object_output_schema() {
5694        let tools = SemanticMemoryServer::tool_router().list_all();
5695        assert!(!tools.is_empty(), "tool router must register tools");
5696
5697        let missing: Vec<_> = tools
5698            .iter()
5699            .filter_map(|tool| {
5700                let schema = tool.output_schema.as_deref()?;
5701                (schema.get("type").and_then(serde_json::Value::as_str) != Some("object"))
5702                    .then(|| tool.name.to_string())
5703            })
5704            .chain(
5705                tools
5706                    .iter()
5707                    .filter(|tool| tool.output_schema.is_none())
5708                    .map(|tool| tool.name.to_string()),
5709            )
5710            .collect();
5711
5712        assert!(
5713            missing.is_empty(),
5714            "every tool must expose a root object outputSchema; missing/invalid: {missing:?}"
5715        );
5716    }
5717    use semantic_memory::{GraphDirection, GraphEdge, GraphEdgeType, GraphView, MemoryError};
5718
5719    struct TestGraph(Vec<(&'static str, &'static str)>);
5720
5721    fn add_fact_params(content: &str, idempotency_key: Option<&str>) -> AddFactParams {
5722        AddFactParams {
5723            content: content.into(),
5724            namespace: "authority-test".into(),
5725            source: Some("tests/authority-source.md".into()),
5726            extract_entities: Some(false),
5727            memory_kind: Some("durable_fact".into()),
5728            sensitivity: Some("internal".into()),
5729            evidence_refs: Some(vec!["evidence:authority-test".into()]),
5730            idempotency_key: idempotency_key.map(str::to_string),
5731        }
5732    }
5733
5734    fn invoke_add_fact(
5735        runtime: &tokio::runtime::Runtime,
5736        server: &SemanticMemoryServer,
5737        params: AddFactParams,
5738    ) -> Result<Json<StructuredOutput>, ErrorData> {
5739        runtime.block_on(async {
5740            tokio::task::block_in_place(|| server.sm_add_fact(Parameters(params)))
5741        })
5742    }
5743
5744    fn invoke_record_outcome(
5745        runtime: &tokio::runtime::Runtime,
5746        server: &SemanticMemoryServer,
5747        query: &str,
5748        outcome: &str,
5749    ) -> serde_json::Value {
5750        let body = runtime
5751            .block_on(async {
5752                tokio::task::block_in_place(|| {
5753                    server.sm_record_outcome(Parameters(RecordOutcomeParams {
5754                        query: query.to_string(),
5755                        outcome: outcome.to_string(),
5756                    }))
5757                })
5758            })
5759            .expect("record routing outcome");
5760        structured_value(&body)
5761    }
5762
5763    #[test]
5764    fn routing_feedback_batch_persists_and_survives_restart() {
5765        use semantic_memory::rl_routing::{is_trained, route_with_policy};
5766        use semantic_memory::routing::{QueryProfile, RetrievalRouter};
5767
5768        let dir = tempfile::tempdir().unwrap();
5769        let memory_dir = dir.path().to_path_buf();
5770        let make_config = || BridgeConfig {
5771            memory_dir: memory_dir.clone(),
5772            embedder_backend: EmbedderBackend::Mock,
5773            embedding_url: String::new(),
5774            embedding_model: "mock".into(),
5775            embedding_dims: 768,
5776            turbo_quant_enabled: false,
5777            turbo_quant_bits: None,
5778            turbo_quant_projections: None,
5779        };
5780        let runtime = tokio::runtime::Runtime::new().unwrap();
5781        let server = SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5782        let query = "compare rust vs python performance";
5783
5784        for index in 0..ROUTING_POLICY_PERSIST_BATCH {
5785            let receipt = invoke_record_outcome(&runtime, &server, query, "bad");
5786            assert_eq!(
5787                receipt["policy_state"]["persisted"],
5788                index + 1 == ROUTING_POLICY_PERSIST_BATCH
5789            );
5790        }
5791
5792        let persisted = runtime
5793            .block_on(server.bridge.store.load_routing_policy())
5794            .unwrap()
5795            .expect("batched routing policy persisted");
5796        assert_eq!(persisted.trained_examples, ROUTING_POLICY_PERSIST_BATCH);
5797        assert!(persisted.last_updated.is_some());
5798        assert!(is_trained(&persisted));
5799
5800        let profile = QueryProfile::from_query(query);
5801        let heuristic = RetrievalRouter::default().route(&profile);
5802        let before_restart = route_with_policy(&persisted, &profile);
5803        assert_ne!(before_restart.rerank_fine, heuristic.rerank_fine);
5804
5805        let policy_json = runtime
5806            .block_on(async { tokio::task::block_in_place(|| server.sm_get_routing_policy()) })
5807            .unwrap();
5808        let policy_json: serde_json::Value = structured_value(&policy_json);
5809        assert_eq!(
5810            policy_json["policy"]["training_examples_count"],
5811            ROUTING_POLICY_PERSIST_BATCH
5812        );
5813        assert!(policy_json["policy"]["last_updated"].is_string());
5814
5815        drop(server);
5816        let restarted =
5817            SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5818        let loaded = runtime
5819            .block_on(restarted.bridge.store.load_routing_policy())
5820            .unwrap()
5821            .expect("routing policy loaded after restart");
5822        let after_restart = route_with_policy(&loaded, &profile);
5823        assert_eq!(after_restart.bm25_coarse, before_restart.bm25_coarse);
5824        assert_eq!(after_restart.vector_medium, before_restart.vector_medium);
5825        assert_eq!(after_restart.rerank_fine, before_restart.rerank_fine);
5826        assert_eq!(after_restart.decoder, before_restart.decoder);
5827    }
5828
5829    fn governed_decision_server(
5830        scopes: semantic_memory::AuthorityScopesV1,
5831    ) -> (SemanticMemoryServer, tokio::runtime::Runtime, String) {
5832        use semantic_memory::{
5833            AuthorityPermit, ElevationRequirementV1, NamespaceScopeV1, OriginAuthorityLabelV1,
5834            OriginClassV1, OriginRiskV1, RevocationStatusV1, SubjectPrincipalV1,
5835        };
5836
5837        let dir = tempfile::tempdir().unwrap();
5838        let bridge = MemoryBridge::open(BridgeConfig {
5839            memory_dir: dir.path().to_path_buf(),
5840            embedder_backend: EmbedderBackend::Mock,
5841            embedding_url: String::new(),
5842            embedding_model: "mock".into(),
5843            embedding_dims: 768,
5844            turbo_quant_enabled: false,
5845            turbo_quant_bits: None,
5846            turbo_quant_projections: None,
5847        })
5848        .unwrap();
5849        let runtime = tokio::runtime::Runtime::new().unwrap();
5850        let origin = OriginAuthorityLabelV1::new(
5851            OriginClassV1::UserStatement,
5852            "principal:writer",
5853            "governed-decision-test",
5854            "blake3:governed-decision-source",
5855            OriginRiskV1::Low,
5856            scopes,
5857            ElevationRequirementV1::ExplicitOperatorApproval,
5858            None,
5859            RevocationStatusV1::Active,
5860            vec!["principal:alice".into(), "team:medical".into()],
5861        )
5862        .unwrap()
5863        .with_subject_principal(SubjectPrincipalV1::new("principal:patient").unwrap())
5864        .with_resource_scope(NamespaceScopeV1::exact("medical"));
5865        let fact_id = runtime
5866            .block_on(
5867                bridge.store.authority().append(
5868                    AuthorityPermit::with_evidence(
5869                        "principal:writer",
5870                        "governed-decision-test",
5871                        AuthorityPermit::APPEND_CAPABILITY,
5872                        vec!["evidence:test".into()],
5873                    )
5874                    .with_origin(origin),
5875                    "governed-decision".into(),
5876                    "medical".into(),
5877                    "CONFIDENTIAL_MEMORY_SENTINEL".into(),
5878                    None,
5879                ),
5880            )
5881            .unwrap()
5882            .affected_ids[0]
5883            .clone();
5884        (SemanticMemoryServer::new(bridge, "full"), runtime, fact_id)
5885    }
5886
5887    fn decision_params(fact_id: &str) -> GovernedDecisionParams {
5888        GovernedDecisionParams {
5889            fact_id: fact_id.into(),
5890            caller: "principal:alice".into(),
5891            subject: "principal:patient".into(),
5892            audiences: vec!["principal:alice".into(), "team:medical".into()],
5893            scope: GovernedNamespaceScopeParams {
5894                namespace: "medical".into(),
5895                domain: None,
5896                workspace_id: None,
5897                repo_id: None,
5898            },
5899            delegation_or_elevation: None,
5900        }
5901    }
5902
5903    fn invoke_decision(
5904        runtime: &tokio::runtime::Runtime,
5905        server: &SemanticMemoryServer,
5906        purpose: semantic_memory::GovernedAccessPurposeV1,
5907        params: GovernedDecisionParams,
5908    ) -> serde_json::Value {
5909        let body = runtime
5910            .block_on(async {
5911                tokio::task::block_in_place(|| match purpose {
5912                    semantic_memory::GovernedAccessPurposeV1::Assertion => {
5913                        server.sm_decide_assertion_authority(Parameters(params))
5914                    }
5915                    semantic_memory::GovernedAccessPurposeV1::Action => {
5916                        server.sm_decide_action_authority(Parameters(params))
5917                    }
5918                    _ => unreachable!(),
5919                })
5920            })
5921            .unwrap();
5922        structured_value(&body)
5923    }
5924
5925    #[test]
5926    fn recall_authority_does_not_imply_assertion_or_action_authority_and_denials_omit_content() {
5927        use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5928        let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5929            recall: AuthorityScopeV1::Audience,
5930            assertion: AuthorityScopeV1::Denied,
5931            action: AuthorityScopeV1::Denied,
5932        });
5933
5934        for purpose in [
5935            GovernedAccessPurposeV1::Assertion,
5936            GovernedAccessPurposeV1::Action,
5937        ] {
5938            let receipt = invoke_decision(&runtime, &server, purpose, decision_params(&fact_id));
5939            assert_eq!(receipt["schema_version"], "origin_authority_decision_v1");
5940            assert_eq!(receipt["allowed"], false);
5941            assert_eq!(
5942                receipt["purpose"],
5943                match purpose {
5944                    GovernedAccessPurposeV1::Assertion => "assertion",
5945                    GovernedAccessPurposeV1::Action => "action",
5946                    _ => unreachable!(),
5947                }
5948            );
5949            assert!(receipt["reasons"]
5950                .as_array()
5951                .unwrap()
5952                .iter()
5953                .any(|reason| { reason == "scope_or_principal_denied" }));
5954            let serialized = receipt.to_string();
5955            assert!(!serialized.contains("CONFIDENTIAL_MEMORY_SENTINEL"));
5956            for forbidden in ["fact", "content", "origin", "memory"] {
5957                assert!(receipt.get(forbidden).is_none(), "leaked field {forbidden}");
5958            }
5959        }
5960    }
5961
5962    #[test]
5963    fn assertion_decision_honors_delegation_expiry_audience_and_namespace() {
5964        use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5965        let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5966            recall: AuthorityScopeV1::Audience,
5967            assertion: AuthorityScopeV1::Audience,
5968            action: AuthorityScopeV1::Audience,
5969        });
5970        let mut params = decision_params(&fact_id);
5971        params.delegation_or_elevation = Some(GovernedLeaseParams {
5972            lease_id: "lease:assertion".into(),
5973            delegator: "principal:patient".into(),
5974            delegatee: "principal:alice".into(),
5975            purposes: vec![GovernedAccessPurposeParam::Assertion],
5976            scope: params.scope.clone(),
5977            audiences: vec!["team:medical".into()],
5978            expires_at: "2999-01-01T00:00:00Z".into(),
5979            revoked: false,
5980            elevation: false,
5981        });
5982        assert_eq!(
5983            invoke_decision(
5984                &runtime,
5985                &server,
5986                GovernedAccessPurposeV1::Assertion,
5987                params.clone()
5988            )["allowed"],
5989            true
5990        );
5991
5992        let mut expired = params.clone();
5993        expired.delegation_or_elevation.as_mut().unwrap().expires_at =
5994            "2000-01-01T00:00:00Z".into();
5995        let receipt = invoke_decision(
5996            &runtime,
5997            &server,
5998            GovernedAccessPurposeV1::Assertion,
5999            expired,
6000        );
6001        assert_eq!(receipt["allowed"], false);
6002        assert!(receipt["reasons"]
6003            .as_array()
6004            .unwrap()
6005            .contains(&serde_json::json!("delegation_expired")));
6006
6007        let mut wrong_audience = params.clone();
6008        wrong_audience.audiences = vec!["team:other".into()];
6009        let receipt = invoke_decision(
6010            &runtime,
6011            &server,
6012            GovernedAccessPurposeV1::Assertion,
6013            wrong_audience,
6014        );
6015        assert_eq!(receipt["allowed"], false);
6016        assert!(receipt["reasons"]
6017            .as_array()
6018            .unwrap()
6019            .contains(&serde_json::json!("audience_intersection_empty")));
6020
6021        let mut wrong_namespace = params;
6022        wrong_namespace.scope.namespace = "other".into();
6023        let receipt = invoke_decision(
6024            &runtime,
6025            &server,
6026            GovernedAccessPurposeV1::Assertion,
6027            wrong_namespace,
6028        );
6029        assert_eq!(receipt["allowed"], false);
6030        assert!(receipt["reasons"]
6031            .as_array()
6032            .unwrap()
6033            .contains(&serde_json::json!("namespace_scope_mismatch")));
6034    }
6035
6036    #[test]
6037    fn action_decision_honors_live_elevation_without_converting_it_to_admin_access() {
6038        use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
6039        let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
6040            recall: AuthorityScopeV1::Audience,
6041            assertion: AuthorityScopeV1::Denied,
6042            action: AuthorityScopeV1::Audience,
6043        });
6044        let mut params = decision_params(&fact_id);
6045        params.delegation_or_elevation = Some(GovernedLeaseParams {
6046            lease_id: "lease:elevation".into(),
6047            delegator: "principal:patient".into(),
6048            delegatee: "principal:alice".into(),
6049            purposes: vec![GovernedAccessPurposeParam::Admin],
6050            scope: params.scope.clone(),
6051            audiences: vec![],
6052            expires_at: "2999-01-01T00:00:00Z".into(),
6053            revoked: false,
6054            elevation: true,
6055        });
6056        let receipt = invoke_decision(&runtime, &server, GovernedAccessPurposeV1::Action, params);
6057        assert_eq!(receipt["allowed"], true);
6058        assert_eq!(receipt["purpose"], "action");
6059        assert_eq!(receipt["lease_id"], "lease:elevation");
6060    }
6061    impl GraphView for TestGraph {
6062        fn neighbors(
6063            &self,
6064            node: &str,
6065            _: GraphDirection,
6066            _: usize,
6067        ) -> Result<Vec<GraphEdge>, MemoryError> {
6068            Ok(self
6069                .0
6070                .iter()
6071                .filter(|(a, b)| *a == node || *b == node)
6072                .map(|(a, b)| GraphEdge {
6073                    source: (*a).into(),
6074                    target: (*b).into(),
6075                    edge_type: GraphEdgeType::Entity {
6076                        relation: "test".into(),
6077                    },
6078                    weight: 1.0,
6079                    metadata: None,
6080                })
6081                .collect())
6082        }
6083        fn path(&self, _: &str, _: &str, _: usize) -> Result<Option<Vec<String>>, MemoryError> {
6084            unreachable!()
6085        }
6086    }
6087
6088    #[test]
6089    fn graph_path_outcomes_do_not_conflate_exhaustion_and_budget() {
6090        let graph = TestGraph(vec![("a", "b"), ("b", "c"), ("x", "y")]);
6091        assert_eq!(
6092            typed_graph_path(&graph, "a", "c", 2).unwrap(),
6093            GraphPathOutcome::Found(vec!["a".into(), "b".into(), "c".into()])
6094        );
6095        assert_eq!(
6096            typed_graph_path(&graph, "a", "c", 1).unwrap(),
6097            GraphPathOutcome::BudgetExceeded
6098        );
6099        assert_eq!(
6100            typed_graph_path(&graph, "a", "x", 5).unwrap(),
6101            GraphPathOutcome::NoPathWithinCompleteSearch
6102        );
6103        assert_eq!(
6104            typed_graph_path(&graph, "missing", "x", 5).unwrap(),
6105            GraphPathOutcome::InvalidEndpoint("missing".into())
6106        );
6107    }
6108
6109    #[test]
6110    fn sm_add_fact_uses_authority_append_and_preserves_output_contract() {
6111        let dir = tempfile::tempdir().unwrap();
6112        let server = SemanticMemoryServer::new(
6113            MemoryBridge::open(BridgeConfig {
6114                memory_dir: dir.path().to_path_buf(),
6115                embedder_backend: EmbedderBackend::Mock,
6116                embedding_url: String::new(),
6117                embedding_model: "mock".into(),
6118                embedding_dims: 768,
6119                turbo_quant_enabled: false,
6120                turbo_quant_bits: None,
6121                turbo_quant_projections: None,
6122            })
6123            .unwrap(),
6124            "full",
6125        );
6126        let runtime = tokio::runtime::Runtime::new().unwrap();
6127        server
6128            .bridge
6129            .store
6130            .authority()
6131            .set_fault(Some(semantic_memory::AuthorityFaultStage::BeforeAppend));
6132
6133        let error = invoke_add_fact(
6134            &runtime,
6135            &server,
6136            add_fact_params("must pass through authority", Some("mcp-authority-fault")),
6137        )
6138        .err()
6139        .expect("expected tool error");
6140        assert!(error.message.contains("authority fault injected"));
6141        assert_eq!(
6142            runtime
6143                .block_on(server.bridge.store.stats())
6144                .unwrap()
6145                .total_facts,
6146            0
6147        );
6148
6149        let body = invoke_add_fact(
6150            &runtime,
6151            &server,
6152            add_fact_params("must pass through authority", Some("mcp-authority-success")),
6153        )
6154        .unwrap();
6155        let json: serde_json::Value = structured_value(&body);
6156        assert_eq!(json["ok"], true);
6157        assert_eq!(json["namespace"], "authority-test");
6158        assert_eq!(json["message"], "Fact added successfully");
6159        assert!(json["fact_id"].as_str().is_some());
6160        assert_eq!(json.as_object().unwrap().len(), 5);
6161        assert!(json["receipt"]["receipt_id"].as_str().is_some());
6162        assert!(json["receipt"]["recorded_at"].as_str().is_some());
6163        assert_eq!(json["receipt"]["tool"], "sm_add_fact");
6164    }
6165
6166    #[test]
6167    fn sm_add_fact_replays_explicit_key_but_unkeyed_identical_writes_remain_distinct() {
6168        let dir = tempfile::tempdir().unwrap();
6169        let server = SemanticMemoryServer::new(
6170            MemoryBridge::open(BridgeConfig {
6171                memory_dir: dir.path().to_path_buf(),
6172                embedder_backend: EmbedderBackend::Mock,
6173                embedding_url: String::new(),
6174                embedding_model: "mock".into(),
6175                embedding_dims: 768,
6176                turbo_quant_enabled: false,
6177                turbo_quant_bits: None,
6178                turbo_quant_projections: None,
6179            })
6180            .unwrap(),
6181            "full",
6182        );
6183        let runtime = tokio::runtime::Runtime::new().unwrap();
6184
6185        let first = invoke_add_fact(
6186            &runtime,
6187            &server,
6188            add_fact_params("retry-safe", Some("caller-retry-key")),
6189        )
6190        .unwrap();
6191        let retry = invoke_add_fact(
6192            &runtime,
6193            &server,
6194            add_fact_params("retry-safe", Some("caller-retry-key")),
6195        )
6196        .unwrap();
6197        // Compare fact_id rather than full JSON: receipt_id and recorded_at
6198        // are unique per call by design, so full-string equality would fail.
6199        let first_fact = structured_value(&first)["fact_id"]
6200            .as_str()
6201            .unwrap()
6202            .to_string();
6203        let retry_fact = structured_value(&retry)["fact_id"]
6204            .as_str()
6205            .unwrap()
6206            .to_string();
6207        assert_eq!(first_fact, retry_fact);
6208
6209        let unkeyed_a = invoke_add_fact(
6210            &runtime,
6211            &server,
6212            add_fact_params("legitimate duplicate", None),
6213        )
6214        .unwrap();
6215        let unkeyed_b = invoke_add_fact(
6216            &runtime,
6217            &server,
6218            add_fact_params("legitimate duplicate", None),
6219        )
6220        .unwrap();
6221        let fact_id = |body: &Json<StructuredOutput>| {
6222            structured_value(body)["fact_id"]
6223                .as_str()
6224                .unwrap()
6225                .to_string()
6226        };
6227        assert_ne!(fact_id(&unkeyed_a), fact_id(&unkeyed_b));
6228        assert_eq!(
6229            runtime
6230                .block_on(server.bridge.store.stats())
6231                .unwrap()
6232                .total_facts,
6233            3
6234        );
6235    }
6236
6237    #[test]
6238    fn sm_delete_fact_uses_governed_forgetting_closure() {
6239        let dir = tempfile::tempdir().unwrap();
6240        let server = SemanticMemoryServer::new(
6241            MemoryBridge::open(BridgeConfig {
6242                memory_dir: dir.path().to_path_buf(),
6243                embedder_backend: EmbedderBackend::Mock,
6244                embedding_url: String::new(),
6245                embedding_model: "mock".into(),
6246                embedding_dims: 768,
6247                turbo_quant_enabled: false,
6248                turbo_quant_bits: None,
6249                turbo_quant_projections: None,
6250            })
6251            .unwrap(),
6252            "full",
6253        );
6254        let runtime = tokio::runtime::Runtime::new().unwrap();
6255        let added = invoke_add_fact(
6256            &runtime,
6257            &server,
6258            add_fact_params("forget through MCP", Some("mcp-forget-source")),
6259        )
6260        .unwrap();
6261        let fact_id = structured_value(&added)["fact_id"]
6262            .as_str()
6263            .unwrap()
6264            .to_string();
6265        let response = runtime
6266            .block_on(async {
6267                tokio::task::block_in_place(|| {
6268                    server.sm_delete_fact(Parameters(DeleteFactParams {
6269                        fact_id: fact_id.clone(),
6270                    }))
6271                })
6272            })
6273            .unwrap();
6274        let json: serde_json::Value = structured_value(&response);
6275        assert_eq!(json["forgotten"], true);
6276        assert_eq!(json["deleted"], false);
6277        assert_eq!(
6278            json["forgetting_receipt"]["schema_version"],
6279            "forgetting_closure_receipt_v1"
6280        );
6281        let raw = runtime
6282            .block_on(server.bridge.store.get_fact_raw_compat(&fact_id))
6283            .unwrap()
6284            .unwrap();
6285        assert_eq!(raw.content, "[FORGOTTEN]");
6286
6287        let http_origin = semantic_memory::OriginAuthorityLabelV1::operator_system(
6288            "principal:semantic-memory-http",
6289            "caller:http-add",
6290        );
6291        let http_fact = runtime
6292            .block_on(
6293                server.bridge.store.authority().append(
6294                    semantic_memory::AuthorityPermit::operator_system(
6295                        "principal:semantic-memory-http",
6296                        "caller:http-add",
6297                        semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6298                    )
6299                    .with_origin(http_origin),
6300                    "cross-adapter-forget".into(),
6301                    "authority-test".into(),
6302                    "cross adapter fact".into(),
6303                    None,
6304                ),
6305            )
6306            .unwrap()
6307            .affected_ids[0]
6308            .clone();
6309        let response = runtime
6310            .block_on(async {
6311                tokio::task::block_in_place(|| {
6312                    server.sm_delete_fact(Parameters(DeleteFactParams {
6313                        fact_id: http_fact.clone(),
6314                    }))
6315                })
6316            })
6317            .unwrap();
6318        let json: serde_json::Value = structured_value(&response);
6319        assert_eq!(json["forgotten"], true);
6320        assert_eq!(
6321            runtime
6322                .block_on(server.bridge.store.get_fact_raw_compat(&http_fact))
6323                .unwrap()
6324                .unwrap()
6325                .content,
6326            "[FORGOTTEN]"
6327        );
6328    }
6329
6330    #[test]
6331    fn sm_add_fact_preserves_ephemeral_evidence_refs_admission_rule() {
6332        let dir = tempfile::tempdir().unwrap();
6333        let server = SemanticMemoryServer::new(
6334            MemoryBridge::open(BridgeConfig {
6335                memory_dir: dir.path().to_path_buf(),
6336                embedder_backend: EmbedderBackend::Mock,
6337                embedding_url: String::new(),
6338                embedding_model: "mock".into(),
6339                embedding_dims: 768,
6340                turbo_quant_enabled: false,
6341                turbo_quant_bits: None,
6342                turbo_quant_projections: None,
6343            })
6344            .unwrap(),
6345            "full",
6346        );
6347        let runtime = tokio::runtime::Runtime::new().unwrap();
6348        let mut params = add_fact_params("unsupported inference", Some("ephemeral-source-only"));
6349        params.memory_kind = Some("ephemeral_inference".into());
6350        params.evidence_refs = None;
6351
6352        let error = invoke_add_fact(&runtime, &server, params)
6353            .err()
6354            .expect("expected tool error");
6355        assert!(error.message.contains("requires evidence_refs"));
6356        assert_eq!(
6357            runtime
6358                .block_on(server.bridge.store.stats())
6359                .unwrap()
6360                .total_facts,
6361            0
6362        );
6363    }
6364
6365    #[test]
6366    fn sm_search_tool_returns_only_current_supersession_head() {
6367        let dir = tempfile::tempdir().unwrap();
6368        let bridge = MemoryBridge::open(BridgeConfig {
6369            memory_dir: dir.path().to_path_buf(),
6370            embedder_backend: EmbedderBackend::Mock,
6371            embedding_url: String::new(),
6372            embedding_model: "mock".into(),
6373            embedding_dims: 768,
6374            turbo_quant_enabled: false,
6375            turbo_quant_bits: None,
6376            turbo_quant_projections: None,
6377        })
6378        .unwrap();
6379        let runtime = tokio::runtime::Runtime::new().unwrap();
6380        let old = runtime
6381            .block_on(
6382                bridge
6383                    .store
6384                    .add_fact("state", "runtime channel is violet", None, None),
6385            )
6386            .unwrap();
6387        let new = runtime
6388            .block_on(
6389                bridge
6390                    .store
6391                    .add_fact("state", "runtime channel is saffron", None, None),
6392            )
6393            .unwrap();
6394        runtime
6395            .block_on(bridge.store.add_graph_edge(
6396                &format!("fact:{new}"),
6397                &format!("fact:{old}"),
6398                GraphEdgeType::Entity {
6399                    relation: "supersedes".into(),
6400                },
6401                1.0,
6402                None,
6403            ))
6404            .unwrap();
6405        let server = SemanticMemoryServer::new(bridge, "full");
6406        let body = runtime
6407            .block_on(async {
6408                tokio::task::block_in_place(|| {
6409                    server.sm_search(Parameters(SearchParams {
6410                        query: "runtime channel".into(),
6411                        top_k: Some(10),
6412                        namespaces: Some(vec!["state".into()]),
6413                    }))
6414                })
6415            })
6416            .unwrap();
6417        let body = structured_value(&body).to_string();
6418        assert!(body.contains("saffron"));
6419        assert!(!body.contains("violet"));
6420    }
6421
6422    #[test]
6423    fn witnessed_search_hydrates_complete_honest_fact_provenance() {
6424        let dir = tempfile::tempdir().unwrap();
6425        let bridge = MemoryBridge::open(BridgeConfig {
6426            memory_dir: dir.path().to_path_buf(),
6427            embedder_backend: EmbedderBackend::Mock,
6428            embedding_url: String::new(),
6429            embedding_model: "mock".into(),
6430            embedding_dims: 768,
6431            turbo_quant_enabled: false,
6432            turbo_quant_bits: None,
6433            turbo_quant_projections: None,
6434        })
6435        .unwrap();
6436        let runtime = tokio::runtime::Runtime::new().unwrap();
6437        let fact_id = runtime
6438            .block_on(bridge.store.add_fact(
6439                "provenance-test",
6440                "witnessed facts must retain their source",
6441                Some("tests/witnessed-source.md"),
6442                None,
6443            ))
6444            .unwrap();
6445        let server = SemanticMemoryServer::new(bridge, "full");
6446        let body = runtime
6447            .block_on(async {
6448                tokio::task::block_in_place(|| {
6449                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6450                        query: "witnessed facts retain source".into(),
6451                        top_k: Some(5),
6452                        namespaces: Some(vec!["provenance-test".into()]),
6453                        request_id: Some("witnessed-provenance-test".into()),
6454                        retrieval_mode: None,
6455                        replay_mode: None,
6456                    }))
6457                })
6458            })
6459            .unwrap();
6460        let json: serde_json::Value = structured_value(&body);
6461        let hit = json["results"]
6462            .as_array()
6463            .unwrap()
6464            .iter()
6465            .find(|hit| hit["memory_id"] == format!("fact:{fact_id}"))
6466            .expect("the stored fact is an injectible witnessed result");
6467
6468        assert_eq!(hit["namespace"], "provenance-test");
6469        assert_eq!(hit["source"], "tests/witnessed-source.md");
6470        assert_eq!(hit["trust"], "persisted_unjudged");
6471        assert_eq!(hit["state"], "current");
6472        assert_eq!(
6473            hit["retrieval_receipt_ref"],
6474            format!("receipt:{}", json["receipt_id"].as_str().unwrap())
6475        );
6476    }
6477
6478    #[test]
6479    fn witnessed_search_omits_noninjectible_fact_without_source() {
6480        let dir = tempfile::tempdir().unwrap();
6481        let bridge = MemoryBridge::open(BridgeConfig {
6482            memory_dir: dir.path().to_path_buf(),
6483            embedder_backend: EmbedderBackend::Mock,
6484            embedding_url: String::new(),
6485            embedding_model: "mock".into(),
6486            embedding_dims: 768,
6487            turbo_quant_enabled: false,
6488            turbo_quant_bits: None,
6489            turbo_quant_projections: None,
6490        })
6491        .unwrap();
6492        let runtime = tokio::runtime::Runtime::new().unwrap();
6493        let _fact_id = runtime
6494            .block_on(bridge.store.add_fact(
6495                "provenance-test",
6496                "source-less facts cannot be injected autonomously",
6497                None,
6498                None,
6499            ))
6500            .unwrap();
6501        let server = SemanticMemoryServer::new(bridge, "full");
6502        let body = runtime
6503            .block_on(async {
6504                tokio::task::block_in_place(|| {
6505                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6506                        query: "source-less facts autonomous injection".into(),
6507                        top_k: Some(5),
6508                        namespaces: Some(vec!["provenance-test".into()]),
6509                        request_id: Some("witnessed-source-less-test".into()),
6510                        retrieval_mode: None,
6511                        replay_mode: None,
6512                    }))
6513                })
6514            })
6515            .unwrap();
6516        let json: serde_json::Value = structured_value(&body);
6517        assert!(json["results"].as_array().unwrap().is_empty());
6518    }
6519
6520    #[test]
6521    fn sm_search_witnessed_exposes_authority_state_and_vector_evidence() {
6522        let dir = tempfile::tempdir().unwrap();
6523        let bridge = MemoryBridge::open(BridgeConfig {
6524            memory_dir: dir.path().to_path_buf(),
6525            embedder_backend: EmbedderBackend::Mock,
6526            embedding_url: String::new(),
6527            embedding_model: "mock".into(),
6528            embedding_dims: 768,
6529            turbo_quant_enabled: false,
6530            turbo_quant_bits: None,
6531            turbo_quant_projections: None,
6532        })
6533        .unwrap();
6534        let runtime = tokio::runtime::Runtime::new().unwrap();
6535        let permit = semantic_memory::AuthorityPermit::operator_system(
6536            "witness-principal",
6537            "witness-caller",
6538            semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6539        );
6540        let receipt = runtime
6541            .block_on(bridge.store.authority().append(
6542                permit,
6543                "witnessed-state-1".into(),
6544                "stateful".into(),
6545                "governed witnessed state should expose".into(),
6546                Some("tests/witnessed-state.md".into()),
6547            ))
6548            .unwrap();
6549
6550        let server = SemanticMemoryServer::new(bridge, "lean");
6551        let body = runtime
6552            .block_on(async {
6553                tokio::task::block_in_place(|| {
6554                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6555                        query: "governed witnessed state should expose".into(),
6556                        top_k: Some(5),
6557                        namespaces: Some(vec!["stateful".into()]),
6558                        request_id: Some("witnessed-state-request".into()),
6559                        retrieval_mode: None,
6560                        replay_mode: None,
6561                    }))
6562                })
6563            })
6564            .unwrap();
6565        let json: serde_json::Value = structured_value(&body);
6566
6567        assert_eq!(json["retrieval_mode"], "hybrid");
6568        assert!(json["authority"]["snapshot_id"].as_str().is_some());
6569        assert!(
6570            json["authority"]["retrieval_epoch"].as_u64().is_some()
6571                || json["authority"]["retrieval_epoch"].as_str().is_some(),
6572            "authoritative retrieval epoch should be surfaced"
6573        );
6574        assert!(json["current_snapshot_id"].as_str().is_some());
6575        assert!(
6576            json["retrieval_epoch"].as_u64().is_some()
6577                || json["retrieval_epoch"].as_str().is_some(),
6578            "top-level retrieval epoch should be surfaced"
6579        );
6580
6581        assert_eq!(json["authority"]["status"], "Applied");
6582        assert_eq!(
6583            json["stage_outcomes"]["authority_snapshot"]["outcome"],
6584            "Applied"
6585        );
6586
6587        let hit = json["results"]
6588            .as_array()
6589            .unwrap()
6590            .iter()
6591            .find(|hit| {
6592                hit.get("content")
6593                    .and_then(|value| value.as_str())
6594                    .is_some_and(|content| content.contains("governed witnessed state"))
6595            })
6596            .expect("governed witnessed result should be returned");
6597
6598        assert!(hit["cosine_similarity"].as_f64().is_some());
6599        assert_eq!(
6600            hit["source"],
6601            serde_json::json!("tests/witnessed-state.md"),
6602            "witnessed path should retain source"
6603        );
6604        assert_eq!(
6605            json["ordered_results"][0]["result_id"],
6606            format!("fact:{}", receipt.affected_ids[0])
6607        );
6608    }
6609
6610    #[test]
6611    fn sm_search_witnessed_accepts_general_retrieval_modes() {
6612        let dir = tempfile::tempdir().unwrap();
6613        let bridge = MemoryBridge::open(BridgeConfig {
6614            memory_dir: dir.path().to_path_buf(),
6615            embedder_backend: EmbedderBackend::Mock,
6616            embedding_url: String::new(),
6617            embedding_model: "mock".into(),
6618            embedding_dims: 768,
6619            turbo_quant_enabled: false,
6620            turbo_quant_bits: None,
6621            turbo_quant_projections: None,
6622        })
6623        .unwrap();
6624        let rt = tokio::runtime::Runtime::new().unwrap();
6625        rt.block_on(
6626            bridge
6627                .store
6628                .add_fact("modes", "alpha beta gamma", Some("test"), None),
6629        )
6630        .unwrap();
6631        let server = SemanticMemoryServer::new(bridge, "full");
6632        for retrieval_mode in [
6633            RetrievalModeParam::Hybrid,
6634            RetrievalModeParam::FtsOnly,
6635            RetrievalModeParam::VectorOnly,
6636        ] {
6637            let body = rt
6638                .block_on(async {
6639                    server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6640                        query: "alpha beta".into(),
6641                        top_k: Some(5),
6642                        namespaces: Some(vec!["modes".into()]),
6643                        request_id: None,
6644                        retrieval_mode: Some(retrieval_mode),
6645                        replay_mode: None,
6646                    }))
6647                })
6648                .unwrap();
6649            let json: serde_json::Value = structured_value(&body);
6650            assert_eq!(json["ok"], true);
6651            assert!(json["receipt_id"].is_string());
6652            assert_eq!(
6653                json["retrieval_mode"],
6654                serde_json::to_value(retrieval_mode).unwrap()
6655            );
6656        }
6657    }
6658
6659    #[test]
6660    fn witnessed_search_opt_in_enables_complete_replay() {
6661        let dir = tempfile::tempdir().unwrap();
6662        let bridge = MemoryBridge::open(BridgeConfig {
6663            memory_dir: dir.path().to_path_buf(),
6664            embedder_backend: EmbedderBackend::Mock,
6665            embedding_url: String::new(),
6666            embedding_model: "mock".into(),
6667            embedding_dims: 768,
6668            turbo_quant_enabled: false,
6669            turbo_quant_bits: None,
6670            turbo_quant_projections: None,
6671        })
6672        .unwrap();
6673        let runtime = tokio::runtime::Runtime::new().unwrap();
6674        runtime
6675            .block_on(bridge.store.add_fact(
6676                "stored-replay",
6677                "complete replay uses explicitly retained inputs",
6678                Some("tests/stored-replay.md"),
6679                None,
6680            ))
6681            .unwrap();
6682        let server = SemanticMemoryServer::new(bridge, "full");
6683        let body = runtime
6684            .block_on(async {
6685                server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6686                    query: "complete replay explicitly retained inputs".into(),
6687                    top_k: Some(1),
6688                    namespaces: Some(vec!["stored-replay".into()]),
6689                    request_id: Some("mcp-stored-replay".into()),
6690                    retrieval_mode: None,
6691                    replay_mode: Some(ReplayModeParam::StoreInputs),
6692                }))
6693            })
6694            .unwrap();
6695        let witnessed: serde_json::Value = structured_value(&body);
6696        assert_eq!(witnessed["complete_replay_available"], true);
6697        assert_eq!(witnessed["stage_outcomes"]["replay"]["outcome"], "Applied");
6698
6699        let replay = runtime
6700            .block_on(async {
6701                server.sm_replay_search(Parameters(ReplayStoredSearchParams {
6702                    receipt_id: "mcp-stored-replay".into(),
6703                }))
6704            })
6705            .unwrap();
6706        let replay: serde_json::Value = structured_value(&replay);
6707        assert_eq!(replay["result_ids_match"], true);
6708        assert_eq!(replay["query_embedding_digest_matches"], true);
6709    }
6710
6711    #[cfg(feature = "claim-integration")]
6712    fn claim_test_entries() -> Vec<claim_ledger::LedgerEntry> {
6713        let first = claim_ledger::LedgerEntryBuilder::new(1, None)
6714            .add_claim(
6715                "claim-1",
6716                "semantic-memory:fact:fact-1",
6717                "full",
6718                "verified claim",
6719            )
6720            .unwrap();
6721        let second = claim_ledger::LedgerEntryBuilder::new(2, Some(first.entry_digest.clone()))
6722            .add_support_judgment(
6723                "judgment-1",
6724                "claim-1",
6725                "bundle-1",
6726                claim_ledger::SupportState::Supported,
6727                "test",
6728            )
6729            .unwrap();
6730        vec![first, second]
6731    }
6732
6733    #[cfg(feature = "claim-integration")]
6734    fn write_claim_jsonl(path: &std::path::Path, entries: &[claim_ledger::LedgerEntry]) {
6735        let mut contents = String::new();
6736        for entry in entries {
6737            contents.push_str(&claim_ledger::serialize_entry(entry).unwrap());
6738            contents.push('\n');
6739        }
6740        std::fs::write(path, contents).unwrap();
6741    }
6742
6743    #[cfg(feature = "claim-integration")]
6744    #[test]
6745    fn claim_ledger_startup_rebuilds_from_verified_snapshot_and_tail() {
6746        let dir = tempfile::tempdir().unwrap();
6747        let legacy = dir.path().join("claim_ledger.jsonl");
6748        let entries = claim_test_entries();
6749        write_claim_jsonl(&legacy, &entries);
6750        let mut store = ClaimLedgerStore::open(legacy.clone());
6751        let result = store
6752            .compact(ClaimLedgerCompactionConfig {
6753                dry_run: false,
6754                max_entries: 0,
6755                max_bytes: 0,
6756                retain_tail_entries: 1,
6757                max_backups: 2,
6758            })
6759            .unwrap();
6760        assert_eq!(result["compacted"], true);
6761
6762        let reopened = ClaimLedgerStore::open(legacy);
6763        assert!(reopened.trust_enabled);
6764        assert!(reopened.snapshot.is_some());
6765        assert_eq!(reopened.entries.len(), 1);
6766        let mut index = ClaimTrustIndex::default();
6767        index.load_snapshot(reopened.snapshot.as_ref().unwrap());
6768        index.rebuild_from_ledger_incremental(&reopened.entries);
6769        assert_eq!(index.trust_for_fact("fact-1"), "supported");
6770        assert_eq!(index.last_processed_sequence, 2);
6771    }
6772
6773    #[cfg(feature = "claim-integration")]
6774    #[test]
6775    fn interrupted_compaction_files_leave_old_ledger_usable() {
6776        let dir = tempfile::tempdir().unwrap();
6777        let legacy = dir.path().join("claim_ledger.jsonl");
6778        let entries = claim_test_entries();
6779        write_claim_jsonl(&legacy, &entries);
6780        let interrupted = dir.path().join("claim_ledger_generations/.tmp-interrupted");
6781        std::fs::create_dir_all(&interrupted).unwrap();
6782        std::fs::write(interrupted.join("snapshot.json"), b"incomplete").unwrap();
6783
6784        let reopened = ClaimLedgerStore::open(legacy);
6785        assert!(reopened.trust_enabled);
6786        assert!(reopened.snapshot.is_none());
6787        assert_eq!(reopened.entries.len(), entries.len());
6788    }
6789
6790    #[cfg(feature = "claim-integration")]
6791    #[test]
6792    fn tampered_active_snapshot_disables_claim_trust() {
6793        let dir = tempfile::tempdir().unwrap();
6794        let legacy = dir.path().join("claim_ledger.jsonl");
6795        write_claim_jsonl(&legacy, &claim_test_entries());
6796        let mut store = ClaimLedgerStore::open(legacy.clone());
6797        store
6798            .compact(ClaimLedgerCompactionConfig {
6799                dry_run: false,
6800                max_entries: 0,
6801                max_bytes: 0,
6802                retain_tail_entries: 1,
6803                max_backups: 2,
6804            })
6805            .unwrap();
6806        let snapshot_path = store.path.parent().unwrap().join("snapshot.json");
6807        let mut snapshot: serde_json::Value =
6808            serde_json::from_slice(&std::fs::read(&snapshot_path).unwrap()).unwrap();
6809        snapshot["claims"][0]["normalized_claim"] = serde_json::json!("tampered");
6810        std::fs::write(&snapshot_path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
6811
6812        let reopened = ClaimLedgerStore::open(legacy);
6813        assert!(!reopened.trust_enabled);
6814        assert!(reopened.entries.is_empty());
6815    }
6816
6817    #[cfg(feature = "claim-integration")]
6818    #[test]
6819    fn compact_claim_ledger_defaults_to_dry_run_without_writes() {
6820        let dir = tempfile::tempdir().unwrap();
6821        let bridge = MemoryBridge::open(BridgeConfig {
6822            memory_dir: dir.path().to_path_buf(),
6823            embedder_backend: EmbedderBackend::Mock,
6824            embedding_url: String::new(),
6825            embedding_model: "mock".into(),
6826            embedding_dims: 768,
6827            turbo_quant_enabled: false,
6828            turbo_quant_bits: None,
6829            turbo_quant_projections: None,
6830        })
6831        .unwrap();
6832        let server = SemanticMemoryServer::new(bridge, "full");
6833        assert!(server.exposes_tool("sm_compact_claim_ledger"));
6834        {
6835            let mut store = server.claim_ledger_store.lock().unwrap();
6836            for entry in claim_test_entries() {
6837                store.append(entry).unwrap();
6838            }
6839        }
6840        let before = std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap();
6841        let response = server
6842            .sm_compact_claim_ledger(Parameters(CompactClaimLedgerParams {
6843                dry_run: None,
6844                max_entries: Some(0),
6845                max_bytes: Some(0),
6846                retain_tail_entries: Some(1),
6847                max_backups: Some(2),
6848            }))
6849            .unwrap();
6850        let response: serde_json::Value = structured_value(&response);
6851        assert_eq!(response["dry_run"], true);
6852        assert_eq!(response["compacted"], false);
6853        assert_eq!(
6854            before,
6855            std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap()
6856        );
6857        assert!(!dir
6858            .path()
6859            .join("claim_ledger.active_compaction.json")
6860            .exists());
6861        assert!(!dir.path().join("claim_ledger_generations").exists());
6862    }
6863}
6864
6865/// Build path segments with edge evidence for each hop in a path.
6866/// SM-AUD-011: Include edge type, weight, and metadata for each hop.
6867fn build_path_segments(
6868    store: &semantic_memory::MemoryStore,
6869    path: &[String],
6870) -> Vec<serde_json::Value> {
6871    let mut segments = Vec::new();
6872    if path.len() < 2 {
6873        return segments;
6874    }
6875
6876    for i in 0..path.len() - 1 {
6877        let from = &path[i];
6878        let to = &path[i + 1];
6879
6880        // Get neighbors of the current node to find the edge to the next node.
6881        let g = store.graph_view();
6882        match g.neighbors(from, semantic_memory::GraphDirection::Both, 1) {
6883            Ok(edges) => {
6884                // Find the edge that connects from -> to.
6885                let connecting = edges.iter().find(|e| {
6886                    (e.source == *from && e.target == *to) || (e.source == *to && e.target == *from)
6887                });
6888
6889                if let Some(edge) = connecting {
6890                    let edge_type_str = match &edge.edge_type {
6891                        semantic_memory::GraphEdgeType::Semantic { cosine_similarity } => {
6892                            serde_json::json!({
6893                                "type": "semantic",
6894                                "cosine_similarity": cosine_similarity,
6895                            })
6896                        }
6897                        semantic_memory::GraphEdgeType::Temporal { delta_secs } => {
6898                            serde_json::json!({
6899                                "type": "temporal",
6900                                "delta_secs": delta_secs,
6901                            })
6902                        }
6903                        semantic_memory::GraphEdgeType::Causal {
6904                            confidence,
6905                            evidence_ids,
6906                        } => {
6907                            serde_json::json!({
6908                                "type": "causal",
6909                                "confidence": confidence,
6910                                "evidence_ids": evidence_ids,
6911                            })
6912                        }
6913                        semantic_memory::GraphEdgeType::Entity { relation } => {
6914                            serde_json::json!({
6915                                "type": "entity",
6916                                "relation": relation,
6917                            })
6918                        }
6919                    };
6920
6921                    segments.push(serde_json::json!({
6922                        "source": from,
6923                        "target": to,
6924                        "edge_type": edge_type_str,
6925                        "weight": edge.weight,
6926                        "metadata": edge.metadata,
6927                    }));
6928                } else {
6929                    // No edge found between consecutive path nodes — shouldn't
6930                    // happen but handle gracefully.
6931                    segments.push(serde_json::json!({
6932                        "source": from,
6933                        "target": to,
6934                        "edge_type": null,
6935                        "weight": null,
6936                        "metadata": null,
6937                    }));
6938                }
6939            }
6940            Err(_) => {
6941                segments.push(serde_json::json!({
6942                    "source": from,
6943                    "target": to,
6944                    "edge_type": null,
6945                    "weight": null,
6946                    "metadata": null,
6947                }));
6948            }
6949        }
6950    }
6951
6952    segments
6953}
6954
6955#[tool_handler(
6956    router = self.tool_router,
6957    name = "semantic-memory-mcp",
6958    version = "0.5.2",
6959    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."
6960)]
6961impl ServerHandler for SemanticMemoryServer {}