Skip to main content

mati_core/mcp/
tools.rs

1//! MCP tool implementations (M-07, M-11).
2//!
3//! Public MCP surface:
4//! - `mem_get`       — direct key lookup
5//! - `mem_query`     — BM25 text search or graph traversal
6//! - `mem_bootstrap` — session context assembly within a token budget
7//! - `mem_set`       — knowledge record writes
8
9use std::collections::HashSet;
10use std::path::PathBuf;
11
12use rmcp::handler::server::tool::ToolRouter;
13use rmcp::handler::server::wrapper::Parameters;
14use rmcp::tool_router;
15use serde_json::json;
16
17use crate::graph::edges::EdgeKind;
18use crate::graph::Graph;
19use crate::store::record::{
20    Category, ContextPacket, FileRecord, GotchaRecord, Priority, QualityTier, Record,
21    RecordLifecycle, StaleReviewPayload, StalenessTier,
22};
23
24use super::protocol::{
25    self as proto, Command, DecisionUpsertInput, DevNoteUpsertInput, GotchaConfirmInput,
26    GotchaDraftInput, GotchaTombstoneInput,
27};
28use super::server::{proxy_daemon_result, proxy_daemon_v2, ProxyDaemonResult};
29use super::types::{MemBootstrapParams, MemGetParams, MemQueryParams, MemSetParams};
30
31/// Vector B — appended to every mem_bootstrap result (64 tokens, budget 77).
32pub(crate) const VECTOR_B: &str =
33    "\n\n[mati] Before reading any file: call mem_get(\"file:<path>\").\n\
34    confidence>=0.6 + confirmed=true \u{2192} use record, skip file read.\n\
35    confidence<0.3 \u{2192} read file, consider mem_set to improve.\n\
36    \"add gotcha\" \u{2192} mem_set(Gotcha) then mati gotcha confirm <key>.";
37
38/// Token budget for mem_bootstrap output (ARCHITECTURE.md §6).
39const TOKEN_BUDGET: usize = 2_000;
40
41/// Reserved tokens for Vector B suffix.
42const VECTOR_B_TOKENS: usize = 77;
43
44/// Estimate token count as text.len() / 4 (consistent with analysis/mod.rs).
45fn estimate_tokens(text: &str) -> usize {
46    text.len() / 4
47}
48
49/// Priority weight for sorting gotchas: confidence × priority_weight.
50fn priority_weight(priority: &Priority) -> f32 {
51    match priority {
52        Priority::Low => 0.25,
53        Priority::Normal => 0.50,
54        Priority::High => 0.75,
55        Priority::Critical => 1.00,
56    }
57}
58
59/// Strip a Record to its agent-facing shape. Removes internal metadata
60/// (device_id, clocks, gap_analysis_score, computed_at, sha, counters)
61/// that agents never use. Cuts ~40% of response size.
62pub(crate) fn record_to_agent_json(record: &Record) -> serde_json::Value {
63    let mut obj = serde_json::Map::new();
64    obj.insert("key".into(), serde_json::json!(record.key));
65    obj.insert("value".into(), serde_json::json!(record.value));
66    obj.insert("category".into(), serde_json::json!(record.category));
67    obj.insert("priority".into(), serde_json::json!(record.priority));
68    if !record.tags.is_empty() {
69        obj.insert("tags".into(), serde_json::json!(record.tags));
70    }
71    obj.insert(
72        "confidence".into(),
73        serde_json::json!(record.confidence.value),
74    );
75    obj.insert(
76        "confirmation_count".into(),
77        serde_json::json!(record.confidence.confirmation_count),
78    );
79    obj.insert("quality".into(), serde_json::json!(record.quality.value));
80    obj.insert(
81        "quality_tier".into(),
82        serde_json::json!(record.quality.tier),
83    );
84    if !record.quality.signals.is_empty() {
85        obj.insert(
86            "quality_signals".into(),
87            serde_json::json!(record.quality.signals),
88        );
89    }
90    obj.insert("source".into(), serde_json::json!(record.source));
91    obj.insert(
92        "staleness_tier".into(),
93        serde_json::json!(record.staleness.tier),
94    );
95    if let Some(ref url) = record.ref_url {
96        obj.insert("ref_url".into(), serde_json::json!(url));
97    }
98    if let Some(ref payload) = record.payload {
99        obj.insert("payload".into(), strip_payload(payload, &record.category));
100    }
101    serde_json::Value::Object(obj)
102}
103
104/// Strip internal-only fields from the payload based on record category.
105fn strip_payload(payload: &serde_json::Value, category: &Category) -> serde_json::Value {
106    let Some(obj) = payload.as_object() else {
107        return payload.clone();
108    };
109
110    // Fields to remove per category
111    let internal_fields: &[&str] = match category {
112        Category::File => &[
113            "token_cost_estimate",
114            "last_modified_session",
115            "content_hash",
116        ],
117        Category::Gotcha => &["discovered_session"],
118        _ => &[],
119    };
120
121    if internal_fields.is_empty() {
122        return payload.clone();
123    }
124
125    let mut stripped = obj.clone();
126    for field in internal_fields {
127        stripped.remove(*field);
128    }
129
130    // Remove empty arrays from file payloads to save space
131    if matches!(category, Category::File) {
132        stripped.retain(|_, v| !matches!(v, serde_json::Value::Array(a) if a.is_empty()));
133    }
134
135    serde_json::Value::Object(stripped)
136}
137
138/// The MCP server struct. After γ-C4, `mati serve` is always a thin
139/// MCP-stdio ↔ UDS proxy that forwards every tool call to a separate
140/// daemon process (spawned by `mati daemon start` or auto-spawned via
141/// `daemon_lifecycle::ensure_daemon`). The daemon owns the store; this
142/// struct only carries the path to the daemon root so we know where to
143/// open the Unix socket.
144#[derive(Clone)]
145pub struct MatiServer {
146    root: PathBuf,
147    pub(crate) tool_router: ToolRouter<Self>,
148}
149
150impl MatiServer {
151    /// Construct a socket-backed proxy rooted at `~/.mati/<slug>/`.
152    pub fn with_socket_root(root: PathBuf) -> Self {
153        Self {
154            root,
155            tool_router: Self::tool_router(),
156        }
157    }
158
159    fn socket_error(op: &str, result: ProxyDaemonResult) -> String {
160        let message = match result {
161            ProxyDaemonResult::NotRunning => format!("{op}: daemon not running"),
162            ProxyDaemonResult::StaleSocket => format!("{op}: daemon socket stale"),
163            ProxyDaemonResult::Unresponsive => format!("{op}: daemon unresponsive"),
164            ProxyDaemonResult::Ok(v) => format!("{op}: malformed daemon response: {v}"),
165        };
166        json!({ "error": message }).to_string()
167    }
168
169    async fn socket_call(&self, op: &str, args: serde_json::Value) -> String {
170        match proxy_daemon_result(&self.root, op, args).await {
171            ProxyDaemonResult::Ok(v) => Self::format_envelope(op, v),
172            other => Self::socket_error(op, other),
173        }
174    }
175
176    /// Send a typed v2 [`Command`] over the daemon socket and format the
177    /// response the same way [`Self::socket_call`] does.
178    ///
179    /// Use this for mutating commands (gotcha_upsert/confirm/tombstone,
180    /// decision_upsert, dev_note_upsert) which have no entry in the legacy
181    /// v1→v2 mapper and would panic the rmcp task if routed through
182    /// [`Self::socket_call`].
183    async fn socket_call_typed(&self, cmd: Command) -> String {
184        let op = cmd.kind();
185        match proxy_daemon_v2(&self.root, cmd).await {
186            ProxyDaemonResult::Ok(v) => Self::format_envelope(op, v),
187            other => Self::socket_error(op, other),
188        }
189    }
190
191    /// Render a daemon envelope `{ok,data}` / `{ok:false,error}` into the
192    /// JSON string the rmcp `String` return type expects.
193    fn format_envelope(op: &str, v: serde_json::Value) -> String {
194        if v.get("ok") != Some(&serde_json::Value::Bool(true)) {
195            let err = v
196                .get("error")
197                .and_then(|e| e.as_str())
198                .unwrap_or("daemon request failed");
199            // Surface the structured error code if present so callers can
200            // distinguish validation failures from store errors.
201            let code = v.get("code").and_then(|c| c.as_str()).unwrap_or("");
202            if code.is_empty() {
203                return json!({ "error": err, "op": op }).to_string();
204            }
205            return json!({ "error": err, "op": op, "code": code }).to_string();
206        }
207        match v.get("data") {
208            Some(serde_json::Value::String(s)) => s.clone(),
209            Some(other) => other.to_string(),
210            None => json!({ "error": "daemon response missing data" }).to_string(),
211        }
212    }
213}
214
215#[tool_router]
216impl MatiServer {
217    /// Retrieve a single record by its namespaced key.
218    ///
219    /// Returns the JSON-serialised record, or "null" if not found.
220    ///
221    /// Note: `read_only_hint = true` is set because mem_get does not modify
222    /// knowledge content. However, it does write consultation receipts
223    /// (session:consulted:*) synchronously — these are critical for hook
224    /// enforcement (deny → mem_get → allow cycle) — and defers access_count
225    /// and analytics writes to a background task.
226    #[rmcp::tool(
227        name = "mem_get",
228        description = "Look up one mati knowledge record by key. Before reading a file directly, call this with \"file:<path>\" and use the record instead when it is confirmed and high-confidence.",
229        annotations(read_only_hint = true)
230    )]
231    pub(crate) async fn mem_get(&self, Parameters(params): Parameters<MemGetParams>) -> String {
232        self.socket_call("mem_get", json!({ "key": params.key }))
233            .await
234    }
235
236    /// Search the knowledge store using BM25 text search or graph traversal.
237    ///
238    /// Modes: "text" (default) for full-text BM25, "graph" for 1-hop traversal.
239    /// Text mode returns a JSON array. Graph mode returns a grouped JSON object.
240    #[rmcp::tool(
241        name = "mem_query",
242        description = "Search the mati knowledge store. Use mode \"text\" for BM25 full-text search, mode \"tag\" to filter by tag, or mode \"graph\" for a 1-hop traversal from a seed key.",
243        annotations(read_only_hint = true)
244    )]
245    pub(crate) async fn mem_query(&self, Parameters(params): Parameters<MemQueryParams>) -> String {
246        self.socket_call(
247            "mem_query",
248            json!({ "query": params.query, "mode": params.mode, "limit": params.limit }),
249        )
250        .await
251    }
252
253    /// Assemble a context packet for the current session.
254    ///
255    /// Gathers stage, gotchas, file records, and decisions within a 2,000-token budget.
256    /// Returns a markdown injection string for Claude.
257    #[rmcp::tool(
258        name = "mem_bootstrap",
259        description = "Assemble a compact context packet for the current coding session from relevant gotchas, file records, and decisions. Call this at session start.",
260        annotations(read_only_hint = true)
261    )]
262    pub(crate) async fn mem_bootstrap(
263        &self,
264        Parameters(params): Parameters<MemBootstrapParams>,
265    ) -> String {
266        self.socket_call(
267            "mem_bootstrap",
268            json!({ "context_files": params.context_files }),
269        )
270        .await
271    }
272
273    /// Write an enriched knowledge record to the mati store.
274    ///
275    /// Used during `/mati-enrich` sessions. Source is always `ClaudeEnrich`.
276    /// Gotcha records land with `confirmed=false` — developer runs `mati review`
277    /// to confirm and activate hook enforcement.
278    #[rmcp::tool(
279        name = "mem_set",
280        description = "Write, confirm, or delete a knowledge record. Actions: \"write\" (default) creates/updates a record, \"confirm\" activates a gotcha for hook enforcement, \"delete\" tombstones a gotcha.",
281        annotations(
282            read_only_hint = false,
283            destructive_hint = true,
284            idempotent_hint = false
285        )
286    )]
287    pub(crate) async fn mem_set(&self, Parameters(params): Parameters<MemSetParams>) -> String {
288        // Route mem_set through typed Commands via proxy_daemon_v2. The
289        // legacy v1 mapper has no arms for gotcha_upsert / gotcha_confirm /
290        // gotcha_tombstone / decision_upsert / dev_note_upsert / the bogus
291        // literal "mem_set" — every prior call panicked the rmcp task and
292        // surfaced as `Transport closed` to the client.
293        match build_mem_set_command(&params) {
294            Ok(cmd) => self.socket_call_typed(cmd).await,
295            Err(error) => json!({ "error": error }).to_string(),
296        }
297    }
298}
299
300/// Map a `MemSetParams` request to a typed v2 [`Command`] for daemon dispatch.
301///
302/// Returns `Err(String)` when the request cannot be expressed as a typed
303/// Command — the caller renders this into a JSON error envelope rather
304/// than panicking the MCP transport.
305///
306/// # Routing rules
307///
308/// - `action = "confirm"` / `"delete"` → key MUST start with `gotcha:`.
309///   This mirrors the Direct path's `mem_set_confirm` / `mem_set_delete`
310///   guards (tools.rs:~1058).
311/// - `action = "write"` (default) routes by key prefix:
312///   - `gotcha:*`   → [`Command::GotchaUpsert`]
313///   - `decision:*` → [`Command::DecisionUpsert`]
314///   - `dev_note:*` → [`Command::DevNoteUpsert`]
315///   - other        → error (file: writes have no public typed Command —
316///     file records are managed by the static-analysis pipeline +
317///     `file_enrich` / `file_reparse`, not direct mem_set).
318fn build_mem_set_command(params: &MemSetParams) -> Result<Command, String> {
319    match params.action.as_str() {
320        "confirm" => {
321            if !params.key.starts_with("gotcha:") {
322                return Err("confirm action only applies to gotcha: keys".into());
323            }
324            Ok(Command::GotchaConfirm(GotchaConfirmInput {
325                key: params.key.clone(),
326            }))
327        }
328        "delete" => {
329            if !params.key.starts_with("gotcha:") {
330                return Err("delete action only applies to gotcha: keys".into());
331            }
332            Ok(Command::GotchaTombstone(GotchaTombstoneInput {
333                key: params.key.clone(),
334            }))
335        }
336        "write" | "" => build_mem_set_write_command(params),
337        other => Err(format!(
338            "unknown action: {other}. Valid: write, confirm, delete"
339        )),
340    }
341}
342
343fn build_mem_set_write_command(params: &MemSetParams) -> Result<Command, String> {
344    // Some MCP clients (Codex) send the payload as a JSON-encoded string;
345    // mirror the Direct-path normalization (tools.rs ~860).
346    let payload = match &params.payload {
347        serde_json::Value::String(s) => {
348            serde_json::from_str::<serde_json::Value>(s).unwrap_or_else(|_| params.payload.clone())
349        }
350        other => other.clone(),
351    };
352
353    let priority = parse_protocol_priority(&params.priority);
354
355    if let Some(stripped) = params.key.strip_prefix("gotcha:") {
356        if stripped.is_empty() {
357            return Err("gotcha key must not be just the prefix".into());
358        }
359        let rule = field_string(&payload, "rule")
360            .ok_or_else(|| "gotcha payload requires non-empty 'rule'".to_string())?;
361        let reason = field_string(&payload, "reason")
362            .ok_or_else(|| "gotcha payload requires non-empty 'reason'".to_string())?;
363        let severity = parse_protocol_severity(payload.get("severity").and_then(|v| v.as_str()));
364        let affected_files = field_string_list(&payload, "affected_files");
365        let ref_url = payload
366            .get("ref_url")
367            .and_then(|v| v.as_str())
368            .map(|s| s.to_string());
369
370        return Ok(Command::GotchaUpsert(GotchaDraftInput {
371            key: params.key.clone(),
372            rule,
373            reason,
374            severity,
375            affected_files,
376            ref_url,
377            tags: params.tags.clone(),
378            priority,
379            source: None,
380        }));
381    }
382
383    if let Some(slug) = params.key.strip_prefix("decision:") {
384        if slug.is_empty() {
385            return Err("decision key must not be just the prefix".into());
386        }
387        let summary = field_string(&payload, "summary")
388            .ok_or_else(|| "decision payload requires non-empty 'summary'".to_string())?;
389        let rationale = field_string(&payload, "rationale")
390            .ok_or_else(|| "decision payload requires non-empty 'rationale'".to_string())?;
391        return Ok(Command::DecisionUpsert(DecisionUpsertInput {
392            slug: slug.to_string(),
393            value: params.value.clone(),
394            summary,
395            rationale,
396            tags: params.tags.clone(),
397            priority,
398        }));
399    }
400
401    if let Some(stripped) = params.key.strip_prefix("dev_note:") {
402        if stripped.is_empty() {
403            return Err("dev_note key must not be just the prefix".into());
404        }
405        if params.value.is_empty() {
406            return Err("dev_note requires non-empty value".into());
407        }
408        return Ok(Command::DevNoteUpsert(DevNoteUpsertInput {
409            key: Some(params.key.clone()),
410            text: params.value.clone(),
411            tags: params.tags.clone(),
412            priority,
413        }));
414    }
415
416    Err("mem_set write requires key with gotcha:/decision:/dev_note: prefix".into())
417}
418
419fn field_string(payload: &serde_json::Value, field: &str) -> Option<String> {
420    payload
421        .get(field)
422        .and_then(|v| v.as_str())
423        .map(|s| s.to_string())
424        .filter(|s| !s.is_empty())
425}
426
427fn field_string_list(payload: &serde_json::Value, field: &str) -> Vec<String> {
428    payload
429        .get(field)
430        .and_then(|v| v.as_array())
431        .map(|arr| {
432            arr.iter()
433                .filter_map(|v| v.as_str().map(|s| s.to_string()))
434                .collect()
435        })
436        .unwrap_or_default()
437}
438
439fn parse_protocol_priority(s: &str) -> proto::Priority {
440    match s {
441        "Critical" | "critical" => proto::Priority::Critical,
442        "High" | "high" => proto::Priority::High,
443        "Low" | "low" => proto::Priority::Low,
444        _ => proto::Priority::Normal,
445    }
446}
447
448fn parse_protocol_severity(s: Option<&str>) -> proto::Severity {
449    match s.map(|s| s.to_ascii_lowercase()).as_deref() {
450        Some("critical") => proto::Severity::Critical,
451        Some("high") => proto::Severity::High,
452        Some("low") => proto::Severity::Low,
453        _ => proto::Severity::Normal,
454    }
455}
456
457// ── mem_set action helpers ──────────────────────────────────────────────────
458//
459// γ-C1.85: the `mem_set_confirm` / `try_confirm_once` / `finalize_confirm` /
460// `mem_set_delete` helpers that lived here are now free functions inside
461// `super::handlers` (apply_mem_set_*). `MatiServer::mem_set` delegates to
462// `handlers::handle_mem_set` so v1 (rmcp wrapper) and v2 (Socket → typed
463// Commands) cannot diverge. The bodies are byte-for-byte identical to the
464// originals — only the `&self` parameter was dropped.
465
466/// Returns true if a gotcha record is eligible for injection into a context packet.
467///
468/// Two classes of gotchas surface in bootstrap:
469///
470/// 1. **Developer-confirmed gotchas** (`payload.confirmed = true`) — these are
471///    intentional captures and always inject when quality is acceptable.
472/// 2. **Auto-derived Layer 0 stubs with intrinsic signal value** —
473///    `gotcha:cochange:*`, `gotcha:revert:*`, `gotcha:ownership:*` records
474///    are minted by `mati init` from git history. They are NOT
475///    developer-confirmed (so they never trigger hook enforcement / file-read
476///    DENY), but their content is high-signal information the agent should
477///    see in bootstrap. Pre-fix these were marked `confirmed=true` at init
478///    which violated the "confirmed ⇒ developer-authoritative ⇒ confidence
479///    ≥ 0.80" schema invariant. Now we keep them `confirmed=false` and
480///    explicitly allowlist them here so bootstrap still surfaces them.
481fn is_injectable_gotcha(r: &Record) -> bool {
482    if !matches!(r.lifecycle, RecordLifecycle::Active) {
483        return false;
484    }
485    if r.staleness.tier == StalenessTier::Tombstone {
486        return false;
487    }
488    if r.quality.value < 0.4 {
489        return false;
490    }
491    if let Some(gotcha) = r.payload_as::<GotchaRecord>() {
492        if gotcha.confirmed {
493            return true;
494        }
495    }
496    // Auto-derived Layer 0 stubs: include advisory signals even unconfirmed.
497    r.key.starts_with("gotcha:cochange:")
498        || r.key.starts_with("gotcha:revert:")
499        || r.key.starts_with("gotcha:ownership:")
500}
501
502/// Resolve the existing record for a mem_set write path.
503///
504/// Returns `Ok(Option<Record>)` on success, or an error JSON string
505/// if the store read failed — callers must abort the write.
506/// Extracted for testability: the `Err` branch was previously untestable
507/// because it required a real store failure.
508pub(crate) fn resolve_existing_for_write(
509    store_result: anyhow::Result<Option<Record>>,
510) -> Result<Option<Record>, String> {
511    match store_result {
512        Ok(record) => Ok(record),
513        Err(e) => Err(serde_json::json!({
514            "error": format!("store read failed \u{2014} refusing to write: {e}")
515        })
516        .to_string()),
517    }
518}
519
520/// Assemble a [`ContextPacket`] from the store and graph.
521///
522/// Steps:
523/// 1. Fetch `stage:current`
524/// 2. Collect confirmed gotchas (deferred until after step 3):
525///    - For non-empty `context_files`: fetch only linked gotchas by key
526///    - For empty `context_files` (global bootstrap): scan all `gotcha:*`
527/// 3. For each context_file: get FileRecord, traverse HasGotcha (1-hop),
528///    traverse Imports→HasGotcha (2-hop), traverse AffectedBy for decisions
529/// 4. Dedup + sort gotchas by confidence × priority_weight
530/// 5. Quality filter: exclude Suppressed, caveat Poor
531/// 6. Build markdown injection string within 2,000-token budget
532/// 7. Append Vector B suffix
533pub async fn assemble_context_packet(
534    store: &crate::store::Store,
535    graph: &Graph,
536    context_files: &[String],
537) -> anyhow::Result<ContextPacket> {
538    // 1. Stage
539    let stage = store.get("stage:current").await?;
540
541    // 2. Gotcha collection — deferred until after context-file traversal
542    //    so we can optimize non-empty context_files to fetch only linked gotchas.
543
544    // 3. Context-file traversal
545    let mut file_records = Vec::new();
546    let mut context_gotcha_keys = HashSet::new();
547    let mut decision_keys = HashSet::new();
548    // Collect nudge candidates during this pass to avoid N+1 re-lookups later.
549    let mut unconfirmed_candidates = Vec::new();
550    // M-13-B: collect stale warnings
551    let mut stale_warnings: Vec<String> = Vec::new();
552    let mut seen_stale_keys: HashSet<String> = HashSet::new();
553
554    for file_path in context_files {
555        let file_key = if file_path.starts_with("file:") {
556            file_path.clone()
557        } else {
558            format!("file:{file_path}")
559        };
560
561        // Get file record first to check lifecycle/staleness before traversal
562        if let Ok(Some(record)) = store.get(&file_key).await {
563            // M-13-B: exclude tombstone files from traversal entirely
564            if record.staleness.tier == StalenessTier::Tombstone
565                || !matches!(record.lifecycle, RecordLifecycle::Active)
566            {
567                continue;
568            }
569
570            // M-13-B: stale/liability file records generate warnings
571            match record.staleness.tier {
572                StalenessTier::Stale => {
573                    let path = file_key.strip_prefix("file:").unwrap_or(&file_key);
574                    if seen_stale_keys.insert(file_key.clone()) {
575                        stale_warnings.push(format!(
576                            "`{path}` record is stale (staleness {:.2}) — verify before trusting",
577                            record.staleness.value
578                        ));
579                    }
580                }
581                StalenessTier::Liability => {
582                    let path = file_key.strip_prefix("file:").unwrap_or(&file_key);
583                    if seen_stale_keys.insert(file_key.clone()) {
584                        stale_warnings.push(format!(
585                            "`{path}` record is a liability (staleness {:.2}) — do not trust, read the file",
586                            record.staleness.value
587                        ));
588                    }
589                }
590                _ => {}
591            }
592
593            if let Some(fr) = record.payload_as::<FileRecord>() {
594                // Supplement graph traversal with the record-level gotcha_keys list.
595                // FileRecord.gotcha_keys is the authoritative persistent source; the
596                // in-memory graph edges are a cache that can lag after CLI gotcha writes
597                // (apply_gotcha_write persists to disk but historically skipped the
598                // in-memory graph update). Including these keys here ensures bootstrap
599                // surfaces confirmed gotchas even when graph edges are stale or missing.
600                for key in &fr.gotcha_keys {
601                    context_gotcha_keys.insert(key.clone());
602                }
603                // Nudge detection: hot file (access_count >= 3) with no gotchas
604                let is_nudge_candidate = record.access_count >= 3 && fr.gotcha_keys.is_empty();
605                file_records.push(fr);
606                if is_nudge_candidate {
607                    unconfirmed_candidates.push(file_key.clone());
608                }
609            }
610        }
611
612        // 1-hop: direct gotchas
613        for key in graph.neighbors(&file_key, &EdgeKind::HasGotcha) {
614            context_gotcha_keys.insert(key);
615        }
616
617        // 2-hop: imports → their gotchas
618        for imported in graph.neighbors(&file_key, &EdgeKind::Imports) {
619            for key in graph.neighbors(&imported, &EdgeKind::HasGotcha) {
620                context_gotcha_keys.insert(key);
621            }
622        }
623
624        // Decisions via AffectedBy
625        for key in graph.neighbors(&file_key, &EdgeKind::AffectedBy) {
626            decision_keys.insert(key);
627        }
628    }
629
630    // 2. (deferred) Collect confirmed gotchas — scope depends on context_files.
631    let mut confirmed_gotchas: Vec<Record> = if context_files.is_empty() {
632        // Global bootstrap: scan all gotchas (no context filter).
633        let all_gotchas = store.scan_prefix("gotcha:").await?;
634        all_gotchas
635            .into_iter()
636            .filter(is_injectable_gotcha)
637            .collect()
638    } else {
639        // Targeted bootstrap: fetch only gotchas linked to context files.
640        let mut gotchas = Vec::with_capacity(context_gotcha_keys.len());
641        for key in &context_gotcha_keys {
642            if let Ok(Some(record)) = store.get(key).await {
643                if is_injectable_gotcha(&record) {
644                    gotchas.push(record);
645                }
646            }
647        }
648        gotchas
649    };
650
651    // M-13-B: surface stale reviews from last 2 days
652    {
653        let now = chrono::Utc::now();
654        for days_ago in 0..2 {
655            let date = (now - chrono::Duration::days(days_ago)).format("%Y-%m-%d");
656            let review_key = format!("analytics:stale_review_{date}");
657            if let Ok(Some(record)) = store.get(&review_key).await {
658                if let Some(payload) = record.payload_as::<StaleReviewPayload>() {
659                    for entry in &payload.entries {
660                        if seen_stale_keys.insert(entry.key.clone()) {
661                            let path = entry.key.strip_prefix("file:").unwrap_or(&entry.key);
662                            stale_warnings.push(format!(
663                                "`{path}` staleness {:.2} ({:?}) — review recommended",
664                                entry.staleness_value, entry.tier
665                            ));
666                        }
667                    }
668                }
669            }
670        }
671    }
672
673    // Fetch decision records — graph-linked first, fallback to scan
674    let mut related_decisions = Vec::new();
675    for key in &decision_keys {
676        if let Ok(Some(record)) = store.get(key).await {
677            related_decisions.push(record);
678        }
679    }
680    // Fallback: when graph traversal found no decisions, scan decision:*
681    // prefix so decisions always surface in bootstrap when they exist.
682    if related_decisions.is_empty() {
683        if let Ok(mut all_decisions) = store.scan_prefix("decision:").await {
684            all_decisions.retain(|r| matches!(r.lifecycle, RecordLifecycle::Active));
685            all_decisions.sort_by(|a, b| {
686                b.confidence
687                    .value
688                    .partial_cmp(&a.confidence.value)
689                    .unwrap_or(std::cmp::Ordering::Equal)
690            });
691            const DECISION_FALLBACK_LIMIT: usize = 5;
692            related_decisions = all_decisions
693                .into_iter()
694                .take(DECISION_FALLBACK_LIMIT)
695                .collect();
696        }
697    }
698
699    // 4. Sort gotchas by confidence × priority_weight (descending)
700    confirmed_gotchas.sort_by(|a, b| {
701        let score_a = a.confidence.value * priority_weight(&a.priority);
702        let score_b = b.confidence.value * priority_weight(&b.priority);
703        score_b
704            .partial_cmp(&score_a)
705            .unwrap_or(std::cmp::Ordering::Equal)
706    });
707
708    // 5. Quality filter: exclude Suppressed (<0.2), caveat Poor (0.2–0.4)
709    //    Context scoping is already handled in step 2: targeted fetch for non-empty
710    //    context_files, full scan for global bootstrap.
711    let critical_gotchas: Vec<Record> = confirmed_gotchas
712        .into_iter()
713        .filter(|r| r.quality.tier != QualityTier::Suppressed)
714        .collect();
715
716    // 6. Build markdown injection string within token budget
717    let available_tokens = TOKEN_BUDGET - VECTOR_B_TOKENS;
718    let mut sections = Vec::new();
719    let mut used_tokens = 0;
720
721    // Stage section
722    if let Some(ref stage_record) = stage {
723        let section = format!("## Current Stage\n{}\n", stage_record.value);
724        let tokens = estimate_tokens(&section);
725        if used_tokens + tokens <= available_tokens {
726            sections.push(section);
727            used_tokens += tokens;
728        }
729    }
730
731    // Gotchas section — separate co-change gotchas (grouped) from regular gotchas (individual)
732    if !critical_gotchas.is_empty() {
733        let mut gotcha_section = String::from("## Gotchas\n");
734
735        // Regular gotchas (non-co-change) — listed individually
736        for record in &critical_gotchas {
737            if record.key.starts_with("gotcha:cochange:") {
738                continue;
739            }
740            let caveat = if record.staleness.tier == StalenessTier::Liability {
741                " [STALE — verify]"
742            } else if record.quality.tier == QualityTier::Poor {
743                " [LOW QUALITY — verify]"
744            } else {
745                ""
746            };
747            let line = format!("- **{}**{}: {}\n", record.key, caveat, record.value);
748            let tokens = estimate_tokens(&line);
749            if used_tokens + tokens > available_tokens {
750                break;
751            }
752            gotcha_section.push_str(&line);
753            used_tokens += tokens;
754        }
755
756        // Co-change gotchas — grouped by source file into one-liners
757        let mut cochange_map: std::collections::BTreeMap<String, Vec<(String, String)>> =
758            std::collections::BTreeMap::new();
759        for record in &critical_gotchas {
760            if !record.key.starts_with("gotcha:cochange:") {
761                continue;
762            }
763            // key format: gotcha:cochange:file_a|file_b
764            if let Some(pair) = record.key.strip_prefix("gotcha:cochange:") {
765                if let Some((src, tgt)) = pair.split_once('|') {
766                    // Extract percentage: "... (78%)." → "78%"
767                    // Robust: find last '(' then take until '%' or ')'
768                    let pct = record
769                        .value
770                        .rfind('(')
771                        .and_then(|i| {
772                            record.value[i + 1..]
773                                .find(')')
774                                .map(|j| &record.value[i + 1..i + 1 + j])
775                        })
776                        .unwrap_or("?");
777                    cochange_map
778                        .entry(src.to_string())
779                        .or_default()
780                        .push((tgt.to_string(), pct.to_string()));
781                }
782            }
783        }
784        if !cochange_map.is_empty() {
785            let all_pairs: Vec<String> = cochange_map
786                .iter()
787                .flat_map(|(src, targets)| {
788                    targets
789                        .iter()
790                        .map(move |(tgt, pct)| format!("{src}\u{2194}{tgt} ({pct})"))
791                })
792                .collect();
793            let total = all_pairs.len();
794            // Show up to 10 pairs, truncate with count
795            let display: Vec<&str> = all_pairs.iter().take(10).map(|s| s.as_str()).collect();
796            let suffix = if total > 10 {
797                format!(", +{} more", total - 10)
798            } else {
799                String::new()
800            };
801            let line = format!("- **Co-change partners**: {}{suffix}\n", display.join(", "));
802            let tokens = estimate_tokens(&line);
803            if used_tokens + tokens <= available_tokens {
804                gotcha_section.push_str(&line);
805                used_tokens += tokens;
806            }
807        }
808
809        if gotcha_section.len() > "## Gotchas\n".len() {
810            sections.push(gotcha_section);
811        }
812    }
813
814    // File records section
815    if !file_records.is_empty() {
816        let mut file_section = String::from("## Context Files\n");
817        for fr in &file_records {
818            if fr.purpose.is_empty() {
819                continue;
820            }
821            let line = format!("- **{}**: {}\n", fr.path, fr.purpose);
822            let tokens = estimate_tokens(&line);
823            if used_tokens + tokens > available_tokens {
824                break;
825            }
826            file_section.push_str(&line);
827            used_tokens += tokens;
828        }
829        if file_section.len() > "## Context Files\n".len() {
830            sections.push(file_section);
831        }
832    }
833
834    // Highest-impact files in context — sorted by blast radius score descending.
835    // Only shown when at least one file has a non-isolated blast radius.
836    {
837        use crate::analysis::blast_radius::BlastTier;
838        let mut impact_files: Vec<(&FileRecord, f32)> = file_records
839            .iter()
840            .filter_map(|fr| {
841                fr.blast_radius.as_ref().and_then(|br| {
842                    if br.tier == BlastTier::Isolated {
843                        None
844                    } else {
845                        Some((fr, br.score))
846                    }
847                })
848            })
849            .collect();
850        impact_files.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
851
852        if !impact_files.is_empty() {
853            let mut impact_section = String::from("## Highest Impact Files\n");
854            for (fr, _score) in impact_files.iter().take(3) {
855                let br = fr
856                    .blast_radius
857                    .as_ref()
858                    .expect("filter_map above kept only files with Some(blast_radius)");
859                let line = format!(
860                    "- `{}`: {} direct importers ({})\n",
861                    fr.path,
862                    br.direct,
863                    br.tier.label(),
864                );
865                let tokens = estimate_tokens(&line);
866                if used_tokens + tokens > available_tokens {
867                    break;
868                }
869                impact_section.push_str(&line);
870                used_tokens += tokens;
871            }
872            if impact_section.len() > "## Highest Impact Files\n".len() {
873                sections.push(impact_section);
874            }
875        }
876    }
877
878    // M-13-B: Stale Warnings section — BEFORE Decisions
879    if !stale_warnings.is_empty() {
880        let mut stale_section = String::from("## Stale Warnings\n");
881        for warning in &stale_warnings {
882            let line = format!("- {warning}\n");
883            let tokens = estimate_tokens(&line);
884            if used_tokens + tokens > available_tokens {
885                break;
886            }
887            stale_section.push_str(&line);
888            used_tokens += tokens;
889        }
890        if stale_section.len() > "## Stale Warnings\n".len() {
891            sections.push(stale_section);
892        }
893    }
894
895    // Decisions section
896    if !related_decisions.is_empty() {
897        let mut dec_section = String::from("## Decisions\n");
898        for record in &related_decisions {
899            let line = format!("- **{}**: {}\n", record.key, record.value);
900            let tokens = estimate_tokens(&line);
901            if used_tokens + tokens > available_tokens {
902                break;
903            }
904            dec_section.push_str(&line);
905            used_tokens += tokens;
906        }
907        if dec_section.len() > "## Decisions\n".len() {
908            sections.push(dec_section);
909        }
910    }
911
912    // M-12-E: Passive nudge — detect hot files with no gotchas.
913    // NOTE: This is a deliberate exception to P2 ("inject nothing by default").
914    // Nudges are advisory suggestions, not knowledge injection, and are only
915    // emitted when token budget allows after all knowledge sections.
916    // unconfirmed_candidates were collected during the context-file traversal
917    // above (step 3), so no additional store lookups are needed here.
918    if !unconfirmed_candidates.is_empty() {
919        let mut nudge_section = String::from("## Suggested Actions\n");
920        for key in &unconfirmed_candidates {
921            let path = key.strip_prefix("file:").unwrap_or(key);
922            let line = format!(
923                "- `{path}` is read frequently but has no recorded gotchas. The developer may want to run `mati gotcha add {path}`.\n"
924            );
925            let tokens = estimate_tokens(&line);
926            if used_tokens + tokens > available_tokens {
927                break;
928            }
929            nudge_section.push_str(&line);
930            used_tokens += tokens;
931        }
932        if nudge_section.len() > "## Suggested Actions\n".len() {
933            sections.push(nudge_section);
934        }
935    }
936
937    let mut injection_string = sections.join("\n");
938    injection_string.push_str(VECTOR_B);
939
940    let token_estimate = estimate_tokens(&injection_string) as u32;
941
942    Ok(ContextPacket {
943        stage,
944        critical_gotchas,
945        file_records,
946        related_decisions,
947        recent_session: None,
948        token_estimate,
949        stale_warnings,
950        unconfirmed_candidates,
951        knowledge_gaps: vec![],
952        compliance_rate: None,
953        injection_string,
954    })
955}
956
957// ── Tests ────────────────────────────────────────────────────────────────────
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use crate::store::record::*;
963    use crate::store::Store;
964    use tempfile::TempDir;
965
966    fn device_id() -> uuid::Uuid {
967        uuid::Uuid::nil()
968    }
969
970    fn now() -> u64 {
971        1_700_000_000
972    }
973
974    fn make_record(key: &str, value: &str, category: Category, quality_value: f32) -> Record {
975        Record {
976            key: key.to_string(),
977            value: value.to_string(),
978            category,
979            priority: Priority::Normal,
980            tags: vec![],
981            created_at: now(),
982            updated_at: now(),
983            ref_url: None,
984            staleness: StalenessScore::fresh(),
985            lifecycle: RecordLifecycle::Active,
986            version: RecordVersion {
987                device_id: device_id(),
988                logical_clock: 1,
989                wall_clock: now(),
990            },
991            quality: QualityScore {
992                value: quality_value,
993                tier: QualityScore::tier_from_value(quality_value),
994                signals: vec![],
995                computed_at: now(),
996            },
997            access_count: 0,
998            last_accessed: 0,
999            source: RecordSource::DeveloperManual,
1000            confidence: ConfidenceScore {
1001                value: 0.8,
1002                confirmation_count: 1,
1003                contributor_count: 1,
1004                last_challenged: None,
1005                challenge_count: 0,
1006            },
1007            gap_analysis_score: 0.0,
1008            payload: Some(serde_json::json!({})),
1009        }
1010    }
1011
1012    fn make_gotcha_record(key: &str, rule: &str, confirmed: bool, quality_value: f32) -> Record {
1013        let gotcha = GotchaRecord {
1014            rule: rule.to_string(),
1015            reason: "test reason".to_string(),
1016            severity: Priority::High,
1017            affected_files: vec![],
1018            ref_url: None,
1019            discovered_session: now(),
1020            confirmed,
1021        };
1022        let mut record = make_record(key, rule, Category::Gotcha, quality_value);
1023        record.payload = serde_json::to_value(&gotcha).ok();
1024        record
1025    }
1026
1027    // ── γ-C3a helpers: handler-level test entry points ───────────────────────
1028    //
1029    // γ-C4 removed `MatiBackend::Direct`. These helpers replaced the
1030    // pre-γ pattern `MatiServer::new(graph).mem_*(Parameters(...)).await`.
1031    // They drive the canonical daemon-side handlers (mcp::handlers::*)
1032    // directly, returning a `String` so existing test assertions
1033    // (`result.contains(...)`, `assert_eq!(result, "null")`) keep working.
1034
1035    fn handler_test_ctx() -> crate::mcp::dispatch_v2::RequestContext {
1036        crate::mcp::dispatch_v2::RequestContext {
1037            peer: crate::mcp::metadata::PeerContext {
1038                uid: 501,
1039                pid: Some(99999),
1040            },
1041            daemon_session: uuid::Uuid::nil(),
1042            repo_root: std::path::PathBuf::new(),
1043        }
1044    }
1045
1046    async fn call_mem_get(
1047        graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1048        key: &str,
1049    ) -> String {
1050        let ctx = handler_test_ctx();
1051        let input = crate::mcp::protocol::MemGetInput {
1052            key: key.to_string(),
1053        };
1054        let g = graph_arc.read().await;
1055        match crate::mcp::handlers::handle_mem_get(
1056            g.store(),
1057            graph_arc,
1058            &ctx,
1059            uuid::Uuid::new_v4(),
1060            &input,
1061        )
1062        .await
1063        {
1064            Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()),
1065            Err((_code, msg)) => format!("{{\"error\": \"{}\"}}", msg.replace('"', "\\\"")),
1066        }
1067    }
1068
1069    async fn call_mem_query(
1070        graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1071        query: &str,
1072        mode: crate::mcp::protocol::QueryMode,
1073        limit: u32,
1074    ) -> String {
1075        let input = crate::mcp::protocol::MemQueryInput {
1076            query: query.to_string(),
1077            mode,
1078            limit,
1079        };
1080        let g = graph_arc.read().await;
1081        match crate::mcp::handlers::handle_mem_query(g.store(), &g, &input).await {
1082            Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()),
1083            Err((_code, msg)) => format!("{{\"error\": \"{}\"}}", msg.replace('"', "\\\"")),
1084        }
1085    }
1086
1087    async fn call_mem_bootstrap(
1088        graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1089        context_files: Vec<String>,
1090    ) -> String {
1091        let ctx = handler_test_ctx();
1092        let input = crate::mcp::protocol::MemBootstrapInput { context_files };
1093        let g = graph_arc.read().await;
1094        match crate::mcp::handlers::handle_mem_bootstrap(
1095            g.store(),
1096            &g,
1097            graph_arc,
1098            &ctx,
1099            uuid::Uuid::new_v4(),
1100            &input,
1101        )
1102        .await
1103        {
1104            Ok(s) => s,
1105            Err((_code, msg)) => format!("[mati] bootstrap error: {msg}"),
1106        }
1107    }
1108
1109    async fn call_mem_set(
1110        graph_arc: &std::sync::Arc<tokio::sync::RwLock<crate::graph::Graph>>,
1111        params: crate::mcp::types::MemSetParams,
1112    ) -> String {
1113        let ctx = handler_test_ctx();
1114        crate::mcp::handlers::handle_mem_set(graph_arc, &ctx, uuid::Uuid::new_v4(), &params).await
1115    }
1116
1117    // ── mem_get tests ────────────────────────────────────────────────────────
1118
1119    #[tokio::test]
1120    async fn mem_get_returns_null_for_nonexistent_key() {
1121        let dir = TempDir::new().unwrap();
1122        let store = Store::open(dir.path()).await.unwrap();
1123        let graph = Graph::load(store).await.unwrap();
1124        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1125
1126        let result = call_mem_get(&graph_arc, "file:nonexistent.rs").await;
1127        assert_eq!(result, "null");
1128    }
1129
1130    #[tokio::test]
1131    async fn mem_get_returns_record_for_existing_key() {
1132        let dir = TempDir::new().unwrap();
1133        let store = Store::open(dir.path()).await.unwrap();
1134        let record = make_record("gotcha:test", "test value", Category::Gotcha, 0.8);
1135        store.put("gotcha:test", &record).await.unwrap();
1136
1137        let graph = Graph::load(store).await.unwrap();
1138        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1139
1140        let result = call_mem_get(&graph_arc, "gotcha:test").await;
1141        assert!(result.contains("gotcha:test"));
1142        assert!(result.contains("test value"));
1143    }
1144
1145    #[tokio::test]
1146    async fn mem_get_blast_radius_warning_for_critical_file() {
1147        let dir = TempDir::new().unwrap();
1148        let store = Store::open(dir.path()).await.unwrap();
1149
1150        let fr = FileRecord {
1151            path: "src/core.rs".to_string(),
1152            purpose: "Core module".to_string(),
1153            entry_points: vec![],
1154            imports: vec![],
1155            gotcha_keys: vec![],
1156            decision_keys: vec![],
1157            todos: vec![],
1158            unsafe_count: 0,
1159            unwrap_count: 0,
1160            change_frequency: 0,
1161            last_author: None,
1162            is_hotspot: false,
1163            token_cost_estimate: 100,
1164            last_modified_session: 0,
1165            content_hash: None,
1166            line_count: 0,
1167            blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1168                direct: 45,
1169                transitive: 10,
1170                score: 48.0,
1171                tier: crate::analysis::blast_radius::BlastTier::Critical,
1172            }),
1173            propagated_staleness: None,
1174        };
1175        let mut record = make_record("file:src/core.rs", "Core module", Category::File, 0.5);
1176        record.payload = serde_json::to_value(&fr).ok();
1177        store.put("file:src/core.rs", &record).await.unwrap();
1178
1179        let graph = Graph::load(store).await.unwrap();
1180        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1181
1182        let result = call_mem_get(&graph_arc, "file:src/core.rs").await;
1183
1184        assert!(
1185            result.contains("HIGH IMPACT FILE"),
1186            "response must contain blast radius warning for critical file, got: {result}"
1187        );
1188        assert!(result.contains("45"), "warning must include direct count");
1189    }
1190
1191    #[tokio::test]
1192    async fn mem_get_no_blast_warning_for_low_file() {
1193        let dir = TempDir::new().unwrap();
1194        let store = Store::open(dir.path()).await.unwrap();
1195
1196        let fr = FileRecord {
1197            path: "src/leaf.rs".to_string(),
1198            purpose: "Leaf module".to_string(),
1199            entry_points: vec![],
1200            imports: vec![],
1201            gotcha_keys: vec![],
1202            decision_keys: vec![],
1203            todos: vec![],
1204            unsafe_count: 0,
1205            unwrap_count: 0,
1206            change_frequency: 0,
1207            last_author: None,
1208            is_hotspot: false,
1209            token_cost_estimate: 100,
1210            last_modified_session: 0,
1211            content_hash: None,
1212            line_count: 0,
1213            blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1214                direct: 2,
1215                transitive: 0,
1216                score: 2.0,
1217                tier: crate::analysis::blast_radius::BlastTier::Low,
1218            }),
1219            propagated_staleness: None,
1220        };
1221        let mut record = make_record("file:src/leaf.rs", "Leaf module", Category::File, 0.5);
1222        record.payload = serde_json::to_value(&fr).ok();
1223        store.put("file:src/leaf.rs", &record).await.unwrap();
1224
1225        let graph = Graph::load(store).await.unwrap();
1226        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1227
1228        let result = call_mem_get(&graph_arc, "file:src/leaf.rs").await;
1229
1230        assert!(
1231            !result.contains("HIGH IMPACT FILE"),
1232            "low blast radius file should NOT have warning"
1233        );
1234    }
1235
1236    // ── mem_get enrichment_depth_hint (D2-α) ─────────────────────────────────
1237
1238    #[tokio::test]
1239    async fn mem_get_includes_depth_hint_for_file_records() {
1240        let dir = TempDir::new().unwrap();
1241        let store = Store::open(dir.path()).await.unwrap();
1242
1243        // 50 LoC, Isolated blast, no cluster, no gotchas → score 0 → Fast
1244        let fr = FileRecord {
1245            path: "src/tiny.rs".to_string(),
1246            purpose: "Tiny leaf module".to_string(),
1247            entry_points: vec![],
1248            imports: vec![],
1249            gotcha_keys: vec![],
1250            decision_keys: vec![],
1251            todos: vec![],
1252            unsafe_count: 0,
1253            unwrap_count: 0,
1254            change_frequency: 0,
1255            last_author: None,
1256            is_hotspot: false,
1257            token_cost_estimate: 100,
1258            last_modified_session: 0,
1259            content_hash: None,
1260            line_count: 50,
1261            blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1262                direct: 0,
1263                transitive: 0,
1264                score: 0.0,
1265                tier: crate::analysis::blast_radius::BlastTier::Isolated,
1266            }),
1267            propagated_staleness: None,
1268        };
1269        let mut record = make_record("file:src/tiny.rs", "Tiny leaf", Category::File, 0.5);
1270        record.payload = serde_json::to_value(&fr).ok();
1271        store.put("file:src/tiny.rs", &record).await.unwrap();
1272
1273        let graph = Graph::load(store).await.unwrap();
1274        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1275
1276        let result = call_mem_get(&graph_arc, "file:src/tiny.rs").await;
1277        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1278        assert_eq!(
1279            parsed.get("enrichment_depth_hint").and_then(|v| v.as_str()),
1280            Some("fast"),
1281            "tiny isolated file should hint Fast tier; got: {result}"
1282        );
1283    }
1284
1285    #[tokio::test]
1286    async fn mem_get_depth_hint_for_hotspot_is_deep() {
1287        let dir = TempDir::new().unwrap();
1288        let store = Store::open(dir.path()).await.unwrap();
1289
1290        // 500 LoC (+3) + High blast (+2) = 5 → Deep
1291        let fr = FileRecord {
1292            path: "src/core.rs".to_string(),
1293            purpose: "Core hotspot".to_string(),
1294            entry_points: vec![],
1295            imports: vec![],
1296            gotcha_keys: vec![],
1297            decision_keys: vec![],
1298            todos: vec![],
1299            unsafe_count: 0,
1300            unwrap_count: 0,
1301            change_frequency: 0,
1302            last_author: None,
1303            is_hotspot: true,
1304            token_cost_estimate: 5000,
1305            last_modified_session: 0,
1306            content_hash: None,
1307            line_count: 500,
1308            blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
1309                direct: 20,
1310                transitive: 30,
1311                score: 35.0,
1312                tier: crate::analysis::blast_radius::BlastTier::High,
1313            }),
1314            propagated_staleness: None,
1315        };
1316        let mut record = make_record("file:src/core.rs", "Core hotspot", Category::File, 0.5);
1317        record.payload = serde_json::to_value(&fr).ok();
1318        store.put("file:src/core.rs", &record).await.unwrap();
1319
1320        let graph = Graph::load(store).await.unwrap();
1321        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1322
1323        let result = call_mem_get(&graph_arc, "file:src/core.rs").await;
1324        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1325        assert_eq!(
1326            parsed.get("enrichment_depth_hint").and_then(|v| v.as_str()),
1327            Some("deep"),
1328            "large hotspot file should hint Deep tier; got: {result}"
1329        );
1330    }
1331
1332    #[tokio::test]
1333    async fn mem_get_omits_depth_hint_for_non_file_records() {
1334        let dir = TempDir::new().unwrap();
1335        let store = Store::open(dir.path()).await.unwrap();
1336
1337        let record = make_record("gotcha:foo", "rule", Category::Gotcha, 0.6);
1338        store.put("gotcha:foo", &record).await.unwrap();
1339
1340        let graph = Graph::load(store).await.unwrap();
1341        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1342
1343        let result = call_mem_get(&graph_arc, "gotcha:foo").await;
1344        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1345        // depth_hint is scoped to file: keys; gotcha responses must NOT carry it.
1346        assert!(
1347            parsed.get("enrichment_depth_hint").is_none(),
1348            "non-file records should not carry enrichment_depth_hint; got: {result}"
1349        );
1350    }
1351
1352    // ── mem_query tests ──────────────────────────────────────────────────────
1353
1354    #[tokio::test]
1355    async fn mem_query_text_mode_returns_results() {
1356        let dir = TempDir::new().unwrap();
1357        let store = Store::open(dir.path()).await.unwrap();
1358        let record = make_record(
1359            "gotcha:async-race",
1360            "never use inference in async context",
1361            Category::Gotcha,
1362            0.8,
1363        );
1364        store.put("gotcha:async-race", &record).await.unwrap();
1365
1366        let graph = Graph::load(store).await.unwrap();
1367        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1368
1369        let result = call_mem_query(
1370            &graph_arc,
1371            "inference",
1372            crate::mcp::protocol::QueryMode::Text,
1373            10,
1374        )
1375        .await;
1376        assert!(result.contains("gotcha:async-race"));
1377    }
1378
1379    // Note: γ-C3a deleted the `mem_query_unknown_mode_returns_error` test
1380    // that used to live here. The unknown-mode string-to-enum conversion no
1381    // longer happens in `tools::mem_query`'s body — after centralization,
1382    // typed `QueryMode` is the input. The validation now lives at the
1383    // protocol layer's serde Deserialize impl. Coverage moved to
1384    // `protocol::tests::query_mode_deserialize_rejects_unknown_variant`.
1385
1386    #[tokio::test]
1387    async fn mem_query_semantic_returns_feature_gate_error() {
1388        let dir = TempDir::new().unwrap();
1389        let store = Store::open(dir.path()).await.unwrap();
1390        let graph = Graph::load(store).await.unwrap();
1391        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1392
1393        let result = call_mem_query(
1394            &graph_arc,
1395            "test",
1396            crate::mcp::protocol::QueryMode::Semantic,
1397            20,
1398        )
1399        .await;
1400        assert!(
1401            result.contains("--features semantic"),
1402            "semantic mode must surface feature-gate error, got: {result}"
1403        );
1404    }
1405
1406    // ── mem_bootstrap tests ──────────────────────────────────────────────────
1407
1408    #[tokio::test]
1409    async fn mem_bootstrap_empty_store_returns_vector_b() {
1410        let dir = TempDir::new().unwrap();
1411        let store = Store::open(dir.path()).await.unwrap();
1412        let graph = Graph::load(store).await.unwrap();
1413        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
1414
1415        let result = call_mem_bootstrap(&graph_arc, vec![]).await;
1416        assert!(result.contains("[mati] Before reading any file"));
1417        assert!(result.contains("mem_get"));
1418    }
1419
1420    #[tokio::test]
1421    async fn mem_bootstrap_token_budget_never_exceeds_2000() {
1422        let dir = TempDir::new().unwrap();
1423        let store = Store::open(dir.path()).await.unwrap();
1424
1425        // Insert many gotchas to try to exceed the budget
1426        for i in 0..100 {
1427            let record = make_gotcha_record(
1428                &format!("gotcha:test-{i:03}"),
1429                &format!("This is a very long gotcha rule number {i} with lots of text to fill up the token budget and ensure we test the truncation logic properly"),
1430                true,
1431                0.8,
1432            );
1433            store.put(&record.key, &record).await.unwrap();
1434        }
1435
1436        let graph = Graph::load(store).await.unwrap();
1437
1438        let packet = assemble_context_packet(graph.store(), &graph, &[])
1439            .await
1440            .unwrap();
1441        let tokens = estimate_tokens(&packet.injection_string);
1442        assert!(
1443            tokens <= TOKEN_BUDGET,
1444            "token estimate {tokens} exceeds budget {TOKEN_BUDGET}"
1445        );
1446    }
1447
1448    #[tokio::test]
1449    async fn quality_filter_suppressed_excluded() {
1450        let dir = TempDir::new().unwrap();
1451        let store = Store::open(dir.path()).await.unwrap();
1452
1453        // Suppressed quality (< 0.2) — should be excluded
1454        let suppressed = make_gotcha_record("gotcha:suppressed", "bad rule", true, 0.10);
1455        store.put("gotcha:suppressed", &suppressed).await.unwrap();
1456
1457        // Good quality — should be included
1458        let good = make_gotcha_record("gotcha:good", "good rule", true, 0.80);
1459        store.put("gotcha:good", &good).await.unwrap();
1460
1461        let graph = Graph::load(store).await.unwrap();
1462        let packet = assemble_context_packet(graph.store(), &graph, &[])
1463            .await
1464            .unwrap();
1465
1466        assert!(
1467            !packet.injection_string.contains("gotcha:suppressed"),
1468            "suppressed gotcha must not appear in injection"
1469        );
1470    }
1471
1472    #[tokio::test]
1473    async fn quality_filter_poor_caveated() {
1474        let dir = TempDir::new().unwrap();
1475        let store = Store::open(dir.path()).await.unwrap();
1476
1477        // Poor quality (0.2–0.4) — should be caveated but included
1478        let poor = make_gotcha_record("gotcha:poor", "poor rule", true, 0.30);
1479        store.put("gotcha:poor", &poor).await.unwrap();
1480
1481        let graph = Graph::load(store).await.unwrap();
1482        let packet = assemble_context_packet(graph.store(), &graph, &[])
1483            .await
1484            .unwrap();
1485
1486        // Poor records should appear with a caveat
1487        if packet.injection_string.contains("gotcha:poor") {
1488            assert!(
1489                packet.injection_string.contains("LOW QUALITY"),
1490                "poor quality gotcha must be caveated"
1491            );
1492        }
1493    }
1494
1495    #[tokio::test]
1496    async fn assemble_context_packet_with_context_files_does_graph_traversal() {
1497        let dir = TempDir::new().unwrap();
1498        let store = Store::open(dir.path()).await.unwrap();
1499
1500        // Create a gotcha record
1501        let gotcha = make_gotcha_record("gotcha:important", "do not use unwrap", true, 0.80);
1502        store.put("gotcha:important", &gotcha).await.unwrap();
1503
1504        // Create a file record
1505        let file_record = make_record("file:src/main.rs", "{}", Category::File, 0.5);
1506        store.put("file:src/main.rs", &file_record).await.unwrap();
1507
1508        // Build graph with HasGotcha edge
1509        let mut graph = Graph::load(store).await.unwrap();
1510        graph
1511            .add_edge("file:src/main.rs", EdgeKind::HasGotcha, "gotcha:important")
1512            .await
1513            .unwrap();
1514
1515        let packet = assemble_context_packet(graph.store(), &graph, &["src/main.rs".to_string()])
1516            .await
1517            .unwrap();
1518
1519        // The gotcha should be in the context packet
1520        assert!(
1521            packet.injection_string.contains("gotcha:important")
1522                || packet
1523                    .critical_gotchas
1524                    .iter()
1525                    .any(|g| g.key == "gotcha:important"),
1526            "graph-connected gotcha must appear in context packet"
1527        );
1528    }
1529
1530    #[tokio::test]
1531    async fn assemble_context_packet_excludes_unrelated_gotchas_for_context_files() {
1532        let dir = TempDir::new().unwrap();
1533        let store = Store::open(dir.path()).await.unwrap();
1534
1535        let relevant = make_gotcha_record("gotcha:relevant", "do not use unwrap", true, 0.80);
1536        let unrelated = make_gotcha_record("gotcha:unrelated", "keep retries bounded", true, 0.80);
1537        store.put("gotcha:relevant", &relevant).await.unwrap();
1538        store.put("gotcha:unrelated", &unrelated).await.unwrap();
1539
1540        let file_record = make_record("file:src/main.rs", "{}", Category::File, 0.5);
1541        store.put("file:src/main.rs", &file_record).await.unwrap();
1542
1543        let mut graph = Graph::load(store).await.unwrap();
1544        graph
1545            .add_edge("file:src/main.rs", EdgeKind::HasGotcha, "gotcha:relevant")
1546            .await
1547            .unwrap();
1548
1549        let packet = assemble_context_packet(graph.store(), &graph, &["src/main.rs".to_string()])
1550            .await
1551            .unwrap();
1552
1553        assert!(
1554            packet
1555                .critical_gotchas
1556                .iter()
1557                .any(|g| g.key == "gotcha:relevant"),
1558            "graph-connected gotcha must remain in context packet"
1559        );
1560        assert!(
1561            !packet
1562                .critical_gotchas
1563                .iter()
1564                .any(|g| g.key == "gotcha:unrelated"),
1565            "unrelated gotcha must not be injected for scoped bootstrap"
1566        );
1567        assert!(
1568            !packet.injection_string.contains("gotcha:unrelated"),
1569            "injection string must not mention unrelated gotchas"
1570        );
1571    }
1572
1573    /// Regression test for the bootstrap low-confidence file bug.
1574    ///
1575    /// Scenario: file record has confidence 0.10 (Layer 0 stub from mati init),
1576    /// a confirmed gotcha with confidence 0.80 is linked via FileRecord.gotcha_keys,
1577    /// but NO HasGotcha graph edge exists (simulating CLI gotcha_write that wrote to
1578    /// the store but never updated the in-memory graph).
1579    ///
1580    /// Bootstrap must still surface the confirmed gotcha by falling back to
1581    /// FileRecord.gotcha_keys when graph edges are absent.
1582    #[tokio::test]
1583    async fn bootstrap_surfaces_confirmed_gotcha_when_graph_edge_missing() {
1584        let dir = TempDir::new().unwrap();
1585        let store = Store::open(dir.path()).await.unwrap();
1586
1587        // Confirmed gotcha with high confidence/quality
1588        let gotcha = make_gotcha_record(
1589            "gotcha:never-remove-rate-limit",
1590            "Never remove the rate limit check on incoming pipeline events because \
1591             removing it caused a cascade failure in staging",
1592            true,
1593            0.80,
1594        );
1595        store
1596            .put("gotcha:never-remove-rate-limit", &gotcha)
1597            .await
1598            .unwrap();
1599
1600        // File record: low-confidence stub (confidence 0.10), but gotcha_keys populated
1601        let file_record = {
1602            let fr = FileRecord {
1603                path: "src/pipeline/prefilter.rs".to_string(),
1604                purpose: String::new(), // no purpose — Layer 0 stub
1605                entry_points: vec![],
1606                imports: vec![],
1607                gotcha_keys: vec!["gotcha:never-remove-rate-limit".to_string()],
1608                decision_keys: vec![],
1609                todos: vec![],
1610                unsafe_count: 0,
1611                unwrap_count: 0,
1612                change_frequency: 18,
1613                last_author: Some("dev".to_string()),
1614                is_hotspot: true,
1615                token_cost_estimate: 0,
1616                last_modified_session: now(),
1617                content_hash: None,
1618                line_count: 0,
1619                blast_radius: None,
1620                propagated_staleness: None,
1621            };
1622            let mut r = make_record(
1623                "file:src/pipeline/prefilter.rs",
1624                "",
1625                Category::File,
1626                0.10, // low confidence — stub
1627            );
1628            r.payload = serde_json::to_value(&fr).ok();
1629            r
1630        };
1631        store
1632            .put("file:src/pipeline/prefilter.rs", &file_record)
1633            .await
1634            .unwrap();
1635
1636        // Intentionally do NOT add a HasGotcha graph edge — simulates the CLI
1637        // gotcha_write bug where the persistent store edge was written but the
1638        // in-memory graph was never updated.
1639        let graph = Graph::load(store).await.unwrap();
1640        assert_eq!(
1641            graph.neighbors("file:src/pipeline/prefilter.rs", &EdgeKind::HasGotcha),
1642            Vec::<String>::new(),
1643            "test setup: graph must have no HasGotcha edge"
1644        );
1645
1646        let packet = assemble_context_packet(
1647            graph.store(),
1648            &graph,
1649            &["src/pipeline/prefilter.rs".to_string()],
1650        )
1651        .await
1652        .unwrap();
1653
1654        assert!(
1655            packet
1656                .critical_gotchas
1657                .iter()
1658                .any(|g| g.key == "gotcha:never-remove-rate-limit"),
1659            "bootstrap must surface confirmed gotcha even when graph edge is missing"
1660        );
1661        assert!(
1662            packet
1663                .injection_string
1664                .contains("gotcha:never-remove-rate-limit"),
1665            "injection string must include the gotcha"
1666        );
1667    }
1668
1669    /// Negative case: file with confidence 0.10 and NO confirmed gotchas should
1670    /// produce minimal bootstrap output — no purpose text, no gotchas, no receipt.
1671    #[tokio::test]
1672    async fn bootstrap_low_confidence_file_with_no_gotchas_returns_minimal_packet() {
1673        let dir = TempDir::new().unwrap();
1674        let store = Store::open(dir.path()).await.unwrap();
1675
1676        let file_record = {
1677            let fr = FileRecord {
1678                path: "src/empty.rs".to_string(),
1679                purpose: String::new(),
1680                entry_points: vec![],
1681                imports: vec![],
1682                gotcha_keys: vec![],
1683                decision_keys: vec![],
1684                todos: vec![],
1685                unsafe_count: 0,
1686                unwrap_count: 0,
1687                change_frequency: 1,
1688                last_author: None,
1689                is_hotspot: false,
1690                token_cost_estimate: 0,
1691                last_modified_session: now(),
1692                content_hash: None,
1693                line_count: 0,
1694                blast_radius: None,
1695                propagated_staleness: None,
1696            };
1697            let mut r = make_record("file:src/empty.rs", "", Category::File, 0.10);
1698            r.payload = serde_json::to_value(&fr).ok();
1699            r
1700        };
1701        store.put("file:src/empty.rs", &file_record).await.unwrap();
1702
1703        let graph = Graph::load(store).await.unwrap();
1704        let packet = assemble_context_packet(graph.store(), &graph, &["src/empty.rs".to_string()])
1705            .await
1706            .unwrap();
1707
1708        assert!(
1709            packet.critical_gotchas.is_empty(),
1710            "no gotchas should be surfaced for a file with no linked gotchas"
1711        );
1712        assert!(
1713            !packet.injection_string.contains("gotcha:"),
1714            "injection string must not mention any gotcha keys"
1715        );
1716    }
1717
1718    // ── M-12-E: nudge detection ─────────────────────────────────────────────
1719
1720    #[tokio::test]
1721    async fn nudge_shown_for_hot_file_with_no_gotchas() {
1722        let dir = TempDir::new().unwrap();
1723        let store = Store::open(dir.path()).await.unwrap();
1724
1725        let fr = FileRecord {
1726            path: "src/hot.rs".to_string(),
1727            purpose: "Hot module".to_string(),
1728            entry_points: vec!["run".to_string()],
1729            imports: vec![],
1730            gotcha_keys: vec![], // no gotchas
1731            decision_keys: vec![],
1732            todos: vec![],
1733            unsafe_count: 0,
1734            unwrap_count: 0,
1735            change_frequency: 10,
1736            last_author: None,
1737            is_hotspot: true,
1738            token_cost_estimate: 100,
1739            last_modified_session: now(),
1740            content_hash: None,
1741            line_count: 0,
1742            blast_radius: None,
1743            propagated_staleness: None,
1744        };
1745        let mut file_record = make_record("file:src/hot.rs", &fr.purpose, Category::File, 0.5);
1746        file_record.payload = serde_json::to_value(&fr).ok();
1747        file_record.access_count = 5; // >= 3 threshold
1748        store.put("file:src/hot.rs", &file_record).await.unwrap();
1749
1750        let graph = Graph::load(store).await.unwrap();
1751        let packet = assemble_context_packet(graph.store(), &graph, &["src/hot.rs".to_string()])
1752            .await
1753            .unwrap();
1754
1755        assert!(
1756            packet
1757                .unconfirmed_candidates
1758                .contains(&"file:src/hot.rs".to_string()),
1759            "hot file with no gotchas should be in unconfirmed_candidates"
1760        );
1761        assert!(
1762            packet.injection_string.contains("Suggested Actions"),
1763            "nudge section should appear in injection string"
1764        );
1765        assert!(
1766            packet.injection_string.contains("mati gotcha add"),
1767            "nudge should suggest gotcha add command"
1768        );
1769    }
1770
1771    #[tokio::test]
1772    async fn no_nudge_for_file_with_low_access_count() {
1773        let dir = TempDir::new().unwrap();
1774        let store = Store::open(dir.path()).await.unwrap();
1775
1776        let fr = FileRecord {
1777            path: "src/cold.rs".to_string(),
1778            purpose: "Cold module".to_string(),
1779            entry_points: vec![],
1780            imports: vec![],
1781            gotcha_keys: vec![],
1782            decision_keys: vec![],
1783            todos: vec![],
1784            unsafe_count: 0,
1785            unwrap_count: 0,
1786            change_frequency: 0,
1787            last_author: None,
1788            is_hotspot: false,
1789            token_cost_estimate: 50,
1790            last_modified_session: now(),
1791            content_hash: None,
1792            line_count: 0,
1793            blast_radius: None,
1794            propagated_staleness: None,
1795        };
1796        let mut file_record = make_record("file:src/cold.rs", &fr.purpose, Category::File, 0.5);
1797        file_record.payload = serde_json::to_value(&fr).ok();
1798        file_record.access_count = 1; // < 3 threshold
1799        store.put("file:src/cold.rs", &file_record).await.unwrap();
1800
1801        let graph = Graph::load(store).await.unwrap();
1802        let packet = assemble_context_packet(graph.store(), &graph, &["src/cold.rs".to_string()])
1803            .await
1804            .unwrap();
1805
1806        assert!(
1807            packet.unconfirmed_candidates.is_empty(),
1808            "low-access file should not trigger nudge"
1809        );
1810    }
1811
1812    #[tokio::test]
1813    async fn no_nudge_for_file_with_gotchas() {
1814        let dir = TempDir::new().unwrap();
1815        let store = Store::open(dir.path()).await.unwrap();
1816
1817        let fr = FileRecord {
1818            path: "src/covered.rs".to_string(),
1819            purpose: "Covered module".to_string(),
1820            entry_points: vec![],
1821            imports: vec![],
1822            gotcha_keys: vec!["gotcha:existing".to_string()],
1823            decision_keys: vec![],
1824            todos: vec![],
1825            unsafe_count: 0,
1826            unwrap_count: 0,
1827            change_frequency: 10,
1828            last_author: None,
1829            is_hotspot: true,
1830            token_cost_estimate: 100,
1831            last_modified_session: now(),
1832            content_hash: None,
1833            line_count: 0,
1834            blast_radius: None,
1835            propagated_staleness: None,
1836        };
1837        let mut file_record = make_record(
1838            "file:src/covered.rs",
1839            &serde_json::to_string(&fr).unwrap(),
1840            Category::File,
1841            0.5,
1842        );
1843        file_record.access_count = 10;
1844        store
1845            .put("file:src/covered.rs", &file_record)
1846            .await
1847            .unwrap();
1848
1849        let graph = Graph::load(store).await.unwrap();
1850        let packet =
1851            assemble_context_packet(graph.store(), &graph, &["src/covered.rs".to_string()])
1852                .await
1853                .unwrap();
1854
1855        assert!(
1856            packet.unconfirmed_candidates.is_empty(),
1857            "file with gotchas should not trigger nudge"
1858        );
1859    }
1860
1861    // ── M-13-B: stale warning tests ─────────────────────────────────────────
1862
1863    #[tokio::test]
1864    async fn tombstone_gotcha_excluded_from_bootstrap() {
1865        let dir = TempDir::new().unwrap();
1866        let store = Store::open(dir.path()).await.unwrap();
1867
1868        // Create a tombstone-tier gotcha
1869        let mut gotcha = make_gotcha_record("gotcha:tombstone", "tombstone rule", true, 0.80);
1870        gotcha.staleness = StalenessScore {
1871            value: 0.95,
1872            tier: StalenessTier::Tombstone,
1873            signals: vec![],
1874            computed_at: now(),
1875            last_record_sha: String::new(),
1876        };
1877        store.put("gotcha:tombstone", &gotcha).await.unwrap();
1878
1879        // Create a normal gotcha
1880        let good = make_gotcha_record("gotcha:good", "good rule", true, 0.80);
1881        store.put("gotcha:good", &good).await.unwrap();
1882
1883        let graph = Graph::load(store).await.unwrap();
1884        let packet = assemble_context_packet(graph.store(), &graph, &[])
1885            .await
1886            .unwrap();
1887
1888        assert!(
1889            !packet.injection_string.contains("gotcha:tombstone"),
1890            "tombstone gotcha must not appear in injection"
1891        );
1892        assert!(
1893            packet.injection_string.contains("gotcha:good"),
1894            "normal gotcha should appear"
1895        );
1896    }
1897
1898    #[tokio::test]
1899    async fn liability_gotcha_gets_stale_caveat() {
1900        let dir = TempDir::new().unwrap();
1901        let store = Store::open(dir.path()).await.unwrap();
1902
1903        let mut gotcha = make_gotcha_record("gotcha:liability", "liability rule", true, 0.80);
1904        gotcha.staleness = StalenessScore {
1905            value: 0.75,
1906            tier: StalenessTier::Liability,
1907            signals: vec![],
1908            computed_at: now(),
1909            last_record_sha: String::new(),
1910        };
1911        store.put("gotcha:liability", &gotcha).await.unwrap();
1912
1913        let graph = Graph::load(store).await.unwrap();
1914        let packet = assemble_context_packet(graph.store(), &graph, &[])
1915            .await
1916            .unwrap();
1917
1918        if packet.injection_string.contains("gotcha:liability") {
1919            assert!(
1920                packet.injection_string.contains("STALE"),
1921                "liability gotcha must have STALE caveat"
1922            );
1923        }
1924    }
1925
1926    #[tokio::test]
1927    async fn stale_file_generates_warning() {
1928        let dir = TempDir::new().unwrap();
1929        let store = Store::open(dir.path()).await.unwrap();
1930
1931        let fr = FileRecord {
1932            path: "src/stale.rs".to_string(),
1933            purpose: "Stale module".to_string(),
1934            entry_points: vec![],
1935            imports: vec![],
1936            gotcha_keys: vec![],
1937            decision_keys: vec![],
1938            todos: vec![],
1939            unsafe_count: 0,
1940            unwrap_count: 0,
1941            change_frequency: 0,
1942            last_author: None,
1943            is_hotspot: false,
1944            token_cost_estimate: 50,
1945            last_modified_session: now(),
1946            content_hash: None,
1947            line_count: 0,
1948            blast_radius: None,
1949            propagated_staleness: None,
1950        };
1951        let mut file_record = make_record(
1952            "file:src/stale.rs",
1953            &serde_json::to_string(&fr).unwrap(),
1954            Category::File,
1955            0.5,
1956        );
1957        file_record.staleness = StalenessScore {
1958            value: 0.55,
1959            tier: StalenessTier::Stale,
1960            signals: vec![],
1961            computed_at: now(),
1962            last_record_sha: String::new(),
1963        };
1964        store.put("file:src/stale.rs", &file_record).await.unwrap();
1965
1966        let graph = Graph::load(store).await.unwrap();
1967        let packet = assemble_context_packet(graph.store(), &graph, &["src/stale.rs".to_string()])
1968            .await
1969            .unwrap();
1970
1971        assert!(
1972            !packet.stale_warnings.is_empty(),
1973            "stale file should generate a warning"
1974        );
1975        assert!(
1976            packet.stale_warnings.iter().any(|w| w.contains("stale.rs")),
1977            "warning should mention the stale file"
1978        );
1979    }
1980
1981    #[tokio::test]
1982    async fn tombstone_file_excluded_from_traversal() {
1983        let dir = TempDir::new().unwrap();
1984        let store = Store::open(dir.path()).await.unwrap();
1985
1986        let fr = FileRecord {
1987            path: "src/dead.rs".to_string(),
1988            purpose: "Dead module".to_string(),
1989            entry_points: vec![],
1990            imports: vec![],
1991            gotcha_keys: vec![],
1992            decision_keys: vec![],
1993            todos: vec![],
1994            unsafe_count: 0,
1995            unwrap_count: 0,
1996            change_frequency: 0,
1997            last_author: None,
1998            is_hotspot: false,
1999            token_cost_estimate: 50,
2000            last_modified_session: now(),
2001            content_hash: None,
2002            line_count: 0,
2003            blast_radius: None,
2004            propagated_staleness: None,
2005        };
2006        let mut file_record = make_record(
2007            "file:src/dead.rs",
2008            &serde_json::to_string(&fr).unwrap(),
2009            Category::File,
2010            0.5,
2011        );
2012        file_record.staleness = StalenessScore {
2013            value: 0.95,
2014            tier: StalenessTier::Tombstone,
2015            signals: vec![],
2016            computed_at: now(),
2017            last_record_sha: String::new(),
2018        };
2019        store.put("file:src/dead.rs", &file_record).await.unwrap();
2020
2021        let graph = Graph::load(store).await.unwrap();
2022        let packet = assemble_context_packet(graph.store(), &graph, &["src/dead.rs".to_string()])
2023            .await
2024            .unwrap();
2025
2026        assert!(
2027            packet.file_records.is_empty(),
2028            "tombstone file should not appear in file_records"
2029        );
2030    }
2031
2032    #[tokio::test]
2033    async fn stale_warnings_deduplicated() {
2034        let dir = TempDir::new().unwrap();
2035        let store = Store::open(dir.path()).await.unwrap();
2036
2037        let fr = FileRecord {
2038            path: "src/dup.rs".to_string(),
2039            purpose: "Dup module".to_string(),
2040            entry_points: vec![],
2041            imports: vec![],
2042            gotcha_keys: vec![],
2043            decision_keys: vec![],
2044            todos: vec![],
2045            unsafe_count: 0,
2046            unwrap_count: 0,
2047            change_frequency: 0,
2048            last_author: None,
2049            is_hotspot: false,
2050            token_cost_estimate: 50,
2051            last_modified_session: now(),
2052            content_hash: None,
2053            line_count: 0,
2054            blast_radius: None,
2055            propagated_staleness: None,
2056        };
2057        let mut file_record = make_record(
2058            "file:src/dup.rs",
2059            &serde_json::to_string(&fr).unwrap(),
2060            Category::File,
2061            0.5,
2062        );
2063        file_record.staleness = StalenessScore {
2064            value: 0.55,
2065            tier: StalenessTier::Stale,
2066            signals: vec![],
2067            computed_at: now(),
2068            last_record_sha: String::new(),
2069        };
2070        store.put("file:src/dup.rs", &file_record).await.unwrap();
2071
2072        // Also create a stale review entry for the same key
2073        let review_payload = StaleReviewPayload {
2074            session_timestamp: now(),
2075            entries: vec![StaleReviewEntry {
2076                key: "file:src/dup.rs".to_string(),
2077                staleness_value: 0.55,
2078                tier: StalenessTier::Stale,
2079                last_updated: now(),
2080                signals: vec!["stale".to_string()],
2081            }],
2082        };
2083        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
2084        let review_key = format!("analytics:stale_review_{today}");
2085        let review_record = make_record(
2086            &review_key,
2087            &serde_json::to_string(&review_payload).unwrap(),
2088            Category::Analytics,
2089            0.5,
2090        );
2091        store.put(&review_key, &review_record).await.unwrap();
2092
2093        let graph = Graph::load(store).await.unwrap();
2094        let packet = assemble_context_packet(graph.store(), &graph, &["src/dup.rs".to_string()])
2095            .await
2096            .unwrap();
2097
2098        // Should have exactly 1 warning, not 2 (dedup by key)
2099        let dup_count = packet
2100            .stale_warnings
2101            .iter()
2102            .filter(|w| w.contains("dup.rs"))
2103            .count();
2104        assert_eq!(
2105            dup_count, 1,
2106            "same key should not produce duplicate warnings"
2107        );
2108    }
2109
2110    #[tokio::test]
2111    async fn stale_warnings_section_before_decisions() {
2112        let dir = TempDir::new().unwrap();
2113        let store = Store::open(dir.path()).await.unwrap();
2114
2115        // Create a stale file
2116        let fr = FileRecord {
2117            path: "src/stale.rs".to_string(),
2118            purpose: "Stale".to_string(),
2119            entry_points: vec![],
2120            imports: vec![],
2121            gotcha_keys: vec![],
2122            decision_keys: vec![],
2123            todos: vec![],
2124            unsafe_count: 0,
2125            unwrap_count: 0,
2126            change_frequency: 0,
2127            last_author: None,
2128            is_hotspot: false,
2129            token_cost_estimate: 50,
2130            last_modified_session: now(),
2131            content_hash: None,
2132            line_count: 0,
2133            blast_radius: None,
2134            propagated_staleness: None,
2135        };
2136        let mut file_record = make_record(
2137            "file:src/stale.rs",
2138            &serde_json::to_string(&fr).unwrap(),
2139            Category::File,
2140            0.5,
2141        );
2142        file_record.staleness = StalenessScore {
2143            value: 0.55,
2144            tier: StalenessTier::Stale,
2145            signals: vec![],
2146            computed_at: now(),
2147            last_record_sha: String::new(),
2148        };
2149        store.put("file:src/stale.rs", &file_record).await.unwrap();
2150
2151        // Create a decision record reachable via graph edge
2152        let decision = make_record("decision:arch", "Use SurrealKV", Category::Decision, 0.8);
2153        store.put("decision:arch", &decision).await.unwrap();
2154
2155        let mut graph = Graph::load(store).await.unwrap();
2156        graph
2157            .add_edge("file:src/stale.rs", EdgeKind::AffectedBy, "decision:arch")
2158            .await
2159            .unwrap();
2160
2161        let packet = assemble_context_packet(graph.store(), &graph, &["src/stale.rs".to_string()])
2162            .await
2163            .unwrap();
2164
2165        let stale_pos = packet.injection_string.find("## Stale Warnings");
2166        let dec_pos = packet.injection_string.find("## Decisions");
2167
2168        if let (Some(s), Some(d)) = (stale_pos, dec_pos) {
2169            assert!(s < d, "Stale Warnings section must appear before Decisions");
2170        }
2171    }
2172
2173    #[tokio::test]
2174    async fn unconfirmed_gotcha_never_injected() {
2175        let dir = TempDir::new().unwrap();
2176        let store = Store::open(dir.path()).await.unwrap();
2177
2178        // Create an unconfirmed gotcha
2179        let unconfirmed = make_gotcha_record("gotcha:unconfirmed", "unconfirmed rule", false, 0.80);
2180        store.put("gotcha:unconfirmed", &unconfirmed).await.unwrap();
2181
2182        let graph = Graph::load(store).await.unwrap();
2183        let packet = assemble_context_packet(graph.store(), &graph, &[])
2184            .await
2185            .unwrap();
2186
2187        assert!(
2188            !packet.injection_string.contains("gotcha:unconfirmed"),
2189            "unconfirmed gotcha must never be injected"
2190        );
2191    }
2192
2193    #[tokio::test]
2194    async fn empty_store_returns_only_vector_b() {
2195        let dir = TempDir::new().unwrap();
2196        let store = Store::open(dir.path()).await.unwrap();
2197        let graph = Graph::load(store).await.unwrap();
2198
2199        let packet = assemble_context_packet(graph.store(), &graph, &[])
2200            .await
2201            .unwrap();
2202
2203        assert!(packet.injection_string.contains("[mati] Before reading"));
2204        assert!(packet.critical_gotchas.is_empty());
2205        assert!(packet.file_records.is_empty());
2206        assert!(packet.stale_warnings.is_empty());
2207        assert!(packet.related_decisions.is_empty());
2208    }
2209
2210    // ── mem_set tests ────────────────────────────────────────────────────────
2211
2212    /// Direct-path mem_set must reject `file:*` writes — they are owned by
2213    /// the static-analysis pipeline (`mati init` Layer 0 + the
2214    /// `file_enrich` / `file_reparse` typed Commands). Mirrors the Socket
2215    /// path's rejection in `build_mem_set_command` so both backends behave
2216    /// identically (regression for the codex smoke-test step 57 failure).
2217    #[tokio::test]
2218    async fn mem_set_rejects_file_writes_on_direct_path() {
2219        let dir = TempDir::new().unwrap();
2220        let store = Store::open(dir.path()).await.unwrap();
2221        let graph = Graph::load(store).await.unwrap();
2222        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2223
2224        let result = call_mem_set(
2225            &graph_arc,
2226            MemSetParams {
2227                action: "write".to_string(),
2228                key: "file:src/main.rs".to_string(),
2229                value: "Handles CLI dispatch and binary entry point".to_string(),
2230                category: "File".to_string(),
2231                payload: serde_json::json!({
2232                    "path": "src/main.rs",
2233                    "purpose": "Handles CLI dispatch and binary entry point"
2234                }),
2235                tags: vec!["entry-point".to_string()],
2236                priority: "Normal".to_string(),
2237            },
2238        )
2239        .await;
2240
2241        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2242        let error = parsed["error"]
2243            .as_str()
2244            .unwrap_or_else(|| panic!("expected an error envelope, got: {result}"));
2245        assert!(
2246            error.contains("must start with"),
2247            "file: write must be rejected by the prefix gate, got: {error}"
2248        );
2249        assert_eq!(parsed.get("ok"), None, "no record should be written");
2250
2251        // Confirm no record was persisted.
2252        let graph = graph_arc.read().await;
2253        assert!(
2254            graph
2255                .store()
2256                .get("file:src/main.rs")
2257                .await
2258                .unwrap()
2259                .is_none(),
2260            "file: write must not create a record"
2261        );
2262    }
2263
2264    #[tokio::test]
2265    async fn mem_set_writes_gotcha_with_quality_score() {
2266        let dir = TempDir::new().unwrap();
2267        let store = Store::open(dir.path()).await.unwrap();
2268        let graph = Graph::load(store).await.unwrap();
2269        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2270
2271        let result = call_mem_set(
2272            &graph_arc,
2273            MemSetParams { action: "write".to_string(),
2274                key: "gotcha:always-use-idempotency-keys".to_string(),
2275                value: "Always pass idempotency_key to Stripe charge creation because duplicate charges cause customer refund disputes".to_string(),
2276                category: "Gotcha".to_string(),
2277                payload: serde_json::json!({
2278                    "rule": "Always pass idempotency_key to Stripe charge creation",
2279                    "reason": "duplicate charges cause customer refund disputes",
2280                    "severity": "Critical",
2281                    "affected_files": ["src/payments/stripe.go"],
2282                    "ref_url": null,
2283                    "discovered_session": 0,
2284                    "confirmed": false
2285                }),
2286                tags: vec![],
2287                priority: "Critical".to_string(),
2288            },
2289        )
2290        .await;
2291
2292        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2293        assert_eq!(parsed["ok"], true);
2294        // Quality should be > 0.2 (passes gate) since rule has imperative verb + reason has causality
2295        assert!(parsed["quality"].as_f64().unwrap() > 0.2);
2296
2297        // Read back
2298        let graph = graph_arc.read().await;
2299        let record = graph
2300            .store()
2301            .get("gotcha:always-use-idempotency-keys")
2302            .await
2303            .unwrap()
2304            .unwrap();
2305        assert_eq!(record.priority, Priority::Critical);
2306        assert_eq!(record.source, RecordSource::ClaudeEnrich);
2307    }
2308
2309    // `mem_set_preserves_existing_layer0_data` was removed: file: writes
2310    // are no longer accepted via mem_set on either backend. Layer 0
2311    // preservation under enrichment is now exercised by the
2312    // `file_enrich` typed Command's tests (see `handle_file_enrich`).
2313
2314    #[tokio::test]
2315    async fn mem_set_rejects_invalid_key_prefix() {
2316        let dir = TempDir::new().unwrap();
2317        let store = Store::open(dir.path()).await.unwrap();
2318        let graph = Graph::load(store).await.unwrap();
2319        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2320
2321        let result = call_mem_set(
2322            &graph_arc,
2323            MemSetParams {
2324                action: "write".to_string(),
2325                key: "session:12345".to_string(),
2326                value: "test".to_string(),
2327                category: "Gotcha".to_string(),
2328                payload: serde_json::json!({}),
2329                tags: vec![],
2330                priority: "Normal".to_string(),
2331            },
2332        )
2333        .await;
2334
2335        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2336        let error = parsed["error"].as_str().unwrap();
2337        assert!(error.contains("must start with"), "got: {error}");
2338        // file: was deliberately removed from the accepted prefix list.
2339        assert!(!error.contains("file:"), "got: {error}");
2340    }
2341
2342    #[tokio::test]
2343    async fn mem_set_rejects_invalid_category() {
2344        let dir = TempDir::new().unwrap();
2345        let store = Store::open(dir.path()).await.unwrap();
2346        let graph = Graph::load(store).await.unwrap();
2347        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2348
2349        let result = call_mem_set(
2350            &graph_arc,
2351            MemSetParams {
2352                action: "write".to_string(),
2353                key: "gotcha:test".to_string(),
2354                value: "test".to_string(),
2355                category: "Unknown".to_string(),
2356                payload: serde_json::json!({}),
2357                tags: vec![],
2358                priority: "Normal".to_string(),
2359            },
2360        )
2361        .await;
2362
2363        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2364        assert!(parsed["error"]
2365            .as_str()
2366            .unwrap()
2367            .contains("unknown category"));
2368    }
2369
2370    #[tokio::test]
2371    async fn mem_set_rejects_key_category_mismatch() {
2372        let dir = TempDir::new().unwrap();
2373        let store = Store::open(dir.path()).await.unwrap();
2374        let graph = Graph::load(store).await.unwrap();
2375        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2376
2377        // gotcha: key with Decision category — must be rejected.
2378        // (File is no longer a valid category for mem_set, so the prior
2379        // gotcha:/File pairing now fails category parsing instead of the
2380        // mismatch check; pick another valid-but-wrong category to keep
2381        // exercising the mismatch arm.)
2382        let result = call_mem_set(
2383            &graph_arc,
2384            MemSetParams {
2385                action: "write".to_string(),
2386                key: "gotcha:should-fail".to_string(),
2387                value: "test".to_string(),
2388                category: "Decision".to_string(),
2389                payload: serde_json::json!({"summary": "x", "rationale": "y"}),
2390                tags: vec![],
2391                priority: "Normal".to_string(),
2392            },
2393        )
2394        .await;
2395
2396        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2397        assert!(
2398            parsed["error"]
2399                .as_str()
2400                .unwrap()
2401                .contains("requires category"),
2402            "key-category mismatch must be rejected: {result}"
2403        );
2404    }
2405
2406    #[tokio::test]
2407    async fn mem_set_rejects_new_gotcha_without_rule_and_reason() {
2408        let dir = TempDir::new().unwrap();
2409        let store = Store::open(dir.path()).await.unwrap();
2410        let graph = Graph::load(store).await.unwrap();
2411        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2412
2413        let result = call_mem_set(
2414            &graph_arc,
2415            MemSetParams {
2416                action: "write".to_string(),
2417                key: "gotcha:missing-fields".to_string(),
2418                value: "test".to_string(),
2419                category: "Gotcha".to_string(),
2420                payload: serde_json::json!({"severity": "Normal"}),
2421                tags: vec![],
2422                priority: "Normal".to_string(),
2423            },
2424        )
2425        .await;
2426
2427        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2428        assert!(
2429            parsed["error"]
2430                .as_str()
2431                .unwrap()
2432                .contains("'rule' and 'reason'"),
2433            "new gotcha without rule/reason must be rejected: {result}"
2434        );
2435    }
2436
2437    #[tokio::test]
2438    async fn mem_set_rejects_new_decision_without_summary_rationale() {
2439        let dir = TempDir::new().unwrap();
2440        let store = Store::open(dir.path()).await.unwrap();
2441        let graph = Graph::load(store).await.unwrap();
2442        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2443
2444        let result = call_mem_set(
2445            &graph_arc,
2446            MemSetParams {
2447                action: "write".to_string(),
2448                key: "decision:incomplete".to_string(),
2449                value: "test".to_string(),
2450                category: "Decision".to_string(),
2451                payload: serde_json::json!({}),
2452                tags: vec![],
2453                priority: "Normal".to_string(),
2454            },
2455        )
2456        .await;
2457
2458        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2459        assert!(
2460            parsed["error"]
2461                .as_str()
2462                .unwrap()
2463                .contains("'summary' and 'rationale'"),
2464            "new decision without summary/rationale must be rejected: {result}"
2465        );
2466    }
2467
2468    #[tokio::test]
2469    async fn mem_set_rejects_new_dev_note_with_empty_value() {
2470        let dir = TempDir::new().unwrap();
2471        let store = Store::open(dir.path()).await.unwrap();
2472        let graph = Graph::load(store).await.unwrap();
2473        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2474
2475        let result = call_mem_set(
2476            &graph_arc,
2477            MemSetParams {
2478                action: "write".to_string(),
2479                key: "dev_note:empty".to_string(),
2480                value: "".to_string(),
2481                category: "DevNote".to_string(),
2482                payload: serde_json::json!({}),
2483                tags: vec![],
2484                priority: "Normal".to_string(),
2485            },
2486        )
2487        .await;
2488
2489        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2490        assert!(
2491            parsed["error"]
2492                .as_str()
2493                .unwrap()
2494                .contains("non-empty value"),
2495            "new dev_note with empty value must be rejected: {result}"
2496        );
2497    }
2498
2499    #[tokio::test]
2500    async fn mem_set_allows_partial_payload_on_update() {
2501        let dir = TempDir::new().unwrap();
2502        let store = Store::open(dir.path()).await.unwrap();
2503        let graph = Graph::load(store).await.unwrap();
2504        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2505
2506        // First write: full payload (new record).
2507        let result = call_mem_set(
2508            &graph_arc,
2509            MemSetParams {
2510                action: "write".to_string(),
2511                key: "gotcha:partial-update".to_string(),
2512                value: "test rule because test reason".to_string(),
2513                category: "Gotcha".to_string(),
2514                payload: serde_json::json!({
2515                    "rule": "test rule",
2516                    "reason": "test reason",
2517                    "severity": "Normal",
2518                    "affected_files": [],
2519                }),
2520                tags: vec![],
2521                priority: "Normal".to_string(),
2522            },
2523        )
2524        .await;
2525        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2526        assert_eq!(parsed["ok"], true, "initial write must succeed");
2527
2528        // Second write: partial payload (update) — must succeed because
2529        // payload validation is skipped for existing records (merge fills fields).
2530        let result = call_mem_set(
2531            &graph_arc,
2532            MemSetParams {
2533                action: "write".to_string(),
2534                key: "gotcha:partial-update".to_string(),
2535                value: "updated rule because updated reason".to_string(),
2536                category: "Gotcha".to_string(),
2537                payload: serde_json::json!({"reason": "updated reason"}),
2538                tags: vec![],
2539                priority: "Normal".to_string(),
2540            },
2541        )
2542        .await;
2543        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2544        assert_eq!(
2545            parsed["ok"], true,
2546            "partial-payload update must succeed: {result}"
2547        );
2548    }
2549
2550    #[tokio::test]
2551    async fn mem_set_preserves_confirmation_state_on_update() {
2552        let dir = TempDir::new().unwrap();
2553        let store = Store::open(dir.path()).await.unwrap();
2554
2555        // Create a confirmed gotcha (simulates post-mati gotcha confirm state)
2556        let mut record = make_gotcha_record(
2557            "gotcha:confirmed-edit-test",
2558            "Always test first",
2559            true,
2560            0.70,
2561        );
2562        record.source = RecordSource::DeveloperManual;
2563        record.confidence = ConfidenceScore {
2564            value: 0.80,
2565            confirmation_count: 1,
2566            contributor_count: 1,
2567            last_challenged: None,
2568            challenge_count: 0,
2569        };
2570        record.tags = vec!["important".to_string()];
2571        store
2572            .put("gotcha:confirmed-edit-test", &record)
2573            .await
2574            .unwrap();
2575
2576        let graph = Graph::load(store).await.unwrap();
2577        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2578
2579        // Update the gotcha's value via mem_set (simulates Claude editing the reason)
2580        let result = call_mem_set(
2581            &graph_arc,
2582            MemSetParams {
2583                action: "write".to_string(),
2584                key: "gotcha:confirmed-edit-test".to_string(),
2585                value: "Always test first because untested changes cause regressions".to_string(),
2586                category: "Gotcha".to_string(),
2587                payload: serde_json::json!({
2588                    "rule": "Always test first",
2589                    "reason": "untested changes cause regressions",
2590                    "severity": "High",
2591                    "affected_files": ["src/main.rs"],
2592                    "ref_url": null,
2593                    "discovered_session": 0,
2594                    "confirmed": true
2595                }),
2596                tags: vec![], // empty — should NOT clear existing tags
2597                priority: "High".to_string(),
2598            },
2599        )
2600        .await;
2601
2602        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2603        assert_eq!(parsed["ok"], true);
2604
2605        // Verify confirmation state preserved
2606        let graph = graph_arc.read().await;
2607        let updated = graph
2608            .store()
2609            .get("gotcha:confirmed-edit-test")
2610            .await
2611            .unwrap()
2612            .unwrap();
2613        assert_eq!(updated.source, RecordSource::DeveloperManual);
2614        assert!(
2615            (updated.confidence.value - 0.80).abs() < 0.01,
2616            "confidence should stay 0.80, got {}",
2617            updated.confidence.value
2618        );
2619        assert_eq!(updated.confidence.confirmation_count, 1);
2620        assert_eq!(
2621            updated.tags,
2622            vec!["important".to_string()],
2623            "tags should be preserved when caller sends empty"
2624        );
2625    }
2626
2627    #[tokio::test]
2628    async fn mem_set_moves_gotcha_links_and_edges_on_edit() {
2629        let dir = TempDir::new().unwrap();
2630        let store = Store::open(dir.path()).await.unwrap();
2631
2632        // Seed file records for both old and new affected files.
2633        let old_file = Record::layer0_file_stub("file:src/old.rs", device_id(), 1, now());
2634        let new_file = Record::layer0_file_stub("file:src/new.rs", device_id(), 1, now());
2635        store.put("file:src/old.rs", &old_file).await.unwrap();
2636        store.put("file:src/new.rs", &new_file).await.unwrap();
2637
2638        let graph = Graph::load(store).await.unwrap();
2639        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2640
2641        call_mem_set(
2642            &graph_arc,
2643            MemSetParams {
2644                action: "write".to_string(),
2645                key: "gotcha:test-move".to_string(),
2646                value: "Always update the paired file because drift breaks the feature".to_string(),
2647                category: "Gotcha".to_string(),
2648                payload: serde_json::json!({
2649                    "rule": "Always update the paired file",
2650                    "reason": "drift breaks the feature",
2651                    "severity": "High",
2652                    "affected_files": ["src/old.rs"],
2653                    "ref_url": null,
2654                    "discovered_session": 0,
2655                    "confirmed": false
2656                }),
2657                tags: vec![],
2658                priority: "High".to_string(),
2659            },
2660        )
2661        .await;
2662
2663        call_mem_set(
2664            &graph_arc,
2665            MemSetParams {
2666                action: "write".to_string(),
2667                key: "gotcha:test-move".to_string(),
2668                value: "Always update the paired file because drift breaks the feature".to_string(),
2669                category: "Gotcha".to_string(),
2670                payload: serde_json::json!({
2671                    "rule": "Always update the paired file",
2672                    "reason": "drift breaks the feature",
2673                    "severity": "High",
2674                    "affected_files": ["src/new.rs"],
2675                    "ref_url": null,
2676                    "discovered_session": 0,
2677                    "confirmed": false
2678                }),
2679                tags: vec![],
2680                priority: "High".to_string(),
2681            },
2682        )
2683        .await;
2684
2685        let graph = graph_arc.read().await;
2686
2687        let old_file = graph.store().get("file:src/old.rs").await.unwrap().unwrap();
2688        let new_file = graph.store().get("file:src/new.rs").await.unwrap().unwrap();
2689        let old_payload = old_file.payload.unwrap();
2690        let new_payload = new_file.payload.unwrap();
2691
2692        assert!(
2693            old_payload["gotcha_keys"]
2694                .as_array()
2695                .map(|arr| arr.is_empty())
2696                .unwrap_or(true),
2697            "old file should no longer reference moved gotcha"
2698        );
2699        assert_eq!(new_payload["gotcha_keys"][0], "gotcha:test-move");
2700
2701        assert!(
2702            !graph
2703                .neighbors("file:src/old.rs", &EdgeKind::HasGotcha)
2704                .contains(&"gotcha:test-move".to_string()),
2705            "old file should not keep stale HasGotcha edge"
2706        );
2707        assert!(
2708            graph
2709                .neighbors("file:src/new.rs", &EdgeKind::HasGotcha)
2710                .contains(&"gotcha:test-move".to_string()),
2711            "new file should gain HasGotcha edge"
2712        );
2713    }
2714
2715    // ── Regression: query limit clamp ───────────────────────────────────────
2716
2717    /// Regression test: mem_query must clamp the limit to MAX_QUERY_LIMIT (50)
2718    /// even when the caller passes a larger value. Passing limit=100 must not
2719    /// error and must return at most 50 results.
2720    #[tokio::test]
2721    async fn test_query_limit_clamped_to_max() {
2722        let dir = TempDir::new().unwrap();
2723        let store = Store::open(dir.path()).await.unwrap();
2724
2725        // Insert 60 records — more than MAX_QUERY_LIMIT (50).
2726        for i in 0..60 {
2727            let record = make_record(
2728                &format!("gotcha:clamp-test-{i:03}"),
2729                &format!("clamp test rule number {i}"),
2730                Category::Gotcha,
2731                0.8,
2732            );
2733            store
2734                .put(&format!("gotcha:clamp-test-{i:03}"), &record)
2735                .await
2736                .unwrap();
2737        }
2738
2739        let graph = Graph::load(store).await.unwrap();
2740        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2741
2742        let result = call_mem_query(
2743            &graph_arc,
2744            "clamp test rule",
2745            crate::mcp::protocol::QueryMode::Text,
2746            100, // exceeds MAX_QUERY_LIMIT (50)
2747        )
2748        .await;
2749
2750        // Must not error
2751        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2752        assert!(
2753            parsed.get("error").is_none(),
2754            "query with limit > 50 must not error"
2755        );
2756
2757        // Must return at most 50 results (the clamped limit)
2758        let results = parsed.as_array().expect("result should be a JSON array");
2759        assert!(
2760            results.len() <= 50,
2761            "result count {} exceeds MAX_QUERY_LIMIT (50)",
2762            results.len()
2763        );
2764    }
2765
2766    // ── Regression: store-read-error refusal on mem_set write ───────────────
2767
2768    /// Regression test: mem_set write to a new key must succeed (proving the
2769    /// Ok(None) arm of the store read works). The Err(e) arm returns
2770    /// {"error": "store read failed — refusing to write: ..."} but cannot be
2771    /// triggered without injecting a store fault, which the test harness does
2772    /// not support. This test validates the happy path; the error format is
2773    /// documented here for grep-ability.
2774    #[tokio::test]
2775    async fn test_mem_set_write_new_key_succeeds() {
2776        let dir = TempDir::new().unwrap();
2777        let store = Store::open(dir.path()).await.unwrap();
2778        let graph = Graph::load(store).await.unwrap();
2779        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2780
2781        let result = call_mem_set(
2782            &graph_arc,
2783            MemSetParams {
2784                action: "write".to_string(),
2785                key: "gotcha:store-read-ok-none".to_string(),
2786                value: "Regression rule because regression reason".to_string(),
2787                category: "Gotcha".to_string(),
2788                payload: serde_json::json!({
2789                    "rule": "Regression rule",
2790                    "reason": "regression reason",
2791                    "severity": "Normal"
2792                }),
2793                tags: vec![],
2794                priority: "Normal".to_string(),
2795            },
2796        )
2797        .await;
2798
2799        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2800        assert_eq!(
2801            parsed["ok"], true,
2802            "writing a new key must succeed (Ok(None) arm)"
2803        );
2804        assert_eq!(parsed["key"], "gotcha:store-read-ok-none");
2805
2806        // Verify record persisted
2807        let graph = graph_arc.read().await;
2808        let record = graph
2809            .store()
2810            .get("gotcha:store-read-ok-none")
2811            .await
2812            .unwrap()
2813            .expect("record must exist after write");
2814        assert_eq!(record.value, "Regression rule because regression reason");
2815
2816        // Note: the Err(e) path (store read failure) returns:
2817        //   {"error": "store read failed — refusing to write: <details>"}
2818        // This cannot be exercised without a store-fault injection mechanism.
2819        // The guard exists at tools.rs line ~605 to prevent blind overwrites
2820        // when the store is in a degraded state.
2821    }
2822
2823    // ── Regression: in-memory graph cleanup after tombstone ─────────────────
2824
2825    /// Regression test: after deleting a gotcha via mem_set action="delete",
2826    /// the in-memory graph must no longer contain HasGotcha edges pointing to
2827    /// the deleted gotcha. Previously, only the store was cleaned up but the
2828    /// in-memory petgraph retained stale edges until process restart.
2829    #[tokio::test]
2830    async fn test_tombstone_removes_in_memory_graph_edges() {
2831        let dir = TempDir::new().unwrap();
2832        let store = Store::open(dir.path()).await.unwrap();
2833
2834        // Seed file record
2835        let file_record = Record::layer0_file_stub("file:src/target.rs", device_id(), 1, now());
2836        store.put("file:src/target.rs", &file_record).await.unwrap();
2837
2838        let graph = Graph::load(store).await.unwrap();
2839        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2840
2841        // Step 1: Write a gotcha linked to the file via affected_files.
2842        let write_result = call_mem_set(
2843            &graph_arc,
2844            MemSetParams {
2845                action: "write".to_string(),
2846                key: "gotcha:graph-cleanup-test".to_string(),
2847                value: "Never skip validation because it causes silent data corruption".to_string(),
2848                category: "Gotcha".to_string(),
2849                payload: serde_json::json!({
2850                    "rule": "Never skip validation",
2851                    "reason": "causes silent data corruption",
2852                    "severity": "High",
2853                    "affected_files": ["src/target.rs"],
2854                    "ref_url": null,
2855                    "discovered_session": 0,
2856                    "confirmed": false
2857                }),
2858                tags: vec![],
2859                priority: "High".to_string(),
2860            },
2861        )
2862        .await;
2863        let parsed: serde_json::Value = serde_json::from_str(&write_result).unwrap();
2864        assert_eq!(parsed["ok"], true, "gotcha write must succeed");
2865
2866        // Step 2: Verify the in-memory graph has the HasGotcha edge.
2867        {
2868            let graph = graph_arc.read().await;
2869            let neighbors = graph.neighbors("file:src/target.rs", &EdgeKind::HasGotcha);
2870            assert!(
2871                neighbors.contains(&"gotcha:graph-cleanup-test".to_string()),
2872                "HasGotcha edge must exist after write; neighbors: {neighbors:?}"
2873            );
2874        }
2875
2876        // Step 3: Delete the gotcha.
2877        let delete_result = call_mem_set(
2878            &graph_arc,
2879            MemSetParams {
2880                action: "delete".to_string(),
2881                key: "gotcha:graph-cleanup-test".to_string(),
2882                value: String::new(),
2883                category: String::new(),
2884                payload: serde_json::json!({}),
2885                tags: vec![],
2886                priority: "Normal".to_string(),
2887            },
2888        )
2889        .await;
2890        let parsed: serde_json::Value = serde_json::from_str(&delete_result).unwrap();
2891        assert_eq!(parsed["ok"], true, "gotcha delete must succeed");
2892        assert_eq!(parsed["tombstoned"], true);
2893
2894        // Step 4: Verify the in-memory graph no longer has the HasGotcha edge.
2895        {
2896            let graph = graph_arc.read().await;
2897            let neighbors = graph.neighbors("file:src/target.rs", &EdgeKind::HasGotcha);
2898            assert!(
2899                !neighbors.contains(&"gotcha:graph-cleanup-test".to_string()),
2900                "HasGotcha edge must be removed after delete; neighbors: {neighbors:?}"
2901            );
2902        }
2903
2904        // Also verify via graph query mode that the gotcha no longer appears.
2905        let graph_query_result = call_mem_query(
2906            &graph_arc,
2907            "file:src/target.rs",
2908            crate::mcp::protocol::QueryMode::Graph,
2909            20,
2910        )
2911        .await;
2912        let parsed: serde_json::Value = serde_json::from_str(&graph_query_result).unwrap();
2913        let gotchas = parsed["gotchas"]
2914            .as_array()
2915            .expect("gotchas group must be an array");
2916        assert!(
2917            !gotchas
2918                .iter()
2919                .any(|g| g["key"] == "gotcha:graph-cleanup-test"),
2920            "deleted gotcha must not appear in graph query results"
2921        );
2922    }
2923
2924    // ── Regression: graph mode respects global limit ──────────────────────
2925
2926    /// Graph mode must respect the caller's `limit` as a global cap across all
2927    /// edge groups. With limit=3 and records in multiple groups, total results
2928    /// must not exceed 3.
2929    #[tokio::test]
2930    async fn test_graph_mode_respects_global_limit() {
2931        let dir = TempDir::new().unwrap();
2932        let store = Store::open(dir.path()).await.unwrap();
2933
2934        // Seed file record
2935        let file_record =
2936            Record::layer0_file_stub("file:src/graph_limit.rs", device_id(), 1, now());
2937        store
2938            .put("file:src/graph_limit.rs", &file_record)
2939            .await
2940            .unwrap();
2941
2942        let graph = Graph::load(store).await.unwrap();
2943        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
2944
2945        // Write 5 gotchas linked to the file
2946        for i in 0..5 {
2947            let result = call_mem_set(
2948                &graph_arc,
2949                MemSetParams {
2950                    action: "write".to_string(),
2951                    key: format!("gotcha:limit-test-{i}"),
2952                    value: format!("Limit test gotcha {i}"),
2953                    category: "Gotcha".to_string(),
2954                    payload: serde_json::json!({
2955                        "rule": format!("Limit rule {i}"),
2956                        "reason": "testing",
2957                        "severity": "Normal",
2958                        "affected_files": ["src/graph_limit.rs"],
2959                        "ref_url": null,
2960                        "discovered_session": 0,
2961                        "confirmed": false
2962                    }),
2963                    tags: vec![],
2964                    priority: "Normal".to_string(),
2965                },
2966            )
2967            .await;
2968            let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2969            assert_eq!(parsed["ok"], true, "gotcha write {i} must succeed");
2970        }
2971
2972        // Query with limit=3 — must get at most 3 total records across all groups.
2973        let result = call_mem_query(
2974            &graph_arc,
2975            "file:src/graph_limit.rs",
2976            crate::mcp::protocol::QueryMode::Graph,
2977            3,
2978        )
2979        .await;
2980        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2981        assert!(parsed.get("error").is_none(), "graph query must not error");
2982
2983        // Count total records across all groups.
2984        let mut total = 0;
2985        for group in &["gotchas", "co_changes", "imports", "decisions", "notes"] {
2986            if let Some(arr) = parsed[group].as_array() {
2987                total += arr.len();
2988            }
2989        }
2990        assert!(
2991            total <= 3,
2992            "graph mode with limit=3 must return at most 3 total records, got {total}"
2993        );
2994    }
2995
2996    /// Graph mode with limit=0 must return zero records in all groups.
2997    #[tokio::test]
2998    async fn test_graph_mode_limit_zero_returns_empty() {
2999        let dir = TempDir::new().unwrap();
3000        let store = Store::open(dir.path()).await.unwrap();
3001
3002        let file_record = Record::layer0_file_stub("file:src/zero.rs", device_id(), 1, now());
3003        store.put("file:src/zero.rs", &file_record).await.unwrap();
3004
3005        let graph = Graph::load(store).await.unwrap();
3006        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3007
3008        // Write one gotcha so there's something to return if limit is ignored.
3009        let _ = call_mem_set(
3010            &graph_arc,
3011            MemSetParams {
3012                action: "write".to_string(),
3013                key: "gotcha:zero-limit-test".to_string(),
3014                value: "Should not appear".to_string(),
3015                category: "Gotcha".to_string(),
3016                payload: serde_json::json!({
3017                    "rule": "Zero limit test",
3018                    "reason": "testing",
3019                    "severity": "Normal",
3020                    "affected_files": ["src/zero.rs"],
3021                    "ref_url": null,
3022                    "discovered_session": 0,
3023                    "confirmed": false
3024                }),
3025                tags: vec![],
3026                priority: "Normal".to_string(),
3027            },
3028        )
3029        .await;
3030
3031        let result = call_mem_query(
3032            &graph_arc,
3033            "file:src/zero.rs",
3034            crate::mcp::protocol::QueryMode::Graph,
3035            0,
3036        )
3037        .await;
3038        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3039
3040        let mut total = 0;
3041        for group in &["gotchas", "co_changes", "imports", "decisions", "notes"] {
3042            if let Some(arr) = parsed[group].as_array() {
3043                total += arr.len();
3044            }
3045        }
3046        assert_eq!(total, 0, "limit=0 must return zero records, got {total}");
3047    }
3048
3049    // ── Regression: mem_set preserves existing data on overwrite ──────────
3050
3051    /// When overwriting an existing record, mem_set must read and preserve
3052    /// Layer 0 structural data from the prior record. This is the exact
3053    /// scenario that `.ok().flatten()` would have broken: a store error would
3054    /// have made mem_set treat the record as new, losing preservation behavior.
3055    #[tokio::test]
3056    async fn test_mem_set_overwrite_preserves_existing_data() {
3057        let dir = TempDir::new().unwrap();
3058        let store = Store::open(dir.path()).await.unwrap();
3059
3060        // Seed a DeveloperManual record with high confidence. (Switched from
3061        // a file: key to a gotcha: key after file: writes were removed from
3062        // mem_set; the merge-on-overwrite semantics under test are the same.)
3063        let mut original =
3064            make_gotcha_record("gotcha:preserve-confirmation", "Original rule", true, 0.7);
3065        original.source = RecordSource::DeveloperManual;
3066        original.confidence.value = 0.85;
3067        original.confidence.confirmation_count = 3;
3068        store
3069            .put("gotcha:preserve-confirmation", &original)
3070            .await
3071            .unwrap();
3072
3073        let graph = Graph::load(store).await.unwrap();
3074        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3075
3076        // Overwrite with mem_set — should preserve confirmation state.
3077        let result = call_mem_set(
3078            &graph_arc,
3079            MemSetParams {
3080                action: "write".to_string(),
3081                key: "gotcha:preserve-confirmation".to_string(),
3082                value: "Updated rule from enrichment".to_string(),
3083                category: "Gotcha".to_string(),
3084                payload: serde_json::json!({"reason": "updated reason"}),
3085                tags: vec![],
3086                priority: "Normal".to_string(),
3087            },
3088        )
3089        .await;
3090        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3091        assert_eq!(parsed["ok"], true, "overwrite must succeed");
3092
3093        // Verify the record was updated but preserved confirmation state.
3094        let graph = graph_arc.read().await;
3095        let record = graph
3096            .store()
3097            .get("gotcha:preserve-confirmation")
3098            .await
3099            .unwrap()
3100            .expect("record must exist");
3101        assert_eq!(
3102            record.value, "Updated rule from enrichment",
3103            "value must be updated"
3104        );
3105        // DeveloperManual source + high confidence should be preserved
3106        // because the write path detects was_confirmed=true.
3107        assert!(
3108            record.confidence.value >= 0.80,
3109            "confirmed record confidence must be preserved, got {}",
3110            record.confidence.value
3111        );
3112    }
3113
3114    // ── Regression: tombstone multi-file gotcha cleans all edges ──────────
3115
3116    /// When a gotcha affects multiple files, tombstone must remove in-memory
3117    /// HasGotcha edges from ALL affected files, not just the first.
3118    #[tokio::test]
3119    async fn test_tombstone_multi_file_removes_all_edges() {
3120        let dir = TempDir::new().unwrap();
3121        let store = Store::open(dir.path()).await.unwrap();
3122
3123        // Seed two file records
3124        let f1 = Record::layer0_file_stub("file:src/a.rs", device_id(), 1, now());
3125        let f2 = Record::layer0_file_stub("file:src/b.rs", device_id(), 1, now());
3126        store.put("file:src/a.rs", &f1).await.unwrap();
3127        store.put("file:src/b.rs", &f2).await.unwrap();
3128
3129        let graph = Graph::load(store).await.unwrap();
3130        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3131
3132        // Write a gotcha affecting both files
3133        let result = call_mem_set(
3134            &graph_arc,
3135            MemSetParams {
3136                action: "write".to_string(),
3137                key: "gotcha:multi-file-tombstone".to_string(),
3138                value: "Cross-file gotcha".to_string(),
3139                category: "Gotcha".to_string(),
3140                payload: serde_json::json!({
3141                    "rule": "Cross-file rule",
3142                    "reason": "testing multi-file cleanup",
3143                    "severity": "Normal",
3144                    "affected_files": ["src/a.rs", "src/b.rs"],
3145                    "ref_url": null,
3146                    "discovered_session": 0,
3147                    "confirmed": false
3148                }),
3149                tags: vec![],
3150                priority: "Normal".to_string(),
3151            },
3152        )
3153        .await;
3154        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3155        assert_eq!(parsed["ok"], true);
3156
3157        // Verify both files have the edge
3158        {
3159            let graph = graph_arc.read().await;
3160            assert!(
3161                graph
3162                    .neighbors("file:src/a.rs", &EdgeKind::HasGotcha)
3163                    .contains(&"gotcha:multi-file-tombstone".to_string()),
3164                "file:src/a.rs must have HasGotcha edge before delete"
3165            );
3166            assert!(
3167                graph
3168                    .neighbors("file:src/b.rs", &EdgeKind::HasGotcha)
3169                    .contains(&"gotcha:multi-file-tombstone".to_string()),
3170                "file:src/b.rs must have HasGotcha edge before delete"
3171            );
3172        }
3173
3174        // Delete the gotcha
3175        let result = call_mem_set(
3176            &graph_arc,
3177            MemSetParams {
3178                action: "delete".to_string(),
3179                key: "gotcha:multi-file-tombstone".to_string(),
3180                value: String::new(),
3181                category: String::new(),
3182                payload: serde_json::json!({}),
3183                tags: vec![],
3184                priority: "Normal".to_string(),
3185            },
3186        )
3187        .await;
3188        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3189        assert_eq!(parsed["ok"], true);
3190
3191        // Verify BOTH files lost the edge
3192        {
3193            let graph = graph_arc.read().await;
3194            assert!(
3195                !graph
3196                    .neighbors("file:src/a.rs", &EdgeKind::HasGotcha)
3197                    .contains(&"gotcha:multi-file-tombstone".to_string()),
3198                "file:src/a.rs must NOT have HasGotcha edge after delete"
3199            );
3200            assert!(
3201                !graph
3202                    .neighbors("file:src/b.rs", &EdgeKind::HasGotcha)
3203                    .contains(&"gotcha:multi-file-tombstone".to_string()),
3204                "file:src/b.rs must NOT have HasGotcha edge after delete"
3205            );
3206        }
3207    }
3208
3209    // ── Regression: confirm is non-idempotent ────────────────────────────
3210
3211    /// Calling confirm twice must increment confirmation_count each time,
3212    /// proving `idempotent_hint = false` is correct for mem_set.
3213    #[tokio::test]
3214    async fn test_confirm_is_non_idempotent() {
3215        let dir = TempDir::new().unwrap();
3216        let store = Store::open(dir.path()).await.unwrap();
3217
3218        let file_record = Record::layer0_file_stub("file:src/idem.rs", device_id(), 1, now());
3219        store.put("file:src/idem.rs", &file_record).await.unwrap();
3220
3221        let graph = Graph::load(store).await.unwrap();
3222        let graph_arc = std::sync::Arc::new(tokio::sync::RwLock::new(graph));
3223
3224        // Write an unconfirmed gotcha
3225        let _ = call_mem_set(
3226            &graph_arc,
3227            MemSetParams {
3228                action: "write".to_string(),
3229                key: "gotcha:idem-test".to_string(),
3230                value: "Idempotency test rule".to_string(),
3231                category: "Gotcha".to_string(),
3232                payload: serde_json::json!({
3233                    "rule": "Idempotency test",
3234                    "reason": "testing",
3235                    "severity": "Normal",
3236                    "affected_files": ["src/idem.rs"],
3237                    "ref_url": null,
3238                    "discovered_session": 0,
3239                    "confirmed": false
3240                }),
3241                tags: vec![],
3242                priority: "Normal".to_string(),
3243            },
3244        )
3245        .await;
3246
3247        // First confirm
3248        let r1 = call_mem_set(
3249            &graph_arc,
3250            MemSetParams {
3251                action: "confirm".to_string(),
3252                key: "gotcha:idem-test".to_string(),
3253                value: String::new(),
3254                category: String::new(),
3255                payload: serde_json::json!({}),
3256                tags: vec![],
3257                priority: "Normal".to_string(),
3258            },
3259        )
3260        .await;
3261        let p1: serde_json::Value = serde_json::from_str(&r1).unwrap();
3262        assert_eq!(p1["ok"], true, "first confirm must succeed");
3263        assert_eq!(p1["confirmed"], true);
3264
3265        // Read confirmation_count after first confirm
3266        let count_after_first = {
3267            let graph = graph_arc.read().await;
3268            let record = graph
3269                .store()
3270                .get("gotcha:idem-test")
3271                .await
3272                .unwrap()
3273                .unwrap();
3274            record.confidence.confirmation_count
3275        };
3276
3277        // Second confirm
3278        let r2 = call_mem_set(
3279            &graph_arc,
3280            MemSetParams {
3281                action: "confirm".to_string(),
3282                key: "gotcha:idem-test".to_string(),
3283                value: String::new(),
3284                category: String::new(),
3285                payload: serde_json::json!({}),
3286                tags: vec![],
3287                priority: "Normal".to_string(),
3288            },
3289        )
3290        .await;
3291        let p2: serde_json::Value = serde_json::from_str(&r2).unwrap();
3292        assert_eq!(p2["ok"], true, "second confirm must succeed");
3293
3294        // Read confirmation_count after second confirm
3295        let count_after_second = {
3296            let graph = graph_arc.read().await;
3297            let record = graph
3298                .store()
3299                .get("gotcha:idem-test")
3300                .await
3301                .unwrap()
3302                .unwrap();
3303            record.confidence.confirmation_count
3304        };
3305
3306        assert!(
3307            count_after_second > count_after_first,
3308            "confirmation_count must increase on each confirm: first={count_after_first}, second={count_after_second}"
3309        );
3310    }
3311
3312    // ── Regression: store-read-error refusal (extracted helper) ───────────
3313
3314    /// The Err(e) branch must return a structured JSON error and refuse to write.
3315    /// Previously untestable because it required a real store failure.
3316    #[test]
3317    fn test_store_read_error_refuses_write() {
3318        let result = resolve_existing_for_write(Err(anyhow::anyhow!("simulated disk I/O timeout")));
3319        assert!(result.is_err(), "store error must refuse write");
3320        let err = result.unwrap_err();
3321        let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
3322        assert!(
3323            parsed["error"]
3324                .as_str()
3325                .unwrap()
3326                .contains("store read failed"),
3327            "error must mention store read failure"
3328        );
3329        assert!(
3330            parsed["error"]
3331                .as_str()
3332                .unwrap()
3333                .contains("simulated disk I/O timeout"),
3334            "error must include the underlying cause"
3335        );
3336    }
3337
3338    /// Ok(None) must pass through — new record, no existing data to preserve.
3339    #[test]
3340    fn test_store_read_ok_none_passes_through() {
3341        let result = resolve_existing_for_write(Ok(None));
3342        assert!(result.is_ok());
3343        assert!(result.unwrap().is_none());
3344    }
3345
3346    /// Ok(Some(record)) must pass through — existing record for preservation.
3347    #[test]
3348    fn test_store_read_ok_some_passes_through() {
3349        let record = make_record("file:test.rs", "test", Category::File, 0.5);
3350        let result = resolve_existing_for_write(Ok(Some(record.clone())));
3351        assert!(result.is_ok());
3352        let resolved = result.unwrap();
3353        assert!(resolved.is_some());
3354        assert_eq!(resolved.unwrap().key, "file:test.rs");
3355    }
3356
3357    #[tokio::test]
3358    async fn bootstrap_highest_impact_section_appears() {
3359        let dir = TempDir::new().unwrap();
3360        let store = Store::open(dir.path()).await.unwrap();
3361
3362        // Create a critical-blast-radius file record
3363        let fr_critical = FileRecord {
3364            path: "src/core.rs".to_string(),
3365            purpose: "Core module".to_string(),
3366            entry_points: vec![],
3367            imports: vec![],
3368            gotcha_keys: vec![],
3369            decision_keys: vec![],
3370            todos: vec![],
3371            unsafe_count: 0,
3372            unwrap_count: 0,
3373            change_frequency: 0,
3374            last_author: None,
3375            is_hotspot: false,
3376            token_cost_estimate: 100,
3377            last_modified_session: 0,
3378            content_hash: None,
3379            line_count: 0,
3380            blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
3381                direct: 45,
3382                transitive: 10,
3383                score: 48.0,
3384                tier: crate::analysis::blast_radius::BlastTier::Critical,
3385            }),
3386            propagated_staleness: None,
3387        };
3388        let mut rec = make_record("file:src/core.rs", "Core module", Category::File, 0.5);
3389        rec.payload = serde_json::to_value(&fr_critical).ok();
3390        store.put("file:src/core.rs", &rec).await.unwrap();
3391
3392        // Create a low-blast-radius file record
3393        let fr_low = FileRecord {
3394            path: "src/leaf.rs".to_string(),
3395            purpose: "Leaf module".to_string(),
3396            entry_points: vec![],
3397            imports: vec![],
3398            gotcha_keys: vec![],
3399            decision_keys: vec![],
3400            todos: vec![],
3401            unsafe_count: 0,
3402            unwrap_count: 0,
3403            change_frequency: 0,
3404            last_author: None,
3405            is_hotspot: false,
3406            token_cost_estimate: 100,
3407            last_modified_session: 0,
3408            content_hash: None,
3409            line_count: 0,
3410            blast_radius: Some(crate::analysis::blast_radius::BlastRadius {
3411                direct: 3,
3412                transitive: 0,
3413                score: 3.0,
3414                tier: crate::analysis::blast_radius::BlastTier::Low,
3415            }),
3416            propagated_staleness: None,
3417        };
3418        let mut rec2 = make_record("file:src/leaf.rs", "Leaf module", Category::File, 0.5);
3419        rec2.payload = serde_json::to_value(&fr_low).ok();
3420        store.put("file:src/leaf.rs", &rec2).await.unwrap();
3421
3422        let graph = Graph::load(store).await.unwrap();
3423        let packet = assemble_context_packet(
3424            graph.store(),
3425            &graph,
3426            &["src/core.rs".to_string(), "src/leaf.rs".to_string()],
3427        )
3428        .await
3429        .unwrap();
3430
3431        assert!(
3432            packet.injection_string.contains("Highest Impact"),
3433            "bootstrap must include highest impact section, got: {}",
3434            packet.injection_string
3435        );
3436        assert!(
3437            packet.injection_string.contains("src/core.rs"),
3438            "critical file must appear in impact section"
3439        );
3440        // core.rs (score 48) should appear before leaf.rs (score 3)
3441        let core_pos = packet.injection_string.find("src/core.rs").unwrap();
3442        let leaf_pos = packet
3443            .injection_string
3444            .find("src/leaf.rs")
3445            .unwrap_or(usize::MAX);
3446        assert!(
3447            core_pos < leaf_pos,
3448            "core.rs should appear before leaf.rs in impact section"
3449        );
3450    }
3451
3452    // ── Pass-29 regression: build_mem_set_command routing ──────────────
3453    //
3454    // The Socket-backend mem_set previously dispatched
3455    // "gotcha_confirm" / "gotcha_tombstone" / "mem_set" through the
3456    // legacy v1 mapper (`v1_to_v2_command`) which has no entries for
3457    // those commands and panicked the rmcp task. The fix routes through
3458    // typed Commands; these tests pin the routing logic.
3459    //
3460    // All test inputs are constructed manually instead of via JSON
3461    // parsing — `MemSetParams` is `Deserialize`-only.
3462
3463    fn make_params(action: &str, key: &str) -> MemSetParams {
3464        MemSetParams {
3465            action: action.to_string(),
3466            key: key.to_string(),
3467            value: String::new(),
3468            category: String::new(),
3469            payload: serde_json::Value::Object(serde_json::Map::new()),
3470            tags: vec![],
3471            priority: "Normal".to_string(),
3472        }
3473    }
3474
3475    #[test]
3476    fn mem_set_socket_routes_gotcha_confirm() {
3477        let p = make_params("confirm", "gotcha:foo");
3478        let cmd = build_mem_set_command(&p).expect("must build");
3479        assert_eq!(cmd.kind(), "gotcha_confirm");
3480        assert_eq!(cmd.target_key(), "gotcha:foo");
3481    }
3482
3483    #[test]
3484    fn mem_set_socket_rejects_confirm_on_non_gotcha_key() {
3485        let p = make_params("confirm", "decision:not-allowed");
3486        let err = build_mem_set_command(&p).expect_err("must reject");
3487        assert!(
3488            err.contains("gotcha:"),
3489            "error must mention gotcha: prefix, got: {err}"
3490        );
3491    }
3492
3493    #[test]
3494    fn mem_set_socket_routes_gotcha_tombstone() {
3495        let p = make_params("delete", "gotcha:foo");
3496        let cmd = build_mem_set_command(&p).expect("must build");
3497        assert_eq!(cmd.kind(), "gotcha_tombstone");
3498        assert_eq!(cmd.target_key(), "gotcha:foo");
3499    }
3500
3501    #[test]
3502    fn mem_set_socket_routes_gotcha_upsert_by_key_prefix() {
3503        let mut p = make_params("write", "gotcha:stripe-idempotency");
3504        p.payload = serde_json::json!({
3505            "rule": "Always include an idempotency key",
3506            "reason": "Stripe retries cause double charges without it",
3507            "severity": "High",
3508            "affected_files": ["src/payments/stripe.rs"],
3509        });
3510        p.tags = vec!["payments".into()];
3511        p.priority = "High".into();
3512        let cmd = build_mem_set_command(&p).expect("must build");
3513        assert_eq!(cmd.kind(), "gotcha_upsert");
3514        match cmd {
3515            Command::GotchaUpsert(input) => {
3516                assert_eq!(input.key, "gotcha:stripe-idempotency");
3517                assert_eq!(input.rule, "Always include an idempotency key");
3518                assert_eq!(input.severity, proto::Severity::High);
3519                assert_eq!(input.priority, proto::Priority::High);
3520                assert_eq!(input.affected_files, vec!["src/payments/stripe.rs"]);
3521                assert_eq!(input.tags, vec!["payments".to_string()]);
3522            }
3523            _ => panic!("expected GotchaUpsert"),
3524        }
3525    }
3526
3527    #[test]
3528    fn mem_set_socket_routes_decision_upsert_by_key_prefix() {
3529        let mut p = make_params("write", "decision:retry-strategy");
3530        p.value = "We use exponential backoff because linear overloads downstream".into();
3531        p.payload = serde_json::json!({
3532            "summary": "Exponential backoff for all retries",
3533            "rationale": "Linear retry caused cascading failures in prod 2024-01",
3534        });
3535        let cmd = build_mem_set_command(&p).expect("must build");
3536        assert_eq!(cmd.kind(), "decision_upsert");
3537        match cmd {
3538            Command::DecisionUpsert(input) => {
3539                assert_eq!(input.slug, "retry-strategy");
3540                assert_eq!(input.summary, "Exponential backoff for all retries");
3541                assert!(input.rationale.contains("cascading"));
3542            }
3543            _ => panic!("expected DecisionUpsert"),
3544        }
3545    }
3546
3547    #[test]
3548    fn mem_set_socket_routes_dev_note_upsert_by_key_prefix() {
3549        let mut p = make_params("write", "dev_note:remember-changelog");
3550        p.value = "Remember to update the changelog before release".into();
3551        let cmd = build_mem_set_command(&p).expect("must build");
3552        assert_eq!(cmd.kind(), "dev_note_upsert");
3553        match cmd {
3554            Command::DevNoteUpsert(input) => {
3555                assert_eq!(input.key.as_deref(), Some("dev_note:remember-changelog"));
3556                assert!(input.text.contains("changelog"));
3557            }
3558            _ => panic!("expected DevNoteUpsert"),
3559        }
3560    }
3561
3562    #[test]
3563    fn mem_set_socket_rejects_write_with_unknown_prefix() {
3564        // file: writes are not supported via mem_set Socket path —
3565        // there is no public typed Command for them.
3566        let p = make_params("write", "file:src/main.rs");
3567        let err = build_mem_set_command(&p).expect_err("must reject");
3568        assert!(
3569            err.contains("gotcha:") && err.contains("decision:") && err.contains("dev_note:"),
3570            "error must list valid prefixes, got: {err}"
3571        );
3572    }
3573
3574    #[test]
3575    fn mem_set_socket_rejects_unknown_action() {
3576        let p = make_params("smuggle", "gotcha:foo");
3577        let err = build_mem_set_command(&p).expect_err("must reject");
3578        assert!(
3579            err.contains("smuggle"),
3580            "error must echo the bad action, got: {err}"
3581        );
3582    }
3583
3584    #[test]
3585    fn mem_set_socket_rejects_gotcha_write_missing_payload_fields() {
3586        let p = make_params("write", "gotcha:incomplete");
3587        let err = build_mem_set_command(&p).expect_err("must reject");
3588        assert!(
3589            err.contains("rule"),
3590            "error must mention 'rule', got: {err}"
3591        );
3592    }
3593
3594    #[test]
3595    fn mem_set_socket_handles_codex_string_payload() {
3596        // Codex sends payload as a JSON-encoded string; the typed
3597        // builder must transparently parse it (matching Direct path).
3598        let mut p = make_params("write", "gotcha:codex-style");
3599        p.payload = serde_json::Value::String(
3600            r#"{"rule":"do X","reason":"because Y","severity":"low"}"#.to_string(),
3601        );
3602        let cmd = build_mem_set_command(&p).expect("must build from stringified payload");
3603        match cmd {
3604            Command::GotchaUpsert(input) => {
3605                assert_eq!(input.rule, "do X");
3606                assert_eq!(input.severity, proto::Severity::Low);
3607            }
3608            _ => panic!("expected GotchaUpsert"),
3609        }
3610    }
3611}