Skip to main content

ai_memory/
mcp.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! MCP (Model Context Protocol) server for ai-memory.
5//! Exposes memory operations as tools for any MCP-compatible AI client over stdio JSON-RPC.
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::io::{self, BufRead, Write};
10use std::path::Path;
11use std::sync::Arc;
12use std::time::Instant;
13
14use crate::config::{AppConfig, FeatureTier, RerankerMode, TierConfig};
15use crate::db;
16use crate::embeddings::Embedder;
17use crate::hnsw::VectorIndex;
18use crate::llm::OllamaClient;
19use crate::models::{CandidateCounts, GovernancePolicy, Memory, RecallMeta, RecallTelemetry, Tier};
20use crate::reranker::CrossEncoder;
21use crate::validate;
22
23// --- JSON-RPC types ---
24
25#[derive(Deserialize)]
26struct RpcRequest {
27    jsonrpc: String,
28    id: Option<Value>,
29    method: String,
30    #[serde(default)]
31    params: Value,
32}
33
34#[derive(Debug, Serialize)]
35struct RpcResponse {
36    jsonrpc: String,
37    id: Value,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    result: Option<Value>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    error: Option<RpcError>,
42}
43
44#[derive(Debug, Serialize)]
45struct RpcError {
46    code: i64,
47    message: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    data: Option<Value>,
50}
51
52fn ok_response(id: Value, result: Value) -> RpcResponse {
53    RpcResponse {
54        jsonrpc: "2.0".into(),
55        id,
56        result: Some(result),
57        error: None,
58    }
59}
60
61fn err_response(id: Value, code: i64, message: String) -> RpcResponse {
62    RpcResponse {
63        jsonrpc: "2.0".into(),
64        id,
65        result: None,
66        error: Some(RpcError {
67            code,
68            message,
69            data: None,
70        }),
71    }
72}
73
74/// PR-5 (issue #487): emit an audit event for an MCP `tools/call`
75/// dispatch. Per-handler emissions inside `handle_store` /
76/// `handle_delete` already produce their canonical events; this
77/// helper covers the remaining mutation+recall tool surface so
78/// `audit_emits_at_every_call_site` holds across the matrix.
79fn audit_emit_for_mcp_dispatch(
80    tool_name: &str,
81    arguments: &Value,
82    result: &Result<Value, String>,
83    mcp_client: Option<&str>,
84) {
85    if !crate::audit::is_enabled() {
86        return;
87    }
88    let action = match tool_name {
89        // Skipped — emitted from inside the handler with full target context.
90        "memory_store" | "memory_delete" => return,
91        "memory_recall"
92        | "memory_search"
93        | "memory_get"
94        | "memory_list"
95        | "memory_session_start" => crate::audit::AuditAction::Recall,
96        "memory_update" => crate::audit::AuditAction::Update,
97        "memory_promote" => crate::audit::AuditAction::Promote,
98        "memory_forget" => crate::audit::AuditAction::Forget,
99        "memory_link" => crate::audit::AuditAction::Link,
100        "memory_consolidate" => crate::audit::AuditAction::Consolidate,
101        "memory_pending_approve" => crate::audit::AuditAction::Approve,
102        "memory_pending_reject" => crate::audit::AuditAction::Reject,
103        // Read-only / metadata tools — no audit event.
104        _ => return,
105    };
106    let agent_id = arguments
107        .get("agent_id")
108        .and_then(Value::as_str)
109        .map(str::to_string)
110        .unwrap_or_else(|| {
111            mcp_client
112                .map(|c| format!("ai:{c}"))
113                .unwrap_or_else(|| "anonymous".into())
114        });
115    let namespace = arguments
116        .get("namespace")
117        .and_then(Value::as_str)
118        .unwrap_or("global")
119        .to_string();
120    let memory_id = arguments
121        .get("id")
122        .or_else(|| arguments.get("memory_id"))
123        .and_then(Value::as_str)
124        .unwrap_or("*")
125        .to_string();
126    let mut builder = crate::audit::EventBuilder::new(
127        action,
128        crate::audit::actor(
129            agent_id,
130            mcp_client.map_or("host_fallback", |_| "mcp_client_info"),
131            None,
132        ),
133        crate::audit::AuditTarget {
134            memory_id,
135            namespace,
136            title: None,
137            tier: None,
138            scope: None,
139        },
140    );
141    if let Err(e) = result {
142        builder = builder.error(e.clone());
143    }
144    crate::audit::emit(builder);
145}
146
147// --- Tool definitions ---
148
149/// Version tag for the `tools/list` response schema. Bumped whenever
150/// an existing tool's shape changes in a breaking way (renamed params,
151/// tightened schemas, removed options). Adding a new tool is additive
152/// and does NOT require a bump. Ultrareview #351.
153const TOOLS_VERSION: &str = "2026-04-26";
154
155/// v0.6.4-006 — Build the `families` overview included in the v2
156/// `memory_capabilities` response. Each entry carries:
157///
158/// - `name` — family identifier (`core`, `graph`, …)
159/// - `tool_count` — expected tool count per the family map
160/// - `loaded` — whether the family is loaded under the active profile
161/// - `tools` — the canonical tool-name list for that family
162///
163/// This is the v0.6.4 NHI runtime-discovery surface: an agent reading
164/// the response sees which families are reachable AND can decide which
165/// to opt into (via `memory_capabilities --include-schema family=<f>`)
166/// without restarting the MCP server.
167pub(crate) fn families_overview(profile: &crate::profile::Profile) -> Value {
168    use crate::profile::Family;
169    let defs = tool_definitions();
170    let all_tools = defs
171        .get("tools")
172        .and_then(Value::as_array)
173        .cloned()
174        .unwrap_or_default();
175    let entries: Vec<Value> = Family::all()
176        .iter()
177        .map(|fam| {
178            let tools_in_family: Vec<&str> = all_tools
179                .iter()
180                .filter_map(|t| t.get("name").and_then(Value::as_str))
181                .filter(|n| Family::for_tool(n) == Some(*fam))
182                .collect();
183            json!({
184                "name": fam.name(),
185                "tool_count": tools_in_family.len(),
186                "loaded": profile.includes(*fam),
187                "tools": tools_in_family,
188            })
189        })
190        .collect();
191    json!({
192        "schema_version": "v0.6.4-families-1",
193        "always_on": crate::profile::ALWAYS_ON_TOOLS,
194        "families": entries,
195    })
196}
197
198/// v0.6.4-006 — Handle `memory_capabilities` invocations that pass a
199/// `family=<name>` parameter. When `include_schema=false` (default),
200/// returns the canonical tool-name list. When `include_schema=true`,
201/// returns the full MCP-style tool definitions for each tool — the
202/// caller (an NHI agent or a host like Claude Code's deferred-tools
203/// path) can register them at runtime without restarting the server.
204///
205/// v0.6.4-008 — when `include_schema=true` AND the daemon's
206/// `[mcp.allowlist]` is configured, the requesting `agent_id` must be
207/// permitted by the allowlist for the requested family. Permissive
208/// (no-allowlist) default preserves Tier-1 single-process behavior —
209/// operators opt into the gate by writing the table.
210///
211/// Errors:
212/// - Unknown family → `Err` with diagnostic listing valid families.
213/// - Empty family name → `Err`.
214/// - Allowlist deny → `Err` with structured reason.
215pub(crate) fn handle_capabilities_family(
216    family_name: &str,
217    include_schema: bool,
218    profile: &crate::profile::Profile,
219    allowlist_cfg: Option<&crate::config::McpConfig>,
220    agent_id: Option<&str>,
221    audit_conn: Option<&rusqlite::Connection>,
222) -> Result<Value, String> {
223    use crate::profile::Family;
224    if family_name.is_empty() {
225        return Err("memory_capabilities: 'family' must not be empty".to_string());
226    }
227    let family = Family::all()
228        .iter()
229        .find(|f| f.name() == family_name)
230        .copied()
231        .ok_or_else(|| {
232            let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
233            format!(
234                "unknown family '{family_name}'. Valid families: {}.",
235                valid.join(", ")
236            )
237        })?;
238
239    // v0.6.4-008 — allowlist gate, only on the runtime-expansion path.
240    if include_schema && let Some(mcp_cfg) = allowlist_cfg {
241        use crate::config::AllowlistDecision;
242        match mcp_cfg.allowlist_decision(agent_id, family.name()) {
243            AllowlistDecision::Disabled | AllowlistDecision::Allow => {}
244            AllowlistDecision::Deny => {
245                // v0.6.4-009 — record the deny so operators can see
246                // attempted-but-blocked expansion patterns.
247                if let Some(conn) = audit_conn {
248                    crate::db::record_capability_expansion(
249                        conn,
250                        agent_id,
251                        family.name(),
252                        false,
253                        None,
254                    );
255                }
256                return Err(format!(
257                    "agent '{}' is not permitted to expand family '{}' under \
258                     [mcp.allowlist]. Ask an operator to add a matching rule \
259                     to config.toml or pass an allowed agent_id.",
260                    agent_id.unwrap_or("<anonymous>"),
261                    family.name()
262                ));
263            }
264        }
265    }
266
267    // v0.6.4-009 — record the grant on the include_schema=true path.
268    // Lightweight name-list calls are not audited (they're informational
269    // only — no schema material released).
270    if include_schema && let Some(conn) = audit_conn {
271        crate::db::record_capability_expansion(conn, agent_id, family.name(), true, None);
272    }
273
274    let defs = tool_definitions();
275    let all_tools = defs
276        .get("tools")
277        .and_then(Value::as_array)
278        .cloned()
279        .unwrap_or_default();
280    let in_family: Vec<Value> = all_tools
281        .into_iter()
282        .filter(|t| {
283            t.get("name")
284                .and_then(Value::as_str)
285                .and_then(Family::for_tool)
286                == Some(family)
287        })
288        .collect();
289
290    if include_schema {
291        Ok(json!({
292            "schema_version": "v0.6.4-family-schemas-1",
293            "family": family.name(),
294            "loaded_under_active_profile": profile.includes(family),
295            "tools": in_family,
296        }))
297    } else {
298        let names: Vec<&str> = in_family
299            .iter()
300            .filter_map(|t| t.get("name").and_then(Value::as_str))
301            .collect();
302        Ok(json!({
303            "schema_version": "v0.6.4-family-list-1",
304            "family": family.name(),
305            "loaded_under_active_profile": profile.includes(family),
306            "tools": names,
307        }))
308    }
309}
310
311/// v0.6.4-002 — Filter `tool_definitions()` down to the tools loaded
312/// under `profile`. Tools whose family is not in the profile's family
313/// list are dropped from `tools[]`. `memory_capabilities` and any
314/// other [`crate::profile::ALWAYS_ON_TOOLS`] are kept regardless of
315/// profile so the runtime-discovery dance still works on
316/// `--profile core`.
317pub(crate) fn tool_definitions_for_profile(profile: &crate::profile::Profile) -> Value {
318    let mut defs = tool_definitions();
319    if let Some(arr) = defs.get_mut("tools").and_then(|t| t.as_array_mut()) {
320        arr.retain(|tool| {
321            tool.get("name")
322                .and_then(Value::as_str)
323                .is_some_and(|name| profile.loads(name))
324        });
325    }
326    defs
327}
328
329#[allow(clippy::too_many_lines)]
330pub(crate) fn tool_definitions() -> Value {
331    json!({
332        "toolsVersion": TOOLS_VERSION,
333        "tools": [
334            {
335                "name": "memory_store",
336                "description": "Store a new memory. Deduplicates by title+namespace.",
337                "inputSchema": {
338                    "type": "object",
339                    "properties": {
340                        "title": {"type": "string", "description": "Short descriptive title"},
341                        "content": {"type": "string", "description": "Full memory content"},
342                        "tier": {"type": "string", "enum": ["short", "mid", "long"], "default": "mid"},
343                        "namespace": {"type": "string", "description": "Project/topic namespace"},
344                        "tags": {"type": "array", "items": {"type": "string"}, "default": []},
345                        "priority": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
346                        "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0, "default": 1.0},
347                        "source": {"type": "string", "enum": ["user", "claude", "hook", "api", "cli", "import", "consolidation", "system", "chaos"], "default": "claude"},
348                        "metadata": {"type": "object", "description": "Arbitrary JSON metadata", "default": {}},
349                        "agent_id": {"type": "string", "description": "Agent identifier. If omitted, the server synthesizes an NHI-hardened default (ai:<client>@<host>:pid-<pid>, host:<host>:pid-<pid>-<uuid8>, or anonymous:pid-<pid>-<uuid8>)."},
350                        "scope": {"type": "string", "enum": ["private", "team", "unit", "org", "collective"], "description": "Task 1.5 visibility scope. Defaults to private when unset. Stored as metadata.scope."},
351                        "on_conflict": {"type": "string", "enum": ["error", "merge", "version"], "description": "v0.6.3.1 P2 (G6) — collision policy when (title, namespace) already exists. 'error' returns CONFLICT (default for v2-capable clients). 'merge' updates the existing row in place (legacy v0.6.3 behaviour, default for v1 clients). 'version' appends a monotonic suffix to the title — 'My Memory (2)', '(3)', ..."}
352                    },
353                    "required": ["title", "content"]
354                }
355            },
356            {
357                "name": "memory_recall",
358                "description": "Recall memories relevant to a context. Uses fuzzy OR matching, ranks by relevance + priority + access frequency + tier.",
359                "inputSchema": {
360                    "type": "object",
361                    "properties": {
362                        "context": {"type": "string", "description": "What you're trying to remember"},
363                        "namespace": {"type": "string", "description": "Filter by namespace"},
364                        "limit": {"type": "integer", "default": 10, "maximum": 50},
365                        "tags": {"type": "string", "description": "Filter by tag"},
366                        "since": {"type": "string", "description": "Only memories created after this RFC3339 timestamp"},
367                        "until": {"type": "string", "description": "Only memories created before this RFC3339 timestamp"},
368                        "as_agent": {"type": "string", "description": "Querying agent's namespace position (Task 1.5). Enables scope-based visibility filtering — results include private memories at this namespace, team/unit/org memories at ancestor subtrees, and collective memories globally."},
369                        "budget_tokens": {"type": "integer", "minimum": 0, "description": "Phase P6 (R1) — context-budget-aware recall. Return the highest-ranked memories whose cumulative content tokens (deterministic cl100k_base BPE; matches Claude/GPT context accounting) fit in N. If the top-ranked memory alone exceeds the budget, it is returned anyway with meta.budget_overflow=true (R1 always-return-at-least-one guarantee). budget_tokens=0 returns zero memories with overflow=false. Response meta block: budget_tokens_used, budget_tokens_remaining, memories_dropped, budget_overflow."},
370                        "context_tokens": {"type": "array", "items": {"type": "string"}, "description": "v0.6.0.0 contextual recall — recent conversation tokens used to bias the query embedding at 70/30 (primary/context). Pulls results toward memories that match both the explicit query and nearby conversation topics."},
371                        "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact", "description": "Response format. Default 'toon_compact' saves 79% tokens vs JSON. 'toon' includes timestamps. 'json' for structured parsing."}
372                    },
373                    "required": ["context"]
374                }
375            },
376            {
377                "name": "memory_search",
378                "description": "Search memories by exact keyword match (AND semantics).",
379                "inputSchema": {
380                    "type": "object",
381                    "properties": {
382                        "query": {"type": "string"},
383                        "namespace": {"type": "string"},
384                        "tier": {"type": "string", "enum": ["short", "mid", "long"]},
385                        "limit": {"type": "integer", "default": 20, "maximum": 200},
386                        "agent_id": {"type": "string", "description": "Filter by metadata.agent_id (exact match)."},
387                        "as_agent": {"type": "string", "description": "Querying agent's namespace position (Task 1.5) for scope-based visibility filtering."},
388                        "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact", "description": "Response format. Default 'toon_compact' saves 79% tokens. 'json' for structured parsing."}
389                    },
390                    "required": ["query"]
391                }
392            },
393            {
394                "name": "memory_list",
395                "description": "List memories, optionally filtered by namespace or tier.",
396                "inputSchema": {
397                    "type": "object",
398                    "properties": {
399                        "namespace": {"type": "string"},
400                        "tier": {"type": "string", "enum": ["short", "mid", "long"]},
401                        "limit": {"type": "integer", "default": 20, "maximum": 200},
402                        "agent_id": {"type": "string", "description": "Filter by metadata.agent_id (exact match)."},
403                        "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact", "description": "Response format. Default 'toon_compact' saves 79% tokens. 'json' for structured parsing."}
404                    }
405                }
406            },
407            {
408                "name": "memory_get_taxonomy",
409                "description": "Pillar 1 / Stream A — return a hierarchical tree of namespaces with memory counts. Walks the `/`-delimited namespace paths grouped from live memories (expired rows excluded). Each node carries `count` (memories at exactly that namespace) and `subtree_count` (count plus all descendants visible within `depth`); the response also exposes `total_count` for the prefix and a `truncated` flag set when `limit` forced rows to be dropped from the tree.",
410                "inputSchema": {
411                    "type": "object",
412                    "properties": {
413                        "namespace_prefix": {"type": "string", "description": "Restrict the tree to memories at this namespace OR any descendant. Omit to walk the full tree. Trailing '/' is tolerated."},
414                        "depth": {"type": "integer", "minimum": 0, "maximum": 8, "default": 8, "description": "Max levels to descend below the prefix. Memories deeper than this still contribute to `subtree_count` of the boundary ancestor."},
415                        "limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 1000, "description": "Cap on `(namespace, count)` rows walked when assembling the tree. Densest namespaces win when truncated."}
416                    }
417                }
418            },
419            {
420                "name": "memory_check_duplicate",
421                "description": "Pillar 2 / Stream D — pre-write near-duplicate check. Embeds `title + content`, scans live memories with stored embeddings (optionally restricted to `namespace`), and returns the highest-cosine match. `is_duplicate` is `nearest.similarity >= threshold`; the response also surfaces `suggested_merge` (the nearest memory's id) when the threshold is met. Threshold is clamped to a hard floor of 0.5 so permissive callers can't dress unrelated content as a merge candidate. Requires the embedder to be loaded (semantic tier or above).",
422                "inputSchema": {
423                    "type": "object",
424                    "properties": {
425                        "title": {"type": "string", "description": "Title of the candidate memory. Combined with `content` to form the embedding input, matching memory_store's encoding."},
426                        "content": {"type": "string", "description": "Content of the candidate memory."},
427                        "namespace": {"type": "string", "description": "Restrict the duplicate scan to this namespace. Omit to scan all namespaces."},
428                        "threshold": {"type": "number", "minimum": 0.5, "maximum": 1.0, "default": 0.85, "description": "Cosine similarity threshold for declaring a duplicate. Clamped to >= 0.5. Default 0.85 is tuned for MiniLM-L6-v2 — near-paraphrases land at 0.88+."}
429                    },
430                    "required": ["title", "content"]
431                }
432            },
433            {
434                "name": "memory_entity_register",
435                "description": "Pillar 2 / Stream B — register an entity (canonical name + aliases) under a namespace. Entities are stored as long-tier memories tagged 'entity' with metadata.kind='entity', so the (title, namespace) coordinate is shared with regular memories without ambiguity. Idempotent: re-registering the same canonical_name+namespace reuses the existing entity_id and merges any new aliases. Errors when the namespace+canonical_name already names a non-entity memory.",
436                "inputSchema": {
437                    "type": "object",
438                    "properties": {
439                        "canonical_name": {"type": "string", "description": "Display name for the entity. Stored as the entity memory's title."},
440                        "namespace": {"type": "string", "description": "Namespace under which the entity lives. Hierarchy paths (e.g. 'projects/alpha') are accepted."},
441                        "aliases": {"type": "array", "items": {"type": "string"}, "description": "Aliases that should resolve to this entity. Blank entries are skipped; duplicates are de-duped via the entity_aliases primary key."},
442                        "metadata": {"type": "object", "description": "Arbitrary metadata to attach to the entity memory. Caller-supplied 'kind' is overwritten with 'entity'; agent_id is stamped from the NHI caller when not specified."},
443                        "agent_id": {"type": "string", "description": "Override the caller's resolved NHI for the entity memory's metadata.agent_id."}
444                    },
445                    "required": ["canonical_name", "namespace"]
446                }
447            },
448            {
449                "name": "memory_entity_get_by_alias",
450                "description": "Pillar 2 / Stream B — resolve an alias to its registered entity. When 'namespace' is provided, only entities in that namespace are returned. When omitted, the most recently created matching entity wins. Returns null when no entity claims the alias under the given filter.",
451                "inputSchema": {
452                    "type": "object",
453                    "properties": {
454                        "alias": {"type": "string", "description": "Alias string to resolve. Whitespace is trimmed."},
455                        "namespace": {"type": "string", "description": "Restrict the resolution to this namespace. Omit to search all namespaces."}
456                    },
457                    "required": ["alias"]
458                }
459            },
460            {
461                "name": "memory_kg_timeline",
462                "description": "Pillar 2 / Stream C — ordered fact timeline for an entity. Returns outbound links from `source_id` (e.g. an entity registered via memory_entity_register) with their temporal-validity columns (valid_from, valid_until, observed_by) and the target memory's title/namespace. Events are ordered by valid_from ASC; rows with NULL valid_from are excluded. Cross-namespace by design — callers can post-filter by target_namespace if needed.",
463                "inputSchema": {
464                    "type": "object",
465                    "properties": {
466                        "source_id": {"type": "string", "description": "Memory ID whose outbound assertions form the timeline. Typically an entity_id from memory_entity_register, but any memory works."},
467                        "since": {"type": "string", "description": "RFC3339 timestamp; events with valid_from earlier than this are excluded (inclusive boundary)."},
468                        "until": {"type": "string", "description": "RFC3339 timestamp; events with valid_from later than this are excluded (inclusive boundary)."},
469                        "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 200, "description": "Max events returned. Clamped to [1, 1000]."}
470                    },
471                    "required": ["source_id"]
472                }
473            },
474            {
475                "name": "memory_kg_invalidate",
476                "description": "Pillar 2 / Stream C — mark a KG link as superseded by setting its `valid_until` column. The link is identified by the (source_id, target_id, relation) triple (memory_links has no separate id column). When `valid_until` is omitted, the current wall-clock time is used. Idempotent: repeated calls overwrite the prior value and the response reports `previous_valid_until` so callers can detect the overwrite. Returns `found: false` when no link matches the triple.",
477                "inputSchema": {
478                    "type": "object",
479                    "properties": {
480                        "source_id": {"type": "string", "description": "Source memory ID of the link to invalidate."},
481                        "target_id": {"type": "string", "description": "Target memory ID of the link to invalidate."},
482                        "relation": {"type": "string", "description": "Relation label of the link (e.g. 'related_to', 'supersedes', 'derived_from'). Must be a recognized relation."},
483                        "valid_until": {"type": "string", "description": "RFC3339 timestamp marking when the assertion stops being valid. Defaults to the current time when omitted."}
484                    },
485                    "required": ["source_id", "target_id", "relation"]
486                }
487            },
488            {
489                "name": "memory_kg_query",
490                "description": "Pillar 2 / Stream C — outbound KG traversal from a source memory. Returns one node per link reachable from `source_id` within `max_depth` hops, with the link's temporal-validity columns (valid_from, valid_until, observed_by) and the target memory's title/namespace. Multi-hop traversal uses a recursive CTE with cycle detection — chains only extend through links that pass every filter on every hop. Filters: `valid_at` keeps only links valid at that instant; `allowed_agents` keeps only links observed by an agent in the set (empty list returns zero rows by design — empty allowlist means 'no agents are trusted'). Ordered by depth ASC, then COALESCE(valid_from, created_at) ASC, for stable shallow-first display. `max_depth` ceiling is 5 (matches the published performance budget); larger values return an explicit error.",
491                "inputSchema": {
492                    "type": "object",
493                    "properties": {
494                        "source_id": {"type": "string", "description": "Memory ID whose outbound links form the traversal frontier. Typically an entity_id from memory_entity_register, but any memory works."},
495                        "max_depth": {"type": "integer", "minimum": 1, "maximum": 5, "default": 1, "description": "Hops from the source. Supported range: 1..=5 (matches the published performance budget for `memory_kg_query`). Larger values return an explicit error."},
496                        "valid_at": {"type": "string", "description": "RFC3339 timestamp; only links valid at this instant (valid_from <= valid_at AND (valid_until IS NULL OR valid_until > valid_at)) are returned. Omit to skip the temporal filter (NULL valid_from rows are then included)."},
497                        "allowed_agents": {"type": "array", "items": {"type": "string"}, "description": "If provided, only links whose observed_by is in this set are returned. An empty array returns zero rows. Omit to skip the agent filter."},
498                        "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 200, "description": "Max nodes returned across all depths. Clamped to [1, 1000]."}
499                    },
500                    "required": ["source_id"]
501                }
502            },
503            {
504                "name": "memory_delete",
505                "description": "Delete a memory by ID.",
506                "inputSchema": {
507                    "type": "object",
508                    "properties": {
509                        "id": {"type": "string"}
510                    },
511                    "required": ["id"]
512                }
513            },
514            {
515                "name": "memory_promote",
516                "description": "Promote a memory. Default: bump tier to long-term (permanent, clears expiry). Task 1.7: when 'to_namespace' is supplied, clone the memory to a hierarchical-ancestor namespace and link clone → source with 'derived_from'. Original is untouched.",
517                "inputSchema": {
518                    "type": "object",
519                    "properties": {
520                        "id": {"type": "string"},
521                        "to_namespace": {"type": "string", "description": "Task 1.7: hierarchical-ancestor namespace to clone this memory into. Must be a proper ancestor (per namespace_ancestors()). Original memory stays put; a new memory with derived_from link is created at the target namespace."}
522                    },
523                    "required": ["id"]
524                }
525            },
526            {
527                "name": "memory_forget",
528                "description": "Bulk delete memories matching a pattern, namespace, or tier. Archives before deletion. Use dry_run to preview.",
529                "inputSchema": {
530                    "type": "object",
531                    "properties": {
532                        "namespace": {"type": "string"},
533                        "pattern": {"type": "string"},
534                        "tier": {"type": "string", "enum": ["short", "mid", "long"]},
535                        "dry_run": {"type": "boolean", "default": false, "description": "If true, report what would be deleted without deleting"}
536                    }
537                }
538            },
539            {
540                "name": "memory_stats",
541                "description": "Get memory store statistics.",
542                "inputSchema": { "type": "object", "properties": {} }
543            },
544            {
545                "name": "memory_update",
546                "description": "Update an existing memory by ID. Only provided fields are changed.",
547                "inputSchema": {
548                    "type": "object",
549                    "properties": {
550                        "id": {"type": "string", "description": "Memory ID to update"},
551                        "title": {"type": "string"},
552                        "content": {"type": "string"},
553                        "tier": {"type": "string", "enum": ["short", "mid", "long"]},
554                        "namespace": {"type": "string"},
555                        "tags": {"type": "array", "items": {"type": "string"}},
556                        "priority": {"type": "integer", "minimum": 1, "maximum": 10},
557                        "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
558                        "expires_at": {"type": "string", "description": "Expiry timestamp (RFC3339), or null to clear"},
559                        "metadata": {"type": "object", "description": "Arbitrary JSON metadata"}
560                    },
561                    "required": ["id"]
562                }
563            },
564            {
565                "name": "memory_get",
566                "description": "Get a specific memory by ID, including its links.",
567                "inputSchema": {
568                    "type": "object",
569                    "properties": {
570                        "id": {"type": "string", "description": "Memory ID to retrieve"}
571                    },
572                    "required": ["id"]
573                }
574            },
575            {
576                "name": "memory_link",
577                "description": "Create a link between two memories.",
578                "inputSchema": {
579                    "type": "object",
580                    "properties": {
581                        "source_id": {"type": "string", "description": "Source memory ID"},
582                        "target_id": {"type": "string", "description": "Target memory ID"},
583                        "relation": {"type": "string", "enum": ["related_to", "supersedes", "contradicts", "derived_from"], "default": "related_to"}
584                    },
585                    "required": ["source_id", "target_id"]
586                }
587            },
588            {
589                "name": "memory_get_links",
590                "description": "Get all links for a memory (both directions).",
591                "inputSchema": {
592                    "type": "object",
593                    "properties": {
594                        "id": {"type": "string", "description": "Memory ID to get links for"}
595                    },
596                    "required": ["id"]
597                }
598            },
599            {
600                "name": "memory_consolidate",
601                "description": "Consolidate multiple memories into one long-term summary. Deletes source memories and creates derived_from links. If summary is omitted and LLM is available (smart/autonomous tier), auto-generates a summary.",
602                "inputSchema": {
603                    "type": "object",
604                    "properties": {
605                        "ids": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 100, "description": "Memory IDs to consolidate (minimum 2, maximum 100)"},
606                        "title": {"type": "string", "description": "Title for the consolidated memory"},
607                        "summary": {"type": "string", "description": "Summary content (optional — auto-generated via LLM if omitted at smart/autonomous tier)"},
608                        "namespace": {"type": "string", "default": "global"}
609                    },
610                    "required": ["ids", "title"]
611                }
612            },
613            {
614                "name": "memory_capabilities",
615                "description": "Report the active feature tier, loaded models, and available capabilities of the memory system. Returns capabilities schema v2 by default (recommended). Pass accept=\"v1\" for the legacy shape used before v0.6.3.1. v0.6.4 — pass family=<name> to enumerate that family's tools; add include_schema=true to retrieve full schemas inline (NHI runtime-expansion path).",
616                "inputSchema": {
617                    "type": "object",
618                    "properties": {
619                        "accept": {
620                            "type": "string",
621                            "enum": ["v1", "v2"],
622                            "default": "v2",
623                            "description": "Capabilities-schema version. v2 is the honest, runtime-overlaid shape (default). v1 returns the legacy pre-v0.6.3.1 shape for backward compat."
624                        },
625                        "family": {
626                            "type": "string",
627                            "enum": ["core", "lifecycle", "graph", "governance", "power", "meta", "archive", "other"],
628                            "description": "v0.6.4 — when set, returns the tool list (or full schemas with include_schema=true) for that family instead of the global capabilities document. Used by NHI agents to opt into a tool family at runtime without restarting the MCP server."
629                        },
630                        "include_schema": {
631                            "type": "boolean",
632                            "default": false,
633                            "description": "v0.6.4 — when true, return full MCP-style tool definitions for each tool in the requested family. Requires family=<name>."
634                        }
635                    }
636                }
637            },
638            {
639                "name": "memory_expand_query",
640                "description": "Use LLM to expand a search query into additional semantically related terms. Requires smart or autonomous tier.",
641                "inputSchema": {
642                    "type": "object",
643                    "properties": {
644                        "query": {"type": "string", "description": "The search query to expand"}
645                    },
646                    "required": ["query"]
647                }
648            },
649            {
650                "name": "memory_auto_tag",
651                "description": "Use LLM to auto-generate tags for a memory. Requires smart or autonomous tier.",
652                "inputSchema": {
653                    "type": "object",
654                    "properties": {
655                        "id": {"type": "string", "description": "Memory ID to auto-tag"}
656                    },
657                    "required": ["id"]
658                }
659            },
660            {
661                "name": "memory_detect_contradiction",
662                "description": "Use LLM to check if two memories contradict each other. Requires smart or autonomous tier.",
663                "inputSchema": {
664                    "type": "object",
665                    "properties": {
666                        "id_a": {"type": "string", "description": "First memory ID"},
667                        "id_b": {"type": "string", "description": "Second memory ID"}
668                    },
669                    "required": ["id_a", "id_b"]
670                }
671            },
672            {
673                "name": "memory_archive_list",
674                "description": "List archived (expired) memories. Archived memories are preserved before GC deletion.",
675                "inputSchema": {
676                    "type": "object",
677                    "properties": {
678                        "namespace": {"type": "string", "description": "Filter by namespace"},
679                        "limit": {"type": "integer", "description": "Max results (default 50, max 1000)"},
680                        "offset": {"type": "integer", "description": "Pagination offset"}
681                    }
682                }
683            },
684            {
685                "name": "memory_archive_restore",
686                "description": "Restore an archived memory back to the active memory store (expires_at cleared).",
687                "inputSchema": {
688                    "type": "object",
689                    "properties": {
690                        "id": {"type": "string", "description": "ID of the archived memory to restore"}
691                    },
692                    "required": ["id"]
693                }
694            },
695            {
696                "name": "memory_archive_purge",
697                "description": "Permanently delete archived memories. Optionally only those older than N days.",
698                "inputSchema": {
699                    "type": "object",
700                    "properties": {
701                        "older_than_days": {"type": "integer", "description": "Only purge entries archived more than N days ago. Omit to purge all."}
702                    }
703                }
704            },
705            {
706                "name": "memory_archive_stats",
707                "description": "Show archive statistics: total count and breakdown by namespace.",
708                "inputSchema": {
709                    "type": "object",
710                    "properties": {}
711                }
712            },
713            {
714                "name": "memory_gc",
715                "description": "Trigger garbage collection on expired memories. Archives them before deletion. Supports dry_run mode.",
716                "inputSchema": {
717                    "type": "object",
718                    "properties": {
719                        "dry_run": {"type": "boolean", "default": false, "description": "If true, report what would be collected without deleting"}
720                    }
721                }
722            },
723            {
724                "name": "memory_session_start",
725                "description": "Auto-recall recent memories on session start. Returns the most recently accessed/updated memories. If LLM is available (smart/autonomous tier), returns an AI-generated summary.",
726                "inputSchema": {
727                    "type": "object",
728                    "properties": {
729                        "namespace": {"type": "string", "description": "Optional namespace to scope recall"},
730                        "limit": {"type": "integer", "default": 10, "maximum": 50},
731                        "format": {"type": "string", "enum": ["json", "toon", "toon_compact"], "default": "toon_compact"}
732                    }
733                }
734            },
735            {
736                "name": "memory_namespace_set_standard",
737                "description": "Set a memory as the standard/policy for a namespace. Auto-prepended to recall and session_start. Supports rule layering (global '*' + parent chain + namespace). Task 1.8: accepts optional `governance` policy object merged into the standard memory's metadata.",
738                "inputSchema": {
739                    "type": "object",
740                    "properties": {
741                        "namespace": {"type": "string", "description": "Namespace to set the standard for"},
742                        "id": {"type": "string", "description": "Memory ID to use as the standard"},
743                        "parent": {"type": "string", "description": "Optional parent namespace to inherit standards from (rule layering)"},
744                        "governance": {
745                            "type": "object",
746                            "description": "Task 1.8 governance policy. Stored in metadata.governance on the standard memory. Consumed by Task 1.9 enforcement + 1.10 approver types. v0.6.3.1 (P4, G1): adds `inherit` flag controlling parent-namespace policy bubbling.",
747                            "properties": {
748                                "write":    {"type": "string", "enum": ["any", "registered", "owner", "approve"]},
749                                "promote":  {"type": "string", "enum": ["any", "registered", "owner", "approve"]},
750                                "delete":   {"type": "string", "enum": ["any", "registered", "owner", "approve"]},
751                                "approver": {"description": "ApproverType: \"human\" | {\"agent\": \"<id>\"} | {\"consensus\": <n>}"},
752                                "inherit":  {"type": "boolean", "default": true, "description": "v0.6.3.1 (P4, G1): when true (default), missing policy at this namespace falls through to parent in the chain. Set false to opt this subtree out of parent inheritance."}
753                            }
754                        }
755                    },
756                    "required": ["namespace", "id"]
757                }
758            },
759            {
760                "name": "memory_namespace_get_standard",
761                "description": "Get the standard/policy memory for a namespace, if one is set. With inherit=true returns the full N-level resolved chain (Task 1.6).",
762                "inputSchema": {
763                    "type": "object",
764                    "properties": {
765                        "namespace": {"type": "string", "description": "Namespace to get the standard for"},
766                        "inherit": {"type": "boolean", "default": false, "description": "Task 1.6: when true, return the full inheritance chain (global * → ancestors → namespace) as a list instead of the single namespace's standard."}
767                    },
768                    "required": ["namespace"]
769                }
770            },
771            {
772                "name": "memory_namespace_clear_standard",
773                "description": "Clear the standard/policy for a namespace.",
774                "inputSchema": {
775                    "type": "object",
776                    "properties": {
777                        "namespace": {"type": "string", "description": "Namespace to clear the standard for"}
778                    },
779                    "required": ["namespace"]
780                }
781            },
782            {
783                "name": "memory_pending_list",
784                "description": "List pending governance-queued actions (Task 1.9). Filter by status: pending (default) / approved / rejected.",
785                "inputSchema": {
786                    "type": "object",
787                    "properties": {
788                        "status": {"type": "string", "enum": ["pending", "approved", "rejected"]},
789                        "limit":  {"type": "integer", "default": 100, "maximum": 1000}
790                    }
791                }
792            },
793            {
794                "name": "memory_pending_approve",
795                "description": "Approve a pending action by id (Task 1.9). Caller identity is stamped as decided_by.",
796                "inputSchema": {
797                    "type": "object",
798                    "properties": {
799                        "id": {"type": "string", "description": "Pending action id"}
800                    },
801                    "required": ["id"]
802                }
803            },
804            {
805                "name": "memory_pending_reject",
806                "description": "Reject a pending action by id (Task 1.9). Caller identity is stamped as decided_by.",
807                "inputSchema": {
808                    "type": "object",
809                    "properties": {
810                        "id": {"type": "string", "description": "Pending action id"}
811                    },
812                    "required": ["id"]
813                }
814            },
815            {
816                "name": "memory_agent_register",
817                "description": "Register an agent in the reserved _agents namespace. Stores agent_type and capabilities, refreshes last_seen_at on re-registration while preserving registered_at. agent_id is claimed, not attested.",
818                "inputSchema": {
819                    "type": "object",
820                    "properties": {
821                        "agent_id": {"type": "string", "description": "Agent identifier (same validation as metadata.agent_id)"},
822                        "agent_type": {"type": "string", "enum": ["ai:claude-opus-4.6", "ai:claude-opus-4.7", "ai:codex-5.4", "ai:grok-4.2", "human", "system"]},
823                        "capabilities": {"type": "array", "items": {"type": "string"}, "default": [], "description": "Optional capability tags"}
824                    },
825                    "required": ["agent_id", "agent_type"]
826                }
827            },
828            {
829                "name": "memory_agent_list",
830                "description": "List every registered agent.",
831                "inputSchema": {
832                    "type": "object",
833                    "properties": {}
834                }
835            },
836            {
837                "name": "memory_notify",
838                "description": "v0.6.0.0 — send a message from the caller to another agent. Stored as a memory in the reserved `_messages/<target>` namespace with sender metadata. The sender is the caller's resolved agent_id. Target agent reads via `memory_inbox`. Payload is a free-form string.",
839                "inputSchema": {
840                    "type": "object",
841                    "properties": {
842                        "target_agent_id": {"type": "string", "description": "Recipient agent_id (same validation as metadata.agent_id)"},
843                        "title": {"type": "string", "description": "Short subject (≤ 200 chars, required)"},
844                        "payload": {"type": "string", "description": "Message body (required)"},
845                        "priority": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
846                        "tier": {"type": "string", "enum": ["short", "mid", "long"], "default": "mid", "description": "short TTL default = 6h, mid = 7d, long = no expiry"}
847                    },
848                    "required": ["target_agent_id", "title", "payload"]
849                }
850            },
851            {
852                "name": "memory_inbox",
853                "description": "v0.6.0.0 — list messages sent to an agent via memory_notify. Reads the reserved `_messages/<agent_id>` namespace. `access_count == 0` is the conventional unread marker; recalling/reading a memory increments access_count via the normal touch path.",
854                "inputSchema": {
855                    "type": "object",
856                    "properties": {
857                        "agent_id": {"type": "string", "description": "Recipient agent_id. Defaults to the caller's resolved agent_id."},
858                        "unread_only": {"type": "boolean", "default": false, "description": "When true, return only messages with access_count == 0."},
859                        "limit": {"type": "integer", "default": 50, "maximum": 500}
860                    }
861                }
862            },
863            {
864                "name": "memory_subscribe",
865                "description": "v0.6.0.0 — register a webhook subscription. Events fire on memory_store today and additional events in v0.6.1+. Payload is a JSON body signed with HMAC-SHA256 when a secret is supplied (header: X-Ai-Memory-Signature: sha256=<hex>). URL must be https unless the host is a loopback address. The shared secret is stored hashed only; the plaintext the operator supplies is what they verify signatures with.",
866                "inputSchema": {
867                    "type": "object",
868                    "properties": {
869                        "url": {"type": "string", "description": "https:// endpoint (or http:// for loopback). SSRF guard rejects private-range IPs."},
870                        "events": {"type": "string", "default": "*", "description": "Comma-separated event whitelist or `*` for all. Known events: memory_store, memory_delete, memory_promote."},
871                        "secret": {"type": "string", "description": "Optional shared secret for HMAC signing. If omitted, payload is unsigned."},
872                        "namespace_filter": {"type": "string", "description": "Optional exact namespace match."},
873                        "agent_filter": {"type": "string", "description": "Optional agent_id filter — only events whose stored agent_id matches this value will fire."}
874                    },
875                    "required": ["url"]
876                }
877            },
878            {
879                "name": "memory_unsubscribe",
880                "description": "v0.6.0.0 — delete a subscription by id.",
881                "inputSchema": {
882                    "type": "object",
883                    "properties": {
884                        "id": {"type": "string"}
885                    },
886                    "required": ["id"]
887                }
888            },
889            {
890                "name": "memory_list_subscriptions",
891                "description": "v0.6.0.0 — list active webhook subscriptions. Secrets are not exposed; only `secret_hash` is stored and even that is not returned.",
892                "inputSchema": {
893                    "type": "object",
894                    "properties": {}
895                }
896            }
897        ]
898    })
899}
900
901// --- MCP Prompts ---
902
903/// Return the list of available prompts.
904fn prompt_definitions() -> Value {
905    json!({
906        "prompts": [
907            {
908                "name": "recall-first",
909                "description": "System prompt for AI clients: proactive memory recall, TOON format, tier strategy.",
910                "arguments": [
911                    {
912                        "name": "namespace",
913                        "description": "Optional namespace to scope recall (e.g. project name)",
914                        "required": false
915                    }
916                ]
917            },
918            {
919                "name": "memory-workflow",
920                "description": "Quick reference card for memory tool usage patterns."
921            }
922        ]
923    })
924}
925
926/// Return the content of a specific prompt.
927fn prompt_content(name: &str, params: &Value) -> Result<Value, String> {
928    match name {
929        "recall-first" => {
930            let ns_hint = params
931                .get("arguments")
932                .and_then(|a| a.get("namespace"))
933                .and_then(|v| v.as_str())
934                .map(|ns| format!(" Scope recall to namespace \"{ns}\" when relevant."))
935                .unwrap_or_default();
936
937            Ok(json!({
938                "messages": [{
939                    "role": "user",
940                    "content": {
941                        "type": "text",
942                        "text": format!(
943            "You have access to a persistent memory system (ai-memory). Follow these rules:\n\
944            1. RECALL FIRST: At conversation start, call memory_recall with the user's apparent topic. Before answering any question about prior work, recall first.\n\
945            2. STORE LEARNINGS: When the user corrects you or teaches something, call memory_store with tier:long, priority:9.\n\
946            3. TOON FORMAT: All recall/list/search responses default to TOON compact (79% smaller than JSON). Pass format:\"json\" only if you need structured parsing.\n\
947            4. TIERS: short=6h ephemeral, mid=7d working knowledge, long=permanent. Mid auto-promotes to long at 5 accesses.\n\
948            5. DEDUP: Storing with an existing title+namespace updates the existing memory, not a duplicate.\n\
949            6. NAMESPACES: Organize by project/topic. Always pass namespace when storing and recalling.\n\
950            7. CAPABILITIES: Call memory_capabilities once per session to discover available features (tier-dependent).\n\
951            8. TAGS: Use tags for cross-cutting concerns. memory_auto_tag can generate them if available.{ns_hint}")
952                    }
953                }]
954            }))
955        }
956        "memory-workflow" => Ok(json!({
957            "messages": [{
958                "role": "user",
959                "content": {
960                    "type": "text",
961                    "text": "\
962        STORE: memory_store(title, content, tier, namespace, tags, priority) — dedup by title+ns\n\
963        RECALL: memory_recall(context, namespace) → ranked results (TOON compact default)\n\
964        SEARCH: memory_search(query, namespace) → exact AND match (TOON compact default)\n\
965        LIST: memory_list(namespace, tier) → browse with filters (TOON compact default)\n\
966        GET: memory_get(id) → single memory with links\n\
967        PROMOTE: memory_promote(id) — mid→long, clears expiry\n\
968        CONSOLIDATE: memory_consolidate(ids, title) — merge N→1, LLM summary if available\n\
969        LINK: memory_link(source_id, target_id, relation) — related_to|supersedes|contradicts|derived_from\n\
970        TAG: memory_auto_tag(id) — LLM generates tags (smart+ tier)\n\
971        EXPAND: memory_expand_query(query) — LLM broadens search terms (smart+ tier)\n\
972        CONTRADICT: memory_detect_contradiction(id_a, id_b) — LLM checks conflict (smart+ tier)"
973                }
974            }]
975        })),
976        _ => Err(format!("unknown prompt: {name}")),
977    }
978}
979
980// --- Tool handlers ---
981
982/// Minimum content length (bytes) before the post-store autonomy hook
983/// will invoke LLM `auto_tag` / `detect_contradiction`. Below this the
984/// LLM round-trip cost exceeds the informational payoff.
985const AUTONOMY_MIN_CONTENT_LEN: usize = 50;
986
987/// v0.6.3.1 P2 (G6) — `on_conflict` modes for `memory_store`.
988///
989/// * `Error`   — refuse the write with a typed CONFLICT error. This is the
990///               new default for v2-aware clients.
991/// * `Merge`   — keep the v0.6.3 silent-merge upsert behaviour. Default for
992///               v1 / unknown clients to preserve backward compatibility.
993/// * `Version` — auto-suffix the title with `(2)`, `(3)`, ... to write a
994///               distinct row.
995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
996enum OnConflict {
997    Error,
998    Merge,
999    Version,
1000}
1001
1002impl OnConflict {
1003    fn parse(s: &str) -> Result<Self, String> {
1004        match s {
1005            "error" => Ok(Self::Error),
1006            "merge" => Ok(Self::Merge),
1007            "version" => Ok(Self::Version),
1008            other => Err(format!(
1009                "invalid on_conflict '{other}' (expected error|merge|version)"
1010            )),
1011        }
1012    }
1013}
1014
1015/// Capability profile detection. v2-aware clients default to `Error`; v1 /
1016/// unknown clients default to `Merge` to preserve the v0.6.3 contract. The
1017/// determination keys off the MCP client name (captured at `initialize`
1018/// from `clientInfo.name`). Known v2 clients are listed explicitly so the
1019/// policy is auditable. The list is intentionally narrow — adding a name
1020/// here is a deliberate decision that "this client knows how to handle a
1021/// CONFLICT response from memory_store".
1022fn default_on_conflict_for_client(mcp_client: Option<&str>) -> OnConflict {
1023    let Some(client) = mcp_client else {
1024        return OnConflict::Merge;
1025    };
1026    // Match on the prefix before any '@' — `ai:foo@host:pid-N` style ids.
1027    let head = client.split('@').next().unwrap_or(client);
1028    let normalized = head.to_ascii_lowercase();
1029    // v2-capable clients (explicitly opted-in via known name).
1030    const V2_CLIENT_PREFIXES: &[&str] = &["ai:claude-code", "ai:ai-memory-cli/v2"];
1031    for prefix in V2_CLIENT_PREFIXES {
1032        if normalized.starts_with(prefix) {
1033            return OnConflict::Error;
1034        }
1035    }
1036    OnConflict::Merge
1037}
1038
1039#[allow(clippy::too_many_lines)]
1040#[allow(clippy::too_many_arguments)]
1041fn handle_store(
1042    conn: &rusqlite::Connection,
1043    db_path: &Path,
1044    params: &Value,
1045    embedder: Option<&Embedder>,
1046    llm: Option<&OllamaClient>,
1047    vector_index: Option<&VectorIndex>,
1048    resolved_ttl: &crate::config::ResolvedTtl,
1049    autonomous_hooks: bool,
1050    mcp_client: Option<&str>,
1051) -> Result<Value, String> {
1052    let title = params["title"].as_str().ok_or("title is required")?;
1053    let content = params["content"].as_str().ok_or("content is required")?;
1054    let tier_str = params["tier"].as_str().unwrap_or("mid");
1055    let tier = Tier::from_str(tier_str).ok_or(format!("invalid tier: {tier_str}"))?;
1056    let namespace = params["namespace"].as_str().unwrap_or("global").to_string();
1057    let source = params["source"].as_str().unwrap_or("claude").to_string();
1058    // v0.6.3.1 P2 (G6) — explicit `on_conflict` overrides the per-client default.
1059    let on_conflict = if let Some(s) = params["on_conflict"].as_str() {
1060        OnConflict::parse(s)?
1061    } else {
1062        default_on_conflict_for_client(mcp_client)
1063    };
1064    let priority = i32::try_from(params["priority"].as_i64().unwrap_or(5)).expect("i64 as i32");
1065    let confidence = params["confidence"].as_f64().unwrap_or(1.0);
1066    let tags: Vec<String> = params["tags"]
1067        .as_array()
1068        .map(|a| {
1069            a.iter()
1070                .filter_map(|v| v.as_str().map(String::from))
1071                .collect()
1072        })
1073        .unwrap_or_default();
1074
1075    validate::validate_title(title).map_err(|e| e.to_string())?;
1076    validate::validate_content(content).map_err(|e| e.to_string())?;
1077    validate::validate_namespace(&namespace).map_err(|e| e.to_string())?;
1078    validate::validate_source(&source).map_err(|e| e.to_string())?;
1079    validate::validate_tags(&tags).map_err(|e| e.to_string())?;
1080    validate::validate_priority(priority).map_err(|e| e.to_string())?;
1081    validate::validate_confidence(confidence).map_err(|e| e.to_string())?;
1082
1083    let mut metadata = if params["metadata"].is_object() {
1084        params["metadata"].clone()
1085    } else {
1086        serde_json::json!({})
1087    };
1088    // Resolve agent_id via the NHI-hardened precedence chain and merge into
1089    // metadata. Explicit values win in this order:
1090    //   1. top-level `agent_id` param
1091    //   2. embedded `metadata.agent_id` (backward compatible with callers
1092    //      that supply it inline)
1093    //   3. env / MCP clientInfo / host / anonymous (handled inside `identity`)
1094    let explicit_agent_id = params["agent_id"]
1095        .as_str()
1096        .or_else(|| metadata.get("agent_id").and_then(serde_json::Value::as_str));
1097    let agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
1098        .map_err(|e| e.to_string())?;
1099    if let Some(obj) = metadata.as_object_mut() {
1100        obj.insert(
1101            "agent_id".to_string(),
1102            serde_json::Value::String(agent_id.clone()),
1103        );
1104    }
1105    // #151 scope: top-level `scope` param OR inline metadata.scope
1106    let explicit_scope = params["scope"]
1107        .as_str()
1108        .or_else(|| metadata.get("scope").and_then(serde_json::Value::as_str))
1109        .map(str::to_string);
1110    if let Some(ref s) = explicit_scope {
1111        validate::validate_scope(s).map_err(|e| e.to_string())?;
1112        if let Some(obj) = metadata.as_object_mut() {
1113            obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
1114        }
1115    }
1116    validate::validate_metadata(&metadata).map_err(|e| e.to_string())?;
1117
1118    let now = chrono::Utc::now();
1119    let expires_at = resolved_ttl
1120        .ttl_for_tier(&tier)
1121        .map(|s| (now + chrono::Duration::seconds(s)).to_rfc3339());
1122
1123    // v0.6.3.1 P2 (G6) — apply the conflict policy BEFORE building the
1124    // canonical Memory. `Version` mode rewrites `title` to a free suffix;
1125    // `Error` mode short-circuits with a typed error if the row already
1126    // exists; `Merge` defers to the legacy code path below.
1127    let resolved_title = match on_conflict {
1128        OnConflict::Error => {
1129            if let Some(existing_id) =
1130                db::find_by_title_namespace(conn, title, &namespace).map_err(|e| e.to_string())?
1131            {
1132                return Err(format!(
1133                    "CONFLICT: memory with title '{title}' already exists in namespace \
1134                     '{namespace}' (existing id: {existing_id}). Pass \
1135                     on_conflict='merge' to update in place or 'version' to suffix the title."
1136                ));
1137            }
1138            title.to_string()
1139        }
1140        OnConflict::Version => {
1141            db::next_versioned_title(conn, title, &namespace).map_err(|e| e.to_string())?
1142        }
1143        OnConflict::Merge => title.to_string(),
1144    };
1145
1146    let mem = Memory {
1147        id: uuid::Uuid::new_v4().to_string(),
1148        tier,
1149        namespace,
1150        title: resolved_title,
1151        content: content.to_string(),
1152        tags,
1153        priority: priority.clamp(1, 10),
1154        confidence: confidence.clamp(0.0, 1.0),
1155        source,
1156        access_count: 0,
1157        created_at: now.to_rfc3339(),
1158        updated_at: now.to_rfc3339(),
1159        last_accessed_at: None,
1160        expires_at,
1161        metadata,
1162    };
1163
1164    // Task 1.9: governance enforcement (store-side).
1165    {
1166        use crate::models::{GovernanceDecision, GovernedAction};
1167        let payload = serde_json::to_value(&mem).unwrap_or_default();
1168        match db::enforce_governance(
1169            conn,
1170            GovernedAction::Store,
1171            &mem.namespace,
1172            &agent_id,
1173            None,
1174            None,
1175            &payload,
1176        )
1177        .map_err(|e| e.to_string())?
1178        {
1179            GovernanceDecision::Allow => {}
1180            GovernanceDecision::Deny(reason) => {
1181                return Err(format!("store denied by governance: {reason}"));
1182            }
1183            GovernanceDecision::Pending(pending_id) => {
1184                return Ok(json!({
1185                    "status": "pending",
1186                    "pending_id": pending_id,
1187                    "reason": "governance requires approval",
1188                    "action": "store",
1189                    "namespace": mem.namespace,
1190                }));
1191            }
1192        }
1193    }
1194
1195    // True dedup: check for exact title+namespace match (#97).
1196    //
1197    // v0.6.3.1 P2 (G6) — only the Merge policy enters the dedup-then-update
1198    // branch. `Error` mode already short-circuited above; `Version` mode
1199    // already rewrote the title to a free suffix so an exact dup cannot
1200    // exist. Both still call `find_contradictions` so the response can
1201    // surface `potential_contradictions` (similar-title fuzzy matches).
1202    let existing = db::find_contradictions(conn, &mem.title, &mem.namespace).unwrap_or_default();
1203    let exact_dup = if matches!(on_conflict, OnConflict::Merge) {
1204        existing
1205            .iter()
1206            .find(|c| c.title == mem.title && c.namespace == mem.namespace)
1207    } else {
1208        None
1209    };
1210    if let Some(dup) = exact_dup {
1211        // Update existing memory instead of creating a duplicate.
1212        // Preserve the original agent_id (provenance is immutable) — the
1213        // existing memory's metadata.agent_id wins over anything in the
1214        // incoming store.
1215        let preserved_metadata = crate::identity::preserve_agent_id(&dup.metadata, &mem.metadata);
1216        let (_found, content_changed) = db::update(
1217            conn,
1218            &dup.id,
1219            None,                       // title (unchanged)
1220            Some(mem.content.as_str()), // content (update)
1221            Some(&mem.tier),            // tier
1222            None,                       // namespace (unchanged)
1223            Some(&mem.tags),            // tags
1224            Some(mem.priority),         // priority
1225            Some(mem.confidence),       // confidence
1226            None,                       // expires_at
1227            Some(&preserved_metadata),  // metadata (agent_id preserved)
1228        )
1229        .map_err(|e| e.to_string())?;
1230        // Regenerate embedding if content changed during dedup update
1231        if content_changed && let Some(emb) = embedder {
1232            let text = format!("{} {}", mem.title, mem.content);
1233            if let Ok(embedding) = emb.embed(&text) {
1234                let _ = db::set_embedding(conn, &dup.id, &embedding);
1235                if let Some(idx) = vector_index {
1236                    idx.remove(&dup.id);
1237                    idx.insert(dup.id.clone(), embedding);
1238                }
1239            }
1240        }
1241        // #196: echo the preserved agent_id (original on dedup, not the caller's)
1242        let echoed_agent_id = preserved_metadata
1243            .get("agent_id")
1244            .and_then(|v| v.as_str())
1245            .map(str::to_string);
1246        return Ok(json!({
1247            "id": dup.id,
1248            "tier": mem.tier,
1249            "title": mem.title,
1250            "namespace": mem.namespace,
1251            "agent_id": echoed_agent_id,
1252            "duplicate": true,
1253            "action": "updated existing memory"
1254        }));
1255    }
1256
1257    let actual_id = db::insert(conn, &mem).map_err(|e| e.to_string())?;
1258
1259    // PR-5 (issue #487): security audit trail. No-op when disabled.
1260    crate::audit::emit(crate::audit::EventBuilder::new(
1261        crate::audit::AuditAction::Store,
1262        crate::audit::actor(
1263            agent_id.clone(),
1264            mcp_client.map_or("host_fallback", |_| "mcp_client_info"),
1265            explicit_scope.clone(),
1266        ),
1267        crate::audit::target_memory(
1268            actual_id.clone(),
1269            mem.namespace.clone(),
1270            Some(mem.title.clone()),
1271            Some(mem.tier.to_string()),
1272            explicit_scope.clone(),
1273        ),
1274    ));
1275
1276    // Exclude self-ID from contradictions (both proposed and actual, since upsert may reuse existing ID)
1277    let contradiction_ids: Vec<String> = existing
1278        .iter()
1279        .filter(|c| c.id != mem.id && c.id != actual_id)
1280        .map(|c| c.id.clone())
1281        .collect();
1282
1283    // Generate and store embedding if embedder is available
1284    if let Some(emb) = embedder {
1285        let text = format!("{} {}", mem.title, mem.content);
1286        match emb.embed(&text) {
1287            Ok(embedding) => {
1288                if let Err(e) = db::set_embedding(conn, &actual_id, &embedding) {
1289                    tracing::warn!("failed to store embedding for {}: {}", &actual_id, e);
1290                }
1291                // Add to HNSW index for fast ANN search
1292                if let Some(idx) = vector_index {
1293                    idx.insert(actual_id.clone(), embedding);
1294                }
1295            }
1296            Err(e) => {
1297                tracing::warn!("failed to generate embedding for {}: {}", &actual_id, e);
1298            }
1299        }
1300    }
1301
1302    // v0.6.0.0 post-store autonomy hooks. When enabled via
1303    // `AI_MEMORY_AUTONOMOUS_HOOKS=1` or `autonomous_hooks = true` in
1304    // config.toml AND an LLM is wired AND the content is long enough
1305    // to be meaningfully taggable, fire `auto_tag` + `detect_contradiction`
1306    // synchronously and persist the results into the memory's metadata.
1307    // Best-effort: any LLM error is logged and does not fail the store.
1308    // Skipped for internal/system namespaces to avoid feedback loops.
1309    let mut auto_tags: Vec<String> = Vec::new();
1310    let mut confirmed_contradictions: Vec<String> = Vec::new();
1311    let hooks_skipped_reason: Option<&'static str> = if !autonomous_hooks {
1312        Some("disabled")
1313    } else if llm.is_none() {
1314        Some("no_llm")
1315    } else if mem.content.len() < AUTONOMY_MIN_CONTENT_LEN {
1316        Some("content_too_short")
1317    } else if mem.namespace.starts_with('_') {
1318        Some("internal_namespace")
1319    } else {
1320        None
1321    };
1322    if hooks_skipped_reason.is_none()
1323        && let Some(llm_client) = llm
1324    {
1325        match llm_client.auto_tag(&mem.title, &mem.content) {
1326            Ok(tags) => {
1327                auto_tags = tags.into_iter().take(8).collect();
1328            }
1329            Err(e) => {
1330                tracing::warn!("auto_tag hook failed for {}: {}", &actual_id, e);
1331            }
1332        }
1333        for cand in &existing {
1334            if cand.id == actual_id || cand.id == mem.id {
1335                continue;
1336            }
1337            match llm_client.detect_contradiction(&mem.content, &cand.content) {
1338                Ok(true) => confirmed_contradictions.push(cand.id.clone()),
1339                Ok(false) => {}
1340                Err(e) => {
1341                    tracing::warn!(
1342                        "detect_contradiction hook failed ({actual_id} vs {}): {e}",
1343                        cand.id
1344                    );
1345                }
1346            }
1347        }
1348        // Persist hook results into metadata. Best-effort — a failed update
1349        // here does not fail the store (the memory is already committed).
1350        if !auto_tags.is_empty() || !confirmed_contradictions.is_empty() {
1351            let mut updated_metadata = mem.metadata.clone();
1352            if let Some(obj) = updated_metadata.as_object_mut() {
1353                if !auto_tags.is_empty() {
1354                    obj.insert("auto_tags".to_string(), json!(auto_tags));
1355                }
1356                if !confirmed_contradictions.is_empty() {
1357                    obj.insert(
1358                        "confirmed_contradictions".to_string(),
1359                        json!(confirmed_contradictions),
1360                    );
1361                }
1362            }
1363            if let Err(e) = db::update(
1364                conn,
1365                &actual_id,
1366                None,
1367                None,
1368                None,
1369                None,
1370                None,
1371                None,
1372                None,
1373                None,
1374                Some(&updated_metadata),
1375            ) {
1376                tracing::warn!(
1377                    "autonomy-hook metadata update failed for {}: {}",
1378                    &actual_id,
1379                    e
1380                );
1381            }
1382        }
1383    }
1384
1385    // v0.6.0.0: fire webhook subscribers on successful store. Best-effort
1386    // fire-and-forget — each subscriber gets its own OS thread; the
1387    // response here does not wait on any webhook dispatch.
1388    crate::subscriptions::dispatch_event(
1389        conn,
1390        "memory_store",
1391        &actual_id,
1392        &mem.namespace,
1393        Some(&agent_id),
1394        db_path,
1395    );
1396
1397    // #196: echo the resolved agent_id
1398    let mut response = json!({
1399        "id": actual_id,
1400        "tier": mem.tier,
1401        "title": mem.title,
1402        "namespace": mem.namespace,
1403        "agent_id": agent_id,
1404    });
1405    if !contradiction_ids.is_empty() {
1406        response["potential_contradictions"] = json!(contradiction_ids);
1407    }
1408    if !auto_tags.is_empty() {
1409        response["auto_tags"] = json!(auto_tags);
1410    }
1411    if !confirmed_contradictions.is_empty() {
1412        response["confirmed_contradictions"] = json!(confirmed_contradictions);
1413    }
1414    if let Some(reason) = hooks_skipped_reason
1415        && autonomous_hooks
1416    {
1417        response["autonomy_hook_skipped"] = json!(reason);
1418    }
1419    Ok(response)
1420}
1421
1422/// Build the standards-inheritance chain for a namespace, most-general
1423/// first. Task 1.6 extends this from the historical 3-level scheme
1424/// (global → parent → namespace) to N levels by walking the `/`-derived
1425/// ancestors from [`crate::models::namespace_ancestors`] plus any
1426/// `namespace_meta` explicit-parent chain rooted at the top of the
1427/// hierarchical path (which keeps legacy flat-namespace setups working).
1428///
1429/// Returned vector is top-down: `[*, org, unit, team, agent]` for a
1430/// 4-level hierarchical namespace. Cycle-safe and bounded.
1431/// Display-side wrapper around [`db::build_namespace_chain`].
1432///
1433/// v0.6.3.1 (P4, audit G1): the chain walker moved into `db.rs` so the
1434/// governance enforcement gate could share a single canonical
1435/// implementation with the recall/standard injection paths. This thin
1436/// shim keeps existing call sites compiling without re-routing every
1437/// invocation through `db::`.
1438fn build_namespace_chain(conn: &rusqlite::Connection, namespace: &str) -> Vec<String> {
1439    db::build_namespace_chain(conn, namespace)
1440}
1441
1442/// Inject namespace standards into a `recall/session_start` response.
1443/// N-level rule layering: global ("*") → root → ... → namespace-specific.
1444/// Uses [`build_namespace_chain`] to resolve the full ancestor path.
1445fn inject_namespace_standard(
1446    conn: &rusqlite::Connection,
1447    namespace: Option<&str>,
1448    response: &mut Value,
1449) {
1450    let mut standards: Vec<Value> = Vec::new();
1451    let mut standard_ids: Vec<String> = Vec::new();
1452
1453    // Helper: add a standard if not already present (dedup by memory ID)
1454    let add_standard = |std: Value, ids: &mut Vec<String>, stds: &mut Vec<Value>| {
1455        let id = std["id"].as_str().unwrap_or_default().to_string();
1456        if !ids.contains(&id) {
1457            ids.push(id);
1458            stds.push(std);
1459        }
1460    };
1461
1462    let chain = if let Some(ns) = namespace {
1463        build_namespace_chain(conn, ns)
1464    } else {
1465        // No namespace context — only the global standard applies.
1466        vec!["*".to_string()]
1467    };
1468
1469    for link in chain {
1470        if let Some(std) = lookup_namespace_standard(conn, &link) {
1471            add_standard(std, &mut standard_ids, &mut standards);
1472        }
1473    }
1474
1475    if standards.is_empty() {
1476        return;
1477    }
1478
1479    // Deduplicate: remove standard memories from results array
1480    if let Some(memories) = response["memories"].as_array_mut() {
1481        memories.retain(|m| {
1482            let mid = m["id"].as_str().unwrap_or_default();
1483            !standard_ids.iter().any(|sid| sid == mid)
1484        });
1485        response["count"] = json!(memories.len());
1486    }
1487
1488    // Return as single object if one standard, array if multiple
1489    if standards.len() == 1 {
1490        response["standard"] = standards.into_iter().next().unwrap();
1491    } else {
1492        response["standards"] = json!(standards);
1493    }
1494}
1495
1496#[allow(clippy::too_many_arguments)]
1497#[allow(clippy::too_many_lines)]
1498pub fn handle_recall(
1499    conn: &rusqlite::Connection,
1500    params: &Value,
1501    embedder: Option<&Embedder>,
1502    vector_index: Option<&VectorIndex>,
1503    reranker: Option<&CrossEncoder>,
1504    archive_on_gc: bool,
1505    resolved_ttl: &crate::config::ResolvedTtl,
1506    resolved_scoring: &crate::config::ResolvedScoring,
1507) -> Result<Value, String> {
1508    // Helper: serialize scored memories with score field (#95)
1509    fn scored_memories(results: Vec<(Memory, f64)>) -> Vec<Value> {
1510        results
1511            .into_iter()
1512            .map(|(mem, score)| {
1513                let mut val = serde_json::to_value(&mem).unwrap_or_default();
1514                if let Some(obj) = val.as_object_mut() {
1515                    obj.insert(
1516                        "score".to_string(),
1517                        json!((score * 1000.0).round() / 1000.0),
1518                    );
1519                }
1520                val
1521            })
1522            .collect()
1523    }
1524
1525    let _ = db::gc_if_needed(conn, archive_on_gc);
1526    let context = params["context"].as_str().ok_or("context is required")?;
1527    let namespace = params["namespace"].as_str();
1528    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(10)).unwrap_or(usize::MAX);
1529    let tags = params["tags"].as_str();
1530    let since = params["since"].as_str();
1531    let until = params["until"].as_str();
1532    // #151 visibility
1533    let as_agent = params["as_agent"].as_str();
1534    if let Some(a) = as_agent {
1535        validate::validate_namespace(a).map_err(|e| e.to_string())?;
1536    }
1537    // Task 1.11 / Phase P6 (R1): optional token budget. R1 semantics
1538    // permit `0` ("give me nothing") and return an empty result with
1539    // `meta.budget_overflow = false` — see the comment on
1540    // `db::apply_token_budget`. This supersedes the v0.6.3 Ultrareview
1541    // #348 hard-reject of 0; the meta block now disambiguates "user
1542    // asked for zero" from "buggy uninitialized counter" by always
1543    // round-tripping the requested budget.
1544    let budget_tokens = params["budget_tokens"]
1545        .as_u64()
1546        .and_then(|n| usize::try_from(n).ok());
1547
1548    // v0.6.0.0 contextual recall — caller-supplied recent conversation tokens.
1549    let context_tokens: Vec<String> = params["context_tokens"]
1550        .as_array()
1551        .map(|arr| {
1552            arr.iter()
1553                .filter_map(|v| v.as_str().map(String::from))
1554                .collect()
1555        })
1556        .unwrap_or_default();
1557
1558    // Helper: tack tokens_used / budget_tokens onto the response, plus
1559    // — when a budget was supplied — the Phase P6 RecallMeta-style
1560    // sub-block (`meta.budget_tokens_used`, `budget_tokens_remaining`,
1561    // `memories_dropped`, `budget_overflow`). The legacy top-level
1562    // `tokens_used` / `budget_tokens` fields are preserved verbatim so
1563    // pre-P6 callers continue to work byte-for-byte.
1564    //
1565    // NOTE on RecallMeta: Phase P3 introduces a top-level `meta` block
1566    // (recall_mode, reranker_used, candidate_counts, blend_weight). This
1567    // P6 worktree pre-dates the P3 merge, so we define the budget-mode
1568    // sub-block directly under `meta.budget` and let P3's rebase fold
1569    // its fields in alongside ours. See REMEDIATIONv0631.md L488-489.
1570    let decorate_budget = |resp: &mut Value, outcome: &db::BudgetOutcome| {
1571        resp["tokens_used"] = json!(outcome.tokens_used);
1572        if let Some(b) = budget_tokens {
1573            resp["budget_tokens"] = json!(b);
1574            // Phase P6 R1 meta block. Always emitted when a budget is
1575            // supplied so callers can rely on the field set. Kept under
1576            // a dedicated `meta` key so the top-level shape stays
1577            // backward-compatible — pre-P6 callers ignore unknown keys.
1578            let meta = resp
1579                .as_object_mut()
1580                .expect("recall response is always a JSON object")
1581                .entry("meta".to_string())
1582                .or_insert_with(|| json!({}));
1583            meta["budget_tokens_used"] = json!(outcome.tokens_used);
1584            meta["budget_tokens_remaining"] = json!(outcome.tokens_remaining.unwrap_or(0));
1585            meta["memories_dropped"] = json!(outcome.memories_dropped);
1586            meta["budget_overflow"] = json!(outcome.budget_overflow);
1587        }
1588    };
1589
1590    // v0.6.3.1 (P3): build the per-request meta block from retrieval-stage
1591    // telemetry + the runtime reranker variant. The block is always
1592    // present in the response — clients that don't read it ignore unknown
1593    // fields per JSON-RPC convention. Closes audit gaps G2/G8/G11 by
1594    // making silent-degrade paths visible at request time.
1595    let reranker_used = match reranker {
1596        Some(ce) if ce.is_neural() => "neural",
1597        Some(_) => "lexical",
1598        None => "none",
1599    };
1600    let attach_meta = |resp: &mut Value, recall_mode: &str, telemetry: &RecallTelemetry| {
1601        // Round blend_weight to 3 decimals — matches the score field
1602        // precision and keeps the wire shape stable regardless of f64
1603        // representation jitter.
1604        let blend_weight = (telemetry.blend_weight_avg * 1000.0).round() / 1000.0;
1605        let meta = RecallMeta {
1606            recall_mode: recall_mode.to_string(),
1607            reranker_used: reranker_used.to_string(),
1608            candidate_counts: CandidateCounts {
1609                fts: telemetry.fts_candidates,
1610                hnsw: telemetry.hnsw_candidates,
1611            },
1612            blend_weight,
1613        };
1614        // Merge into existing meta object rather than replacing — P6's
1615        // decorate_budget may have already populated budget_* keys here.
1616        if let Ok(Value::Object(p3_fields)) = serde_json::to_value(&meta) {
1617            let meta_obj = resp
1618                .as_object_mut()
1619                .expect("recall response is always a JSON object")
1620                .entry("meta".to_string())
1621                .or_insert_with(|| json!({}));
1622            if let Some(existing) = meta_obj.as_object_mut() {
1623                for (k, v) in p3_fields {
1624                    existing.insert(k, v);
1625                }
1626            }
1627        }
1628    };
1629
1630    // Use hybrid recall if embedder is available
1631    if let Some(emb) = embedder {
1632        match emb.embed(context) {
1633            Ok(primary_emb) => {
1634                // v0.6.0.0: fuse primary query with context-token embedding
1635                // at 70/30 when caller supplied conversation tokens.
1636                let query_emb = if context_tokens.is_empty() {
1637                    primary_emb
1638                } else {
1639                    let joined = context_tokens.join(" ");
1640                    match emb.embed(&joined) {
1641                        Ok(ctx_emb) => {
1642                            crate::embeddings::Embedder::fuse(&primary_emb, &ctx_emb, 0.7)
1643                        }
1644                        Err(e) => {
1645                            tracing::warn!("context_tokens embed failed, using primary only: {e}");
1646                            primary_emb
1647                        }
1648                    }
1649                };
1650                let (results, outcome, telemetry) = db::recall_hybrid_with_telemetry(
1651                    conn,
1652                    context,
1653                    &query_emb,
1654                    namespace,
1655                    limit.min(50),
1656                    tags,
1657                    since,
1658                    until,
1659                    vector_index,
1660                    resolved_ttl.short_extend_secs,
1661                    resolved_ttl.mid_extend_secs,
1662                    as_agent,
1663                    budget_tokens,
1664                    resolved_scoring,
1665                )
1666                .map_err(|e| e.to_string())?;
1667
1668                // Apply cross-encoder reranking if available
1669                if let Some(ce) = reranker {
1670                    let ce_reranked = ce.rerank(context, results);
1671                    let memories = scored_memories(ce_reranked);
1672                    let mut resp = json!({"memories": memories, "count": memories.len(), "mode": "hybrid+rerank"});
1673                    decorate_budget(&mut resp, &outcome);
1674                    attach_meta(&mut resp, "hybrid", &telemetry);
1675                    inject_namespace_standard(conn, namespace, &mut resp);
1676                    return Ok(resp);
1677                }
1678
1679                let memories = scored_memories(results);
1680                let mut resp =
1681                    json!({"memories": memories, "count": memories.len(), "mode": "hybrid"});
1682                decorate_budget(&mut resp, &outcome);
1683                attach_meta(&mut resp, "hybrid", &telemetry);
1684                inject_namespace_standard(conn, namespace, &mut resp);
1685                return Ok(resp);
1686            }
1687            Err(e) => {
1688                // v0.6.3.1 (P3, G11): the embedder being present but the
1689                // per-query embed failing is a different silent-degrade
1690                // path than "embedder unavailable at startup" — preserve
1691                // the existing tracing event and fall through to
1692                // keyword_only mode below, which is what the meta block
1693                // will report.
1694                tracing::warn!("embedding failed, falling back to FTS: {}", e);
1695            }
1696        }
1697    }
1698
1699    // Fallback to keyword-only recall
1700    let (results, outcome, telemetry) = db::recall_with_telemetry(
1701        conn,
1702        context,
1703        namespace,
1704        limit.min(50),
1705        tags,
1706        since,
1707        until,
1708        resolved_ttl.short_extend_secs,
1709        resolved_ttl.mid_extend_secs,
1710        as_agent,
1711        budget_tokens,
1712    )
1713    .map_err(|e| e.to_string())?;
1714    let memories = scored_memories(results);
1715    let mut resp = json!({"memories": memories, "count": memories.len(), "mode": "keyword"});
1716    decorate_budget(&mut resp, &outcome);
1717    attach_meta(&mut resp, "keyword_only", &telemetry);
1718    inject_namespace_standard(conn, namespace, &mut resp);
1719    Ok(resp)
1720}
1721
1722/// Capabilities schema selector (v0.6.3.1 P1 honesty patch).
1723///
1724/// HTTP callers send `Accept-Capabilities: v1` (or `v2`) to request a
1725/// shape; MCP callers pass `accept: "v1"` (or `"v2"`) to
1726/// `memory_capabilities`. Default is v2.
1727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728pub enum CapabilitiesAccept {
1729    V1,
1730    V2,
1731}
1732
1733impl CapabilitiesAccept {
1734    /// Parse the wire value sent by the client. Unknown values fall back
1735    /// to v2 (the default). Whitespace and case insensitive.
1736    #[must_use]
1737    pub fn parse(s: &str) -> Self {
1738        match s.trim().to_ascii_lowercase().as_str() {
1739            "v1" | "1" => Self::V1,
1740            _ => Self::V2,
1741        }
1742    }
1743}
1744
1745/// v0.6.3 (capabilities schema v2 / P1 honesty patch): the canonical
1746/// capabilities entry point.
1747///
1748/// **Live overlays.** When the wrapper has access to the corresponding
1749/// runtime handle, it overlays:
1750/// - `features.embedder_loaded` from `embedder_loaded`,
1751/// - `features.recall_mode_active` from `embedder_loaded` (loaded ⇒
1752///   `Hybrid`; not loaded but configured ⇒ `KeywordOnly`; configured
1753///   but failed ⇒ `Degraded`; tier == keyword ⇒ `Disabled`),
1754/// - `features.reranker_active` from the `CrossEncoder` enum variant
1755///   (`Neural` / `LexicalFallback` / `Off`),
1756/// - `features.cross_encoder_reranking` flips to `false` when the
1757///   neural reranker fell back to lexical (the v1 honesty fix #93),
1758/// - `models.cross_encoder` annotated with `lexical-fallback` when the
1759///   neural download failed.
1760///
1761/// **Live DB counts.** When `conn` is `Some`, the dynamic blocks
1762/// (`permissions.active_rules`, `hooks.registered_count`,
1763/// `approval.pending_requests`) are populated from live counts. DB
1764/// errors are non-fatal — the report falls back to zero-state so a
1765/// transient blip cannot 500 the capabilities endpoint.
1766///
1767/// **Schema selection.** `accept` controls the wire shape. `V2` is the
1768/// default and recommended; `V1` projects the v2 report down to the
1769/// legacy shape for backward compat (see [`Capabilities::to_v1`]).
1770pub fn handle_capabilities_with_conn(
1771    tier_config: &TierConfig,
1772    reranker: Option<&CrossEncoder>,
1773    embedder_loaded: bool,
1774    conn: Option<&rusqlite::Connection>,
1775    accept: CapabilitiesAccept,
1776) -> Result<Value, String> {
1777    let mut caps = tier_config.capabilities();
1778
1779    // --- Reranker live state (P1) ---
1780    // The old (#93) handler flipped `cross_encoder_reranking` to false
1781    // when the neural model fell back to lexical. The honesty patch
1782    // additionally surfaces the runtime variant in `reranker_active`.
1783    caps.features.reranker_active = match reranker {
1784        Some(ce) if ce.is_neural() => RerankerMode::Neural,
1785        Some(_) => {
1786            // Lexical fallback — neural download or load failed.
1787            caps.features.cross_encoder_reranking = false;
1788            caps.models.cross_encoder = "lexical-fallback (neural download failed)".to_string();
1789            RerankerMode::LexicalFallback
1790        }
1791        None => RerankerMode::Off,
1792    };
1793
1794    // --- Embedder live state (P1, S18) ---
1795    caps.features.embedder_loaded = embedder_loaded;
1796    caps.features.recall_mode_active = compute_recall_mode(tier_config, embedder_loaded);
1797
1798    // --- HNSW eviction surface (P3, G2) ---
1799    // Mirror the per-process HNSW eviction counters into the capabilities
1800    // surface so a `memory_capabilities` poll can tell operators whether
1801    // the index is currently shedding embeddings. Same values surfaced in
1802    // `memory_stats.index_evictions_total`.
1803    caps.hnsw.evictions_total = crate::hnsw::index_evictions_total();
1804    caps.hnsw.evicted_recently = crate::hnsw::evicted_recently(60);
1805
1806    // --- Live DB-count overlays ---
1807    if let Some(c) = conn {
1808        if let Ok(n) = db::count_active_governance_rules(c) {
1809            caps.permissions.active_rules = n;
1810        }
1811        if let Ok(n) = db::count_subscriptions(c) {
1812            caps.hooks.registered_count = n;
1813        }
1814        if let Ok(n) = db::count_pending_actions_by_status(c, "pending") {
1815            caps.approval.pending_requests = n;
1816        }
1817    }
1818
1819    // --- Schema selection ---
1820    match accept {
1821        CapabilitiesAccept::V2 => serde_json::to_value(caps).map_err(|e| e.to_string()),
1822        CapabilitiesAccept::V1 => serde_json::to_value(caps.to_v1()).map_err(|e| e.to_string()),
1823    }
1824}
1825
1826/// Compute the live `recall_mode_active` tag from the configured tier
1827/// and the runtime embedder-loaded signal. P1 honesty patch.
1828///
1829/// - Tier configured no embedder (keyword tier) → `Disabled`.
1830/// - Tier configured an embedder and it loaded → `Hybrid`.
1831/// - Tier configured an embedder but it did not load → `Degraded`.
1832/// - (Reserved) `KeywordOnly` is returned only when the daemon has an
1833///   embedder configured but the operator explicitly disabled hybrid
1834///   blending — not possible in v0.6.3.1, so unreachable today.
1835fn compute_recall_mode(
1836    tier_config: &TierConfig,
1837    embedder_loaded: bool,
1838) -> crate::config::RecallMode {
1839    use crate::config::RecallMode;
1840    if tier_config.embedding_model.is_none() {
1841        RecallMode::Disabled
1842    } else if embedder_loaded {
1843        RecallMode::Hybrid
1844    } else {
1845        RecallMode::Degraded
1846    }
1847}
1848
1849fn handle_expand_query(llm: Option<&OllamaClient>, params: &Value) -> Result<Value, String> {
1850    let llm = llm.ok_or("query expansion requires smart or autonomous tier (Ollama LLM)")?;
1851    let query = params["query"].as_str().ok_or("query is required")?;
1852    let terms = llm.expand_query(query).map_err(|e| e.to_string())?;
1853    Ok(json!({"original": query, "expanded_terms": terms}))
1854}
1855
1856fn handle_auto_tag(
1857    conn: &rusqlite::Connection,
1858    llm: Option<&OllamaClient>,
1859    params: &Value,
1860) -> Result<Value, String> {
1861    let llm = llm.ok_or("auto-tagging requires smart or autonomous tier (Ollama LLM)")?;
1862    let id = params["id"].as_str().ok_or("id is required")?;
1863    validate::validate_id(id).map_err(|e| e.to_string())?;
1864    let mem = db::get(conn, id)
1865        .map_err(|e| e.to_string())?
1866        .ok_or("memory not found")?;
1867    let tags = llm
1868        .auto_tag(&mem.title, &mem.content)
1869        .map_err(|e| e.to_string())?;
1870    // Apply tags to the memory
1871    let mut all_tags = mem.tags.clone();
1872    for t in &tags {
1873        if !all_tags.contains(t) {
1874            all_tags.push(t.clone());
1875        }
1876    }
1877    db::update(
1878        conn,
1879        id,
1880        None,
1881        None,
1882        None,
1883        None,
1884        Some(&all_tags),
1885        None,
1886        None,
1887        None,
1888        None,
1889    )
1890    .map_err(|e| e.to_string())?;
1891    Ok(json!({"id": id, "new_tags": tags, "all_tags": all_tags}))
1892}
1893
1894fn handle_detect_contradiction(
1895    conn: &rusqlite::Connection,
1896    llm: Option<&OllamaClient>,
1897    params: &Value,
1898) -> Result<Value, String> {
1899    let llm =
1900        llm.ok_or("contradiction detection requires smart or autonomous tier (Ollama LLM)")?;
1901    let id_a = params["id_a"].as_str().ok_or("id_a is required")?;
1902    let id_b = params["id_b"].as_str().ok_or("id_b is required")?;
1903    validate::validate_id(id_a).map_err(|e| e.to_string())?;
1904    validate::validate_id(id_b).map_err(|e| e.to_string())?;
1905    let mem_a = db::get(conn, id_a)
1906        .map_err(|e| e.to_string())?
1907        .ok_or("memory A not found")?;
1908    let mem_b = db::get(conn, id_b)
1909        .map_err(|e| e.to_string())?
1910        .ok_or("memory B not found")?;
1911    let contradicts = llm
1912        .detect_contradiction(&mem_a.content, &mem_b.content)
1913        .map_err(|e| e.to_string())?;
1914    Ok(json!({
1915        "contradicts": contradicts,
1916        "memory_a": {"id": id_a, "title": mem_a.title},
1917        "memory_b": {"id": id_b, "title": mem_b.title}
1918    }))
1919}
1920
1921fn handle_search(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
1922    let query = params["query"].as_str().ok_or("query is required")?;
1923    let namespace = params["namespace"].as_str();
1924    let tier = params["tier"].as_str().and_then(Tier::from_str);
1925    // Ultrareview #339: saturate instead of panic on 32-bit targets
1926    // where u64 may exceed usize::MAX. A malicious client passing
1927    // limit=2^63 would otherwise take down the daemon.
1928    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(20)).unwrap_or(usize::MAX);
1929
1930    let agent_id = params["agent_id"].as_str();
1931    if let Some(aid) = agent_id {
1932        validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
1933    }
1934    let as_agent = params["as_agent"].as_str();
1935    if let Some(a) = as_agent {
1936        validate::validate_namespace(a).map_err(|e| e.to_string())?;
1937    }
1938    let results = db::search(
1939        conn,
1940        query,
1941        namespace,
1942        tier.as_ref(),
1943        limit.min(200),
1944        None,
1945        None,
1946        None,
1947        None,
1948        agent_id,
1949        as_agent,
1950    )
1951    .map_err(|e| e.to_string())?;
1952    Ok(json!({"results": results, "count": results.len()}))
1953}
1954
1955fn handle_get_taxonomy(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
1956    // Defaults match the JSON schema. Trailing '/' is forgiven so MCP
1957    // clients can pass either `"alpha"` or `"alpha/"` without an extra
1958    // round trip — the underlying validate_namespace rejects the
1959    // trailing slash form, so we strip it before validating.
1960    let prefix_raw = params
1961        .get("namespace_prefix")
1962        .and_then(|v| v.as_str())
1963        .map(str::trim)
1964        .filter(|s| !s.is_empty());
1965    let prefix_owned: Option<String> = prefix_raw.map(|s| s.trim_end_matches('/').to_string());
1966    if let Some(p) = prefix_owned.as_deref() {
1967        validate::validate_namespace(p).map_err(|e| e.to_string())?;
1968    }
1969    let depth = usize::try_from(params.get("depth").and_then(Value::as_u64).unwrap_or(8))
1970        .unwrap_or(usize::MAX)
1971        .min(crate::models::MAX_NAMESPACE_DEPTH);
1972    let limit = usize::try_from(params.get("limit").and_then(Value::as_u64).unwrap_or(1000))
1973        .unwrap_or(usize::MAX)
1974        .clamp(1, 10_000);
1975
1976    let tax =
1977        db::get_taxonomy(conn, prefix_owned.as_deref(), depth, limit).map_err(|e| e.to_string())?;
1978    Ok(json!({
1979        "tree": tax.tree,
1980        "total_count": tax.total_count,
1981        "truncated": tax.truncated,
1982    }))
1983}
1984
1985fn handle_check_duplicate(
1986    conn: &rusqlite::Connection,
1987    params: &Value,
1988    embedder: Option<&Embedder>,
1989) -> Result<Value, String> {
1990    let title = params["title"].as_str().ok_or("title is required")?;
1991    let content = params["content"].as_str().ok_or("content is required")?;
1992    let namespace = params["namespace"]
1993        .as_str()
1994        .map(str::trim)
1995        .filter(|s| !s.is_empty());
1996    // Float defaults are awkward in JSON schema land — accept either an
1997    // explicit threshold or fall back to the tuned default. The hard
1998    // floor is enforced inside `db::check_duplicate`.
1999    #[allow(clippy::cast_possible_truncation)]
2000    let threshold = params["threshold"]
2001        .as_f64()
2002        .map_or(db::DUPLICATE_THRESHOLD_DEFAULT, |t| t as f32);
2003
2004    validate::validate_title(title).map_err(|e| e.to_string())?;
2005    validate::validate_content(content).map_err(|e| e.to_string())?;
2006    if let Some(ns) = namespace {
2007        validate::validate_namespace(ns).map_err(|e| e.to_string())?;
2008    }
2009
2010    let emb = embedder
2011        .ok_or("memory_check_duplicate requires the embedder; enable semantic tier or above")?;
2012    let text = format!("{title} {content}");
2013    let query_embedding = emb.embed(&text).map_err(|e| e.to_string())?;
2014
2015    let check = db::check_duplicate(conn, &query_embedding, namespace, threshold)
2016        .map_err(|e| e.to_string())?;
2017
2018    // Round similarity to 3 decimals at the response edge — keeps the
2019    // JSON readable without leaking the f32's full quantisation noise.
2020    let nearest_json = check.nearest.as_ref().map(|m| {
2021        json!({
2022            "id": m.id,
2023            "title": m.title,
2024            "namespace": m.namespace,
2025            "similarity": (m.similarity * 1000.0).round() / 1000.0,
2026        })
2027    });
2028    let suggested_merge = if check.is_duplicate {
2029        check.nearest.as_ref().map(|m| m.id.clone())
2030    } else {
2031        None
2032    };
2033
2034    Ok(json!({
2035        "is_duplicate": check.is_duplicate,
2036        "threshold": check.threshold,
2037        "nearest": nearest_json,
2038        "suggested_merge": suggested_merge,
2039        "candidates_scanned": check.candidates_scanned,
2040    }))
2041}
2042
2043fn handle_entity_register(
2044    conn: &rusqlite::Connection,
2045    params: &Value,
2046    mcp_client: Option<&str>,
2047) -> Result<Value, String> {
2048    let canonical_name = params["canonical_name"]
2049        .as_str()
2050        .ok_or("canonical_name is required")?;
2051    let namespace = params["namespace"]
2052        .as_str()
2053        .ok_or("namespace is required")?;
2054    let aliases: Vec<String> = params["aliases"]
2055        .as_array()
2056        .map(|arr| {
2057            arr.iter()
2058                .filter_map(|v| v.as_str().map(String::from))
2059                .collect()
2060        })
2061        .unwrap_or_default();
2062    let extra_metadata = if params["metadata"].is_object() {
2063        params["metadata"].clone()
2064    } else {
2065        json!({})
2066    };
2067    let explicit_agent_id = params["agent_id"].as_str();
2068
2069    validate::validate_title(canonical_name).map_err(|e| e.to_string())?;
2070    validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
2071    if let Some(aid) = explicit_agent_id {
2072        validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
2073    }
2074
2075    let agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
2076        .map_err(|e| e.to_string())?;
2077
2078    let reg = db::entity_register(
2079        conn,
2080        canonical_name,
2081        namespace,
2082        &aliases,
2083        &extra_metadata,
2084        Some(&agent_id),
2085    )
2086    .map_err(|e| e.to_string())?;
2087
2088    Ok(json!({
2089        "entity_id": reg.entity_id,
2090        "canonical_name": reg.canonical_name,
2091        "namespace": reg.namespace,
2092        "aliases": reg.aliases,
2093        "created": reg.created,
2094    }))
2095}
2096
2097fn handle_entity_get_by_alias(
2098    conn: &rusqlite::Connection,
2099    params: &Value,
2100) -> Result<Value, String> {
2101    let alias = params["alias"].as_str().ok_or("alias is required")?;
2102    let namespace = params["namespace"]
2103        .as_str()
2104        .map(str::trim)
2105        .filter(|s| !s.is_empty());
2106    if let Some(ns) = namespace {
2107        validate::validate_namespace(ns).map_err(|e| e.to_string())?;
2108    }
2109
2110    match db::entity_get_by_alias(conn, alias, namespace).map_err(|e| e.to_string())? {
2111        Some(rec) => Ok(json!({
2112            "found": true,
2113            "entity_id": rec.entity_id,
2114            "canonical_name": rec.canonical_name,
2115            "namespace": rec.namespace,
2116            "aliases": rec.aliases,
2117        })),
2118        None => Ok(json!({
2119            "found": false,
2120            "entity_id": null,
2121            "canonical_name": null,
2122            "namespace": null,
2123            "aliases": [],
2124        })),
2125    }
2126}
2127
2128fn handle_kg_timeline(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2129    let source_id = params["source_id"]
2130        .as_str()
2131        .ok_or("source_id is required")?;
2132    validate::validate_id(source_id).map_err(|e| e.to_string())?;
2133    let since = params["since"]
2134        .as_str()
2135        .map(str::trim)
2136        .filter(|s| !s.is_empty());
2137    let until = params["until"]
2138        .as_str()
2139        .map(str::trim)
2140        .filter(|s| !s.is_empty());
2141    if let Some(s) = since {
2142        validate::validate_expires_at_format(s).map_err(|e| e.to_string())?;
2143    }
2144    if let Some(u) = until {
2145        validate::validate_expires_at_format(u).map_err(|e| e.to_string())?;
2146    }
2147    let limit = params["limit"]
2148        .as_u64()
2149        .and_then(|n| usize::try_from(n).ok());
2150
2151    let events =
2152        db::kg_timeline(conn, source_id, since, until, limit).map_err(|e| e.to_string())?;
2153
2154    let events_json: Vec<Value> = events
2155        .iter()
2156        .map(|e| {
2157            json!({
2158                "target_id": e.target_id,
2159                "relation": e.relation,
2160                "valid_from": e.valid_from,
2161                "valid_until": e.valid_until,
2162                "observed_by": e.observed_by,
2163                "title": e.title,
2164                "target_namespace": e.target_namespace,
2165            })
2166        })
2167        .collect();
2168
2169    Ok(json!({
2170        "source_id": source_id,
2171        "events": events_json,
2172        "count": events.len(),
2173    }))
2174}
2175
2176fn handle_kg_invalidate(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2177    let source_id = params["source_id"]
2178        .as_str()
2179        .ok_or("source_id is required")?;
2180    let target_id = params["target_id"]
2181        .as_str()
2182        .ok_or("target_id is required")?;
2183    let relation = params["relation"].as_str().ok_or("relation is required")?;
2184    validate::validate_link(source_id, target_id, relation).map_err(|e| e.to_string())?;
2185    let valid_until = params["valid_until"]
2186        .as_str()
2187        .map(str::trim)
2188        .filter(|s| !s.is_empty());
2189    if let Some(ts) = valid_until {
2190        validate::validate_expires_at_format(ts).map_err(|e| e.to_string())?;
2191    }
2192
2193    match db::invalidate_link(conn, source_id, target_id, relation, valid_until)
2194        .map_err(|e| e.to_string())?
2195    {
2196        Some(res) => Ok(json!({
2197            "found": true,
2198            "source_id": source_id,
2199            "target_id": target_id,
2200            "relation": relation,
2201            "valid_until": res.valid_until,
2202            "previous_valid_until": res.previous_valid_until,
2203        })),
2204        None => Ok(json!({
2205            "found": false,
2206            "source_id": source_id,
2207            "target_id": target_id,
2208            "relation": relation,
2209        })),
2210    }
2211}
2212
2213fn handle_kg_query(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2214    let source_id = params["source_id"]
2215        .as_str()
2216        .ok_or("source_id is required")?;
2217    validate::validate_id(source_id).map_err(|e| e.to_string())?;
2218
2219    let max_depth = params["max_depth"]
2220        .as_u64()
2221        .and_then(|n| usize::try_from(n).ok())
2222        .unwrap_or(1);
2223
2224    let valid_at = params["valid_at"]
2225        .as_str()
2226        .map(str::trim)
2227        .filter(|s| !s.is_empty());
2228    if let Some(t) = valid_at {
2229        validate::validate_expires_at_format(t).map_err(|e| e.to_string())?;
2230    }
2231
2232    let allowed_agents: Option<Vec<String>> = params["allowed_agents"].as_array().map(|arr| {
2233        arr.iter()
2234            .filter_map(|v| v.as_str().map(str::trim).filter(|s| !s.is_empty()))
2235            .map(str::to_string)
2236            .collect()
2237    });
2238    if let Some(agents) = allowed_agents.as_ref() {
2239        for a in agents {
2240            validate::validate_agent_id(a).map_err(|e| e.to_string())?;
2241        }
2242    }
2243
2244    let limit = params["limit"]
2245        .as_u64()
2246        .and_then(|n| usize::try_from(n).ok());
2247
2248    let nodes = db::kg_query(
2249        conn,
2250        source_id,
2251        max_depth,
2252        valid_at,
2253        allowed_agents.as_deref(),
2254        limit,
2255    )
2256    .map_err(|e| e.to_string())?;
2257
2258    let memories_json: Vec<Value> = nodes
2259        .iter()
2260        .map(|n| {
2261            json!({
2262                "target_id": n.target_id,
2263                "relation": n.relation,
2264                "valid_from": n.valid_from,
2265                "valid_until": n.valid_until,
2266                "observed_by": n.observed_by,
2267                "title": n.title,
2268                "target_namespace": n.target_namespace,
2269                "depth": n.depth,
2270                "path": n.path,
2271            })
2272        })
2273        .collect();
2274    let paths_json: Vec<&str> = nodes.iter().map(|n| n.path.as_str()).collect();
2275
2276    Ok(json!({
2277        "source_id": source_id,
2278        "max_depth": max_depth,
2279        "memories": memories_json,
2280        "paths": paths_json,
2281        "count": nodes.len(),
2282    }))
2283}
2284
2285fn handle_list(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2286    let namespace = params["namespace"].as_str();
2287    let tier = params["tier"].as_str().and_then(Tier::from_str);
2288    // Ultrareview #339: saturate instead of panic (see handle_search).
2289    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(20)).unwrap_or(usize::MAX);
2290    let agent_id = params["agent_id"].as_str();
2291    if let Some(aid) = agent_id {
2292        validate::validate_agent_id(aid).map_err(|e| e.to_string())?;
2293    }
2294
2295    let results = db::list(
2296        conn,
2297        namespace,
2298        tier.as_ref(),
2299        limit.min(200),
2300        0,
2301        None,
2302        None,
2303        None,
2304        None,
2305        agent_id,
2306    )
2307    .map_err(|e| e.to_string())?;
2308    Ok(json!({"memories": results, "count": results.len()}))
2309}
2310
2311fn handle_delete(
2312    conn: &rusqlite::Connection,
2313    db_path: &Path,
2314    params: &Value,
2315    vector_index: Option<&VectorIndex>,
2316    mcp_client: Option<&str>,
2317) -> Result<Value, String> {
2318    let id = params["id"].as_str().ok_or("id is required")?;
2319    validate::validate_id(id).map_err(|e| e.to_string())?;
2320
2321    // Resolve the memory first so governance has owner context.
2322    let target = if let Some(m) = db::get(conn, id).map_err(|e| e.to_string())? {
2323        Some(m)
2324    } else {
2325        db::get_by_prefix(conn, id).map_err(|e| e.to_string())?
2326    };
2327    let Some(target) = target else {
2328        return Err("memory not found".into());
2329    };
2330
2331    // P5 (G9): snapshot fields the dispatcher needs BEFORE delete frees
2332    // the row. The dispatch itself is fire-and-forget after the DELETE
2333    // commits, but the payload is built from this owned snapshot.
2334    let snapshot_namespace = target.namespace.clone();
2335    let snapshot_title = target.title.clone();
2336    let snapshot_tier = target.tier.as_str().to_string();
2337    let snapshot_owner: Option<String> = target
2338        .metadata
2339        .get("agent_id")
2340        .and_then(|v| v.as_str())
2341        .map(str::to_string);
2342
2343    // Task 1.9: governance enforcement (delete-side).
2344    {
2345        use crate::models::{GovernanceDecision, GovernedAction};
2346        let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
2347            .map_err(|e| e.to_string())?;
2348        let mem_owner = target
2349            .metadata
2350            .get("agent_id")
2351            .and_then(|v| v.as_str())
2352            .map(str::to_string);
2353        let payload = json!({"id": target.id, "title": target.title});
2354        match db::enforce_governance(
2355            conn,
2356            GovernedAction::Delete,
2357            &target.namespace,
2358            &agent_id,
2359            Some(&target.id),
2360            mem_owner.as_deref(),
2361            &payload,
2362        )
2363        .map_err(|e| e.to_string())?
2364        {
2365            GovernanceDecision::Allow => {}
2366            GovernanceDecision::Deny(reason) => {
2367                return Err(format!("delete denied by governance: {reason}"));
2368            }
2369            GovernanceDecision::Pending(pending_id) => {
2370                return Ok(json!({
2371                    "status": "pending",
2372                    "pending_id": pending_id,
2373                    "reason": "governance requires approval",
2374                    "action": "delete",
2375                    "memory_id": target.id,
2376                }));
2377            }
2378        }
2379    }
2380
2381    let deleted = db::delete(conn, &target.id).map_err(|e| e.to_string())?;
2382    if deleted {
2383        if let Some(idx) = vector_index {
2384            idx.remove(&target.id);
2385        }
2386        // PR-5 (issue #487): security audit trail. No-op when disabled.
2387        crate::audit::emit(crate::audit::EventBuilder::new(
2388            crate::audit::AuditAction::Delete,
2389            crate::audit::actor(
2390                snapshot_owner
2391                    .clone()
2392                    .unwrap_or_else(|| "unknown".to_string()),
2393                mcp_client.map_or("host_fallback", |_| "mcp_client_info"),
2394                None,
2395            ),
2396            crate::audit::target_memory(
2397                target.id.clone(),
2398                snapshot_namespace.clone(),
2399                Some(snapshot_title.clone()),
2400                Some(snapshot_tier.clone()),
2401                None,
2402            ),
2403        ));
2404        // P5 (G9): fire `memory_delete` webhook AFTER the row is gone
2405        // (best-effort, fire-and-forget — same pattern as memory_store).
2406        let details = serde_json::to_value(crate::subscriptions::DeleteEventDetails {
2407            title: snapshot_title,
2408            tier: snapshot_tier,
2409        })
2410        .ok();
2411        crate::subscriptions::dispatch_event_with_details(
2412            conn,
2413            "memory_delete",
2414            &target.id,
2415            &snapshot_namespace,
2416            snapshot_owner.as_deref(),
2417            db_path,
2418            details,
2419        );
2420        Ok(json!({"deleted": true}))
2421    } else {
2422        Err("memory not found".into())
2423    }
2424}
2425
2426fn handle_promote(
2427    conn: &rusqlite::Connection,
2428    db_path: &Path,
2429    params: &Value,
2430    mcp_client: Option<&str>,
2431) -> Result<Value, String> {
2432    let id = params["id"].as_str().ok_or("id is required")?;
2433    validate::validate_id(id).map_err(|e| e.to_string())?;
2434    // Resolve prefix if exact ID not found; capture the memory so governance
2435    // has owner context (Task 1.9).
2436    let target = if let Some(m) = db::get(conn, id).map_err(|e| e.to_string())? {
2437        m
2438    } else if let Some(m) = db::get_by_prefix(conn, id).map_err(|e| e.to_string())? {
2439        m
2440    } else {
2441        return Err("memory not found".into());
2442    };
2443    let resolved_id = target.id.clone();
2444    // P5 (G9): snapshot fields needed for the post-success webhook.
2445    let snapshot_namespace = target.namespace.clone();
2446    let snapshot_owner: Option<String> = target
2447        .metadata
2448        .get("agent_id")
2449        .and_then(|v| v.as_str())
2450        .map(str::to_string);
2451
2452    // Task 1.9: governance enforcement (promote-side).
2453    {
2454        use crate::models::{GovernanceDecision, GovernedAction};
2455        let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
2456            .map_err(|e| e.to_string())?;
2457        let mem_owner = target
2458            .metadata
2459            .get("agent_id")
2460            .and_then(|v| v.as_str())
2461            .map(str::to_string);
2462        let payload = json!({
2463            "id": resolved_id,
2464            "to_namespace": params["to_namespace"].as_str(),
2465        });
2466        match db::enforce_governance(
2467            conn,
2468            GovernedAction::Promote,
2469            &target.namespace,
2470            &agent_id,
2471            Some(&resolved_id),
2472            mem_owner.as_deref(),
2473            &payload,
2474        )
2475        .map_err(|e| e.to_string())?
2476        {
2477            GovernanceDecision::Allow => {}
2478            GovernanceDecision::Deny(reason) => {
2479                return Err(format!("promote denied by governance: {reason}"));
2480            }
2481            GovernanceDecision::Pending(pending_id) => {
2482                return Ok(json!({
2483                    "status": "pending",
2484                    "pending_id": pending_id,
2485                    "reason": "governance requires approval",
2486                    "action": "promote",
2487                    "memory_id": resolved_id,
2488                }));
2489            }
2490        }
2491    }
2492
2493    // Task 1.7: optional vertical promotion to an ancestor namespace.
2494    // When `to_namespace` is supplied, clone (don't move) the memory to the
2495    // target and link clone → source with `derived_from`. Original is
2496    // untouched; tier is NOT changed by this path.
2497    if let Some(to_ns) = params["to_namespace"].as_str() {
2498        validate::validate_namespace(to_ns).map_err(|e| e.to_string())?;
2499        let clone_id =
2500            db::promote_to_namespace(conn, &resolved_id, to_ns).map_err(|e| e.to_string())?;
2501        // P5 (G9): fire `memory_promote` webhook for vertical mode AFTER
2502        // the clone commits. memory_id = source id (subscribers can
2503        // distinguish via `mode` and `clone_id` in the details block).
2504        let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
2505            mode: "vertical".to_string(),
2506            tier: None,
2507            to_namespace: Some(to_ns.to_string()),
2508            clone_id: Some(clone_id.clone()),
2509        })
2510        .ok();
2511        crate::subscriptions::dispatch_event_with_details(
2512            conn,
2513            "memory_promote",
2514            &resolved_id,
2515            &snapshot_namespace,
2516            snapshot_owner.as_deref(),
2517            db_path,
2518            details,
2519        );
2520        return Ok(json!({
2521            "promoted": true,
2522            "mode": "vertical",
2523            "source_id": resolved_id,
2524            "clone_id": clone_id,
2525            "to_namespace": to_ns,
2526        }));
2527    }
2528
2529    // Default: tier promotion to long (historical behavior).
2530    let (found, _) = db::update(
2531        conn,
2532        &resolved_id,
2533        None,
2534        None,
2535        Some(&Tier::Long),
2536        None,
2537        None,
2538        None,
2539        None,
2540        Some(""), // empty string clears expires_at
2541        None,
2542    )
2543    .map_err(|e| e.to_string())?;
2544    if !found {
2545        return Err("memory not found".into());
2546    }
2547    // P5 (G9): fire `memory_promote` webhook for the default tier-upgrade
2548    // path AFTER the update commits.
2549    let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
2550        mode: "tier".to_string(),
2551        tier: Some("long".to_string()),
2552        to_namespace: None,
2553        clone_id: None,
2554    })
2555    .ok();
2556    crate::subscriptions::dispatch_event_with_details(
2557        conn,
2558        "memory_promote",
2559        &resolved_id,
2560        &snapshot_namespace,
2561        snapshot_owner.as_deref(),
2562        db_path,
2563        details,
2564    );
2565    Ok(json!({"promoted": true, "mode": "tier", "id": resolved_id, "tier": "long"}))
2566}
2567
2568fn handle_forget(
2569    conn: &rusqlite::Connection,
2570    params: &Value,
2571    archive: bool,
2572) -> Result<Value, String> {
2573    let namespace = params["namespace"].as_str();
2574    let pattern = params["pattern"].as_str();
2575    let tier = params["tier"].as_str().and_then(Tier::from_str);
2576    let dry_run = params["dry_run"].as_bool().unwrap_or(false);
2577
2578    if dry_run {
2579        let count =
2580            db::forget_count(conn, namespace, pattern, tier.as_ref()).map_err(|e| e.to_string())?;
2581        return Ok(json!({"would_delete": count, "dry_run": true}));
2582    }
2583
2584    let deleted =
2585        db::forget(conn, namespace, pattern, tier.as_ref(), archive).map_err(|e| e.to_string())?;
2586    Ok(json!({"deleted": deleted, "archived": archive}))
2587}
2588
2589fn handle_stats(conn: &rusqlite::Connection, db_path: &Path) -> Result<Value, String> {
2590    let stats = db::stats(conn, db_path).map_err(|e| e.to_string())?;
2591    serde_json::to_value(stats).map_err(|e| e.to_string())
2592}
2593
2594fn handle_update(
2595    conn: &rusqlite::Connection,
2596    params: &Value,
2597    embedder: Option<&Embedder>,
2598    vector_index: Option<&VectorIndex>,
2599) -> Result<Value, String> {
2600    let id = params["id"].as_str().ok_or("id is required")?;
2601    validate::validate_id(id).map_err(|e| e.to_string())?;
2602    // Resolve prefix if exact ID not found
2603    let resolved_id = if db::get(conn, id).map_err(|e| e.to_string())?.is_some() {
2604        id.to_string()
2605    } else if let Some(mem) = db::get_by_prefix(conn, id).map_err(|e| e.to_string())? {
2606        mem.id
2607    } else {
2608        return Err("memory not found".into());
2609    };
2610    let title = params["title"].as_str();
2611    let content = params["content"].as_str();
2612    let tier = params["tier"].as_str().and_then(Tier::from_str);
2613    let namespace = params["namespace"].as_str();
2614    let tags: Option<Vec<String>> = params["tags"].as_array().map(|a| {
2615        a.iter()
2616            .filter_map(|v| v.as_str().map(String::from))
2617            .collect()
2618    });
2619    let priority = params["priority"]
2620        .as_i64()
2621        .map(|p| i32::try_from(p).expect("i64 as i32"));
2622    let confidence = params["confidence"].as_f64();
2623    let expires_at = params["expires_at"].as_str();
2624
2625    if let Some(t) = title {
2626        validate::validate_title(t).map_err(|e| e.to_string())?;
2627    }
2628    if let Some(c) = content {
2629        validate::validate_content(c).map_err(|e| e.to_string())?;
2630    }
2631    if let Some(ns) = &namespace {
2632        validate::validate_namespace(ns).map_err(|e| e.to_string())?;
2633    }
2634    if let Some(ref t) = tags {
2635        validate::validate_tags(t).map_err(|e| e.to_string())?;
2636    }
2637    if let Some(p) = priority {
2638        validate::validate_priority(p).map_err(|e| e.to_string())?;
2639    }
2640    if let Some(c) = confidence {
2641        validate::validate_confidence(c).map_err(|e| e.to_string())?;
2642    }
2643    if let Some(ts) = expires_at {
2644        // Allow past dates in update for programmatic TTL management and GC testing
2645        validate::validate_expires_at_format(ts).map_err(|e| e.to_string())?;
2646    }
2647
2648    let metadata = if params["metadata"].is_object() {
2649        let m = params["metadata"].clone();
2650        validate::validate_metadata(&m).map_err(|e| e.to_string())?;
2651        // Preserve existing metadata.agent_id — provenance is immutable.
2652        // Without this, any MCP caller could rewrite the author of any memory.
2653        let existing = db::get(conn, &resolved_id)
2654            .map_err(|e| e.to_string())?
2655            .map_or_else(|| serde_json::json!({}), |m| m.metadata);
2656        Some(crate::identity::preserve_agent_id(&existing, &m))
2657    } else {
2658        None
2659    };
2660
2661    let (found, content_changed) = db::update(
2662        conn,
2663        &resolved_id,
2664        title,
2665        content,
2666        tier.as_ref(),
2667        namespace,
2668        tags.as_ref(),
2669        priority,
2670        confidence,
2671        expires_at,
2672        metadata.as_ref(),
2673    )
2674    .map_err(|e| e.to_string())?;
2675
2676    if !found {
2677        return Err("memory not found".into());
2678    }
2679
2680    // Regenerate embedding when title or content changed
2681    if content_changed && let Some(emb) = embedder {
2682        let mem = db::get(conn, &resolved_id).map_err(|e| e.to_string())?;
2683        if let Some(ref m) = mem {
2684            let text = format!("{} {}", m.title, m.content);
2685            if let Ok(embedding) = emb.embed(&text) {
2686                let _ = db::set_embedding(conn, &resolved_id, &embedding);
2687                if let Some(idx) = vector_index {
2688                    idx.remove(&resolved_id);
2689                    idx.insert(resolved_id.clone(), embedding);
2690                }
2691            }
2692        }
2693    }
2694
2695    let mem = db::get(conn, &resolved_id).map_err(|e| e.to_string())?;
2696    Ok(json!({"updated": true, "memory": mem}))
2697}
2698
2699fn handle_get(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2700    let id = params["id"].as_str().ok_or("id is required")?;
2701    validate::validate_id(id).map_err(|e| e.to_string())?;
2702    match db::resolve_id(conn, id).map_err(|e| e.to_string())? {
2703        Some(mem) => {
2704            let links = db::get_links(conn, &mem.id).unwrap_or_default();
2705            // Flatten: merge memory fields with links at top level (#96)
2706            let mut val = serde_json::to_value(&mem).map_err(|e| e.to_string())?;
2707            if let Some(obj) = val.as_object_mut() {
2708                obj.insert("links".to_string(), json!(links));
2709            }
2710            Ok(val)
2711        }
2712        None => Err("memory not found".into()),
2713    }
2714}
2715
2716fn handle_link(
2717    conn: &rusqlite::Connection,
2718    db_path: &Path,
2719    params: &Value,
2720) -> Result<Value, String> {
2721    let source_id = params["source_id"]
2722        .as_str()
2723        .ok_or("source_id is required")?;
2724    let target_id = params["target_id"]
2725        .as_str()
2726        .ok_or("target_id is required")?;
2727    let relation = params["relation"].as_str().unwrap_or("related_to");
2728
2729    validate::validate_link(source_id, target_id, relation).map_err(|e| e.to_string())?;
2730    db::create_link(conn, source_id, target_id, relation).map_err(|e| e.to_string())?;
2731
2732    // P5 (G9): fire `memory_link_created` webhook AFTER the link is
2733    // persisted. Resolve the source memory to populate `namespace` /
2734    // `agent_id` for the dispatch envelope; if it's somehow gone (race
2735    // with delete) fall back to "global"/None and let the webhook
2736    // reflect the link metadata only.
2737    let (event_namespace, event_agent_id) = match db::get(conn, source_id) {
2738        Ok(Some(mem)) => {
2739            let owner = mem
2740                .metadata
2741                .get("agent_id")
2742                .and_then(|v| v.as_str())
2743                .map(str::to_string);
2744            (mem.namespace, owner)
2745        }
2746        _ => ("global".to_string(), None),
2747    };
2748    let details = serde_json::to_value(crate::subscriptions::LinkCreatedEventDetails {
2749        target_id: target_id.to_string(),
2750        relation: relation.to_string(),
2751    })
2752    .ok();
2753    crate::subscriptions::dispatch_event_with_details(
2754        conn,
2755        "memory_link_created",
2756        source_id,
2757        &event_namespace,
2758        event_agent_id.as_deref(),
2759        db_path,
2760        details,
2761    );
2762
2763    Ok(
2764        json!({"linked": true, "source_id": source_id, "target_id": target_id, "relation": relation}),
2765    )
2766}
2767
2768fn handle_get_links(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
2769    let id = params["id"].as_str().ok_or("id is required")?;
2770    validate::validate_id(id).map_err(|e| e.to_string())?;
2771    let links = db::get_links(conn, id).map_err(|e| e.to_string())?;
2772    Ok(json!({"links": links, "count": links.len()}))
2773}
2774
2775fn handle_consolidate(
2776    conn: &rusqlite::Connection,
2777    db_path: &Path,
2778    params: &Value,
2779    llm: Option<&OllamaClient>,
2780    embedder: Option<&Embedder>,
2781    vector_index: Option<&VectorIndex>,
2782    mcp_client: Option<&str>,
2783) -> Result<Value, String> {
2784    let ids_arr = params["ids"]
2785        .as_array()
2786        .ok_or("ids is required (array of memory IDs)")?;
2787    let mut ids = Vec::with_capacity(ids_arr.len());
2788    for (i, v) in ids_arr.iter().enumerate() {
2789        match v.as_str() {
2790            Some(s) => {
2791                validate::validate_id(s).map_err(|e| e.to_string())?;
2792                ids.push(s.to_string());
2793            }
2794            None => return Err(format!("ids[{i}] must be a string")),
2795        }
2796    }
2797    let title = params["title"].as_str().ok_or("title is required")?;
2798    let namespace = params["namespace"].as_str().unwrap_or("global");
2799
2800    // Auto-generate summary via LLM if not provided
2801    let summary: String = if let Some(s) = params["summary"].as_str() {
2802        s.to_string()
2803    } else if let Some(llm_client) = llm {
2804        // Fetch memory contents for LLM summarization
2805        let mut memory_pairs: Vec<(String, String)> = Vec::new();
2806        for id in &ids {
2807            match db::get(conn, id) {
2808                Ok(Some(mem)) => memory_pairs.push((mem.title, mem.content)),
2809                Ok(None) => return Err(format!("memory not found: {id}")),
2810                Err(e) => return Err(e.to_string()),
2811            }
2812        }
2813        llm_client
2814            .summarize_memories(&memory_pairs)
2815            .map_err(|e| format!("LLM summarization failed: {e}"))?
2816    } else {
2817        return Err(
2818            "summary is required (or use smart/autonomous tier for auto-summarization)".into(),
2819        );
2820    };
2821
2822    validate::validate_consolidate(&ids, title, &summary, namespace).map_err(|e| e.to_string())?;
2823
2824    let auto_generated = params["summary"].as_str().is_none();
2825
2826    // Remove old entries from HNSW index before consolidation deletes them
2827    if let Some(idx) = vector_index {
2828        for id in &ids {
2829            idx.remove(id);
2830        }
2831    }
2832
2833    // NHI: the caller (consolidator) owns the new memory's agent_id;
2834    // source authors are preserved as a forensic array by db::consolidate.
2835    let explicit_agent_id = params["agent_id"].as_str();
2836    let consolidator_agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
2837        .map_err(|e| e.to_string())?;
2838    let new_id = db::consolidate(
2839        conn,
2840        &ids,
2841        title,
2842        &summary,
2843        namespace,
2844        &Tier::Long,
2845        "consolidation",
2846        &consolidator_agent_id,
2847    )
2848    .map_err(|e| e.to_string())?;
2849
2850    // Generate embedding for the consolidated memory (#52)
2851    if let Some(emb) = embedder {
2852        let text = format!("{title} {summary}");
2853        match emb.embed(&text) {
2854            Ok(embedding) => {
2855                if let Err(e) = db::set_embedding(conn, &new_id, &embedding) {
2856                    tracing::warn!(
2857                        "failed to store embedding for consolidated {}: {}",
2858                        &new_id,
2859                        e
2860                    );
2861                }
2862                if let Some(idx) = vector_index {
2863                    // Remove old embeddings from HNSW index
2864                    for id in &ids {
2865                        idx.remove(id);
2866                    }
2867                    idx.insert(new_id.clone(), embedding);
2868                }
2869            }
2870            Err(e) => {
2871                tracing::warn!(
2872                    "failed to generate embedding for consolidated {}: {}",
2873                    &new_id,
2874                    e
2875                );
2876            }
2877        }
2878    }
2879
2880    let mut result = json!({"id": new_id, "consolidated": ids.len()});
2881    if auto_generated {
2882        result["auto_summary"] = json!(true);
2883        result["summary_preview"] = json!(summary.chars().take(200).collect::<String>());
2884    }
2885    // Warn if any source memory was a namespace standard
2886    let standard_ids: Vec<&str> = ids
2887        .iter()
2888        .filter(|id| db::is_namespace_standard(conn, id))
2889        .map(std::string::String::as_str)
2890        .collect();
2891    if !standard_ids.is_empty() {
2892        result["warning"] = json!(format!(
2893            "consolidated memories included namespace standard(s): {}. Re-set the standard to the new memory ID: {}",
2894            standard_ids.join(", "),
2895            new_id
2896        ));
2897    }
2898
2899    // P5 (G9): fire `memory_consolidated` webhook AFTER db::consolidate
2900    // commits the new memory. memory_id = the new consolidated id; the
2901    // details block carries the source ids that were merged.
2902    let details = serde_json::to_value(crate::subscriptions::ConsolidatedEventDetails {
2903        source_ids: ids.clone(),
2904        source_count: ids.len(),
2905    })
2906    .ok();
2907    crate::subscriptions::dispatch_event_with_details(
2908        conn,
2909        "memory_consolidated",
2910        &new_id,
2911        namespace,
2912        Some(&consolidator_agent_id),
2913        db_path,
2914        details,
2915    );
2916
2917    Ok(result)
2918}
2919
2920// ---------------------------------------------------------------------------
2921// Namespace standard handlers
2922// ---------------------------------------------------------------------------
2923
2924pub(crate) fn handle_namespace_set_standard(
2925    conn: &rusqlite::Connection,
2926    params: &Value,
2927) -> Result<Value, String> {
2928    let namespace = params["namespace"]
2929        .as_str()
2930        .ok_or("namespace is required")?;
2931    validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
2932    let id = params["id"].as_str().ok_or("id is required")?;
2933    validate::validate_id(id).map_err(|e| e.to_string())?;
2934    let parent = params["parent"].as_str();
2935    if let Some(p) = parent {
2936        validate::validate_namespace(p).map_err(|e| e.to_string())?;
2937    }
2938
2939    // Task 1.8: optional governance policy merged into the standard memory's
2940    // metadata.governance. Policy is deserialized + validated before write.
2941    let governance_val = params.get("governance").filter(|v| !v.is_null());
2942    if let Some(g) = governance_val {
2943        let policy: crate::models::GovernancePolicy =
2944            serde_json::from_value(g.clone()).map_err(|e| format!("invalid governance: {e}"))?;
2945        validate::validate_governance_policy(&policy).map_err(|e| e.to_string())?;
2946
2947        // Load the standard memory, merge metadata.governance, write back.
2948        let mut mem = db::get(conn, id)
2949            .map_err(|e| e.to_string())?
2950            .ok_or_else(|| format!("memory not found: {id}"))?;
2951        let mut metadata = if mem.metadata.is_object() {
2952            mem.metadata.clone()
2953        } else {
2954            serde_json::json!({})
2955        };
2956        if let Some(obj) = metadata.as_object_mut() {
2957            obj.insert(
2958                "governance".to_string(),
2959                serde_json::to_value(&policy).map_err(|e| e.to_string())?,
2960            );
2961        }
2962        let (found, _) = db::update(
2963            conn,
2964            &mem.id,
2965            None,
2966            None,
2967            None,
2968            None,
2969            None,
2970            None,
2971            None,
2972            None,
2973            Some(&metadata),
2974        )
2975        .map_err(|e| e.to_string())?;
2976        if !found {
2977            return Err(format!("memory not found during governance merge: {id}"));
2978        }
2979        mem.metadata = metadata;
2980    }
2981
2982    db::set_namespace_standard(conn, namespace, id, parent).map_err(|e| e.to_string())?;
2983    let mut resp = json!({"set": true, "namespace": namespace, "standard_id": id});
2984    if let Some(p) = parent {
2985        resp["parent"] = json!(p);
2986    }
2987    if let Some(g) = governance_val {
2988        resp["governance"] = g.clone();
2989    }
2990    Ok(resp)
2991}
2992
2993pub(crate) fn handle_namespace_get_standard(
2994    conn: &rusqlite::Connection,
2995    params: &Value,
2996) -> Result<Value, String> {
2997    let namespace = params["namespace"]
2998        .as_str()
2999        .ok_or("namespace is required")?;
3000    validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
3001
3002    // Task 1.6: --inherit returns the full resolved chain, most-general-first.
3003    let inherit = params["inherit"].as_bool().unwrap_or(false);
3004    if inherit {
3005        let chain = build_namespace_chain(conn, namespace);
3006        let mut standards: Vec<Value> = Vec::new();
3007        for link in &chain {
3008            if let Some(std) = lookup_namespace_standard(conn, link) {
3009                let gov = extract_governance(&std);
3010                let entry = json!({
3011                    "namespace": link,
3012                    "standard_id": std["id"].clone(),
3013                    "title": std["title"].clone(),
3014                    "content": std["content"].clone(),
3015                    "priority": std["priority"].clone(),
3016                    "governance": gov,
3017                });
3018                standards.push(entry);
3019            }
3020        }
3021        return Ok(json!({
3022            "namespace": namespace,
3023            "chain": chain,
3024            "standards": standards,
3025            "count": standards.len(),
3026        }));
3027    }
3028
3029    let standard_id = db::get_namespace_standard(conn, namespace).map_err(|e| e.to_string())?;
3030    match standard_id {
3031        Some(id) => {
3032            let mem = db::get(conn, &id).map_err(|e| e.to_string())?;
3033            match mem {
3034                Some(m) => {
3035                    // Task 1.8: surface metadata.governance (or default policy).
3036                    let gov = GovernancePolicy::from_metadata(&m.metadata)
3037                        .map(Result::unwrap_or_default)
3038                        .unwrap_or_default();
3039                    Ok(json!({
3040                        "namespace": namespace,
3041                        "standard_id": id,
3042                        "title": m.title,
3043                        "content": m.content,
3044                        "priority": m.priority,
3045                        "governance": gov,
3046                    }))
3047                }
3048                None => Ok(
3049                    json!({"namespace": namespace, "standard_id": id, "warning": "standard memory not found — may have been deleted"}),
3050                ),
3051            }
3052        }
3053        None => Ok(json!({"namespace": namespace, "standard_id": null})),
3054    }
3055}
3056
3057/// Task 1.8 — extract metadata.governance from a serialized memory value,
3058/// resolving to the default policy when missing or invalid. Used by the
3059/// `--inherit` get-standard path and tool responses.
3060fn extract_governance(mem_val: &Value) -> Value {
3061    let default = serde_json::to_value(GovernancePolicy::default()).unwrap_or(Value::Null);
3062    let Some(meta) = mem_val.get("metadata") else {
3063        return default;
3064    };
3065    match GovernancePolicy::from_metadata(meta) {
3066        Some(Ok(p)) => serde_json::to_value(&p).unwrap_or(default),
3067        _ => default,
3068    }
3069}
3070
3071pub(crate) fn handle_namespace_clear_standard(
3072    conn: &rusqlite::Connection,
3073    params: &Value,
3074) -> Result<Value, String> {
3075    let namespace = params["namespace"]
3076        .as_str()
3077        .ok_or("namespace is required")?;
3078    validate::validate_namespace(namespace).map_err(|e| e.to_string())?;
3079    let cleared = db::clear_namespace_standard(conn, namespace).map_err(|e| e.to_string())?;
3080    Ok(json!({"cleared": cleared, "namespace": namespace}))
3081}
3082
3083/// Look up the namespace standard and return it as a serialized Memory, or None.
3084fn lookup_namespace_standard(conn: &rusqlite::Connection, namespace: &str) -> Option<Value> {
3085    let standard_id = db::get_namespace_standard(conn, namespace).ok()??;
3086    let mem = db::get(conn, &standard_id).ok()??;
3087    serde_json::to_value(&mem).ok()
3088}
3089
3090/// Auto-register namespace parent chain from the filesystem path.
3091/// Walks from cwd up to home dir, checks if each directory name has a namespace
3092/// standard set, and registers the parent chain.
3093///
3094/// Example: cwd = /home/user/monorepo/frontend
3095///   → checks "frontend" (cwd), "monorepo" (parent), stops at home dir
3096///   → if "monorepo" has a standard, sets `parent_namespace` of "frontend" to "monorepo"
3097#[allow(dead_code)]
3098fn auto_register_path_hierarchy(conn: &rusqlite::Connection, namespace: &str) {
3099    // Only run if this namespace doesn't already have an explicit parent
3100    if db::get_namespace_parent(conn, namespace).is_some() {
3101        return;
3102    }
3103    let Ok(cwd) = std::env::current_dir() else {
3104        return;
3105    };
3106    let home = dirs::home_dir().unwrap_or_default();
3107    // Walk up from parent of cwd (cwd itself IS the namespace)
3108    let mut current = cwd.parent().map(std::path::Path::to_path_buf);
3109    while let Some(dir) = current {
3110        // Stop at or above home directory
3111        if dir == home || !dir.starts_with(&home) {
3112            break;
3113        }
3114        if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str()) {
3115            // Check if this directory name has a namespace standard
3116            if db::get_namespace_standard(conn, dir_name)
3117                .ok()
3118                .flatten()
3119                .is_some()
3120            {
3121                // Found a parent with a standard — register it
3122                let now = chrono::Utc::now().to_rfc3339();
3123                let _ = conn.execute(
3124                    "UPDATE namespace_meta SET parent_namespace = ?1, updated_at = ?2 WHERE namespace = ?3 AND parent_namespace IS NULL",
3125                    rusqlite::params![dir_name, now, namespace],
3126                );
3127                tracing::info!(
3128                    "auto-registered parent namespace: {} -> {}",
3129                    namespace,
3130                    dir_name
3131                );
3132                break;
3133            }
3134        }
3135        current = dir.parent().map(std::path::Path::to_path_buf);
3136    }
3137}
3138
3139// ---------------------------------------------------------------------------
3140// Archive tool handlers
3141// ---------------------------------------------------------------------------
3142
3143fn handle_agent_register(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3144    let agent_id = params["agent_id"].as_str().ok_or("agent_id is required")?;
3145    let agent_type = params["agent_type"]
3146        .as_str()
3147        .ok_or("agent_type is required")?;
3148    let capabilities: Vec<String> = params["capabilities"]
3149        .as_array()
3150        .map(|arr| {
3151            arr.iter()
3152                .filter_map(|v| v.as_str().map(String::from))
3153                .collect()
3154        })
3155        .unwrap_or_default();
3156
3157    validate::validate_agent_id(agent_id).map_err(|e| e.to_string())?;
3158    validate::validate_agent_type(agent_type).map_err(|e| e.to_string())?;
3159    validate::validate_capabilities(&capabilities).map_err(|e| e.to_string())?;
3160
3161    let id =
3162        db::register_agent(conn, agent_id, agent_type, &capabilities).map_err(|e| e.to_string())?;
3163
3164    Ok(json!({
3165        "registered": true,
3166        "id": id,
3167        "agent_id": agent_id,
3168        "agent_type": agent_type,
3169        "capabilities": capabilities,
3170    }))
3171}
3172
3173fn handle_agent_list(conn: &rusqlite::Connection) -> Result<Value, String> {
3174    let agents = db::list_agents(conn).map_err(|e| e.to_string())?;
3175    Ok(json!({
3176        "count": agents.len(),
3177        "agents": agents,
3178    }))
3179}
3180
3181// --- v0.6.0.0 agent notify / inbox -----------------------------------------
3182
3183/// Compose the canonical inbox namespace for a given `agent_id`.
3184///
3185/// Reuses the same sanitization regex that `validate_namespace` enforces
3186/// on writes, so any `agent_id` that passes `validate::validate_agent_id`
3187/// produces an acceptable namespace here.
3188fn messages_namespace_for(agent_id: &str) -> String {
3189    format!("_messages/{agent_id}")
3190}
3191
3192pub(crate) fn handle_notify(
3193    conn: &rusqlite::Connection,
3194    params: &Value,
3195    resolved_ttl: &crate::config::ResolvedTtl,
3196    mcp_client: Option<&str>,
3197) -> Result<Value, String> {
3198    let target = params["target_agent_id"]
3199        .as_str()
3200        .ok_or("target_agent_id is required")?;
3201    let title = params["title"].as_str().ok_or("title is required")?;
3202    let payload = params["payload"].as_str().ok_or("payload is required")?;
3203    let priority = i32::try_from(params["priority"].as_i64().unwrap_or(5))
3204        .expect("i64 as i32")
3205        .clamp(1, 10);
3206    let tier_str = params["tier"].as_str().unwrap_or("mid");
3207    let tier = Tier::from_str(tier_str).ok_or(format!("invalid tier: {tier_str}"))?;
3208
3209    validate::validate_agent_id(target).map_err(|e| e.to_string())?;
3210    validate::validate_title(title).map_err(|e| e.to_string())?;
3211    validate::validate_content(payload).map_err(|e| e.to_string())?;
3212
3213    let sender = crate::identity::resolve_agent_id(None, mcp_client).map_err(|e| e.to_string())?;
3214    let namespace = messages_namespace_for(target);
3215
3216    let now = chrono::Utc::now();
3217    let expires_at = resolved_ttl
3218        .ttl_for_tier(&tier)
3219        .map(|s| (now + chrono::Duration::seconds(s)).to_rfc3339());
3220
3221    let metadata = json!({
3222        "agent_id": sender.clone(),
3223        "recipient_agent_id": target,
3224        "message_kind": "notify",
3225    });
3226
3227    let mem = Memory {
3228        id: uuid::Uuid::new_v4().to_string(),
3229        tier,
3230        namespace: namespace.clone(),
3231        title: title.to_string(),
3232        content: payload.to_string(),
3233        tags: vec!["_message".to_string()],
3234        priority,
3235        confidence: 1.0,
3236        source: "notify".to_string(),
3237        access_count: 0,
3238        created_at: now.to_rfc3339(),
3239        updated_at: now.to_rfc3339(),
3240        last_accessed_at: None,
3241        expires_at,
3242        metadata,
3243    };
3244    let actual_id = db::insert(conn, &mem).map_err(|e| e.to_string())?;
3245
3246    Ok(json!({
3247        "id": actual_id,
3248        "from": sender,
3249        "to": target,
3250        "namespace": namespace,
3251        "tier": mem.tier,
3252        "delivered_at": mem.created_at,
3253    }))
3254}
3255
3256pub(crate) fn handle_inbox(
3257    conn: &rusqlite::Connection,
3258    params: &Value,
3259    mcp_client: Option<&str>,
3260) -> Result<Value, String> {
3261    // Caller identity is the default inbox owner — agents read their own
3262    // inbox unless an explicit agent_id is supplied.
3263    let explicit = params["agent_id"].as_str();
3264    let owner =
3265        crate::identity::resolve_agent_id(explicit, mcp_client).map_err(|e| e.to_string())?;
3266    let unread_only = params["unread_only"].as_bool().unwrap_or(false);
3267    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(50))
3268        .unwrap_or(usize::MAX)
3269        .min(500);
3270    let namespace = messages_namespace_for(&owner);
3271    let items = db::list(
3272        conn,
3273        Some(&namespace),
3274        None,
3275        limit,
3276        0,
3277        None,
3278        None,
3279        None,
3280        None,
3281        None,
3282    )
3283    .map_err(|e| e.to_string())?;
3284    let filtered: Vec<&Memory> = items
3285        .iter()
3286        .filter(|m| !unread_only || m.access_count == 0)
3287        .collect();
3288    let messages: Vec<Value> = filtered
3289        .iter()
3290        .map(|m| {
3291            let sender = m
3292                .metadata
3293                .get("agent_id")
3294                .and_then(|v| v.as_str())
3295                .unwrap_or("");
3296            json!({
3297                "id": m.id,
3298                "from": sender,
3299                "title": m.title,
3300                "payload": m.content,
3301                "priority": m.priority,
3302                "tier": m.tier,
3303                "created_at": m.created_at,
3304                "read": m.access_count > 0,
3305                "access_count": m.access_count,
3306            })
3307        })
3308        .collect();
3309    Ok(json!({
3310        "agent_id": owner,
3311        "namespace": namespace,
3312        "count": messages.len(),
3313        "unread_only": unread_only,
3314        "messages": messages,
3315    }))
3316}
3317
3318// --- v0.6.0.0 webhook subscriptions ---------------------------------------
3319
3320pub(crate) fn handle_subscribe(
3321    conn: &rusqlite::Connection,
3322    params: &Value,
3323    mcp_client: Option<&str>,
3324) -> Result<Value, String> {
3325    let url = params["url"].as_str().ok_or("url is required")?;
3326    let events = params["events"].as_str().unwrap_or("*");
3327    let secret = params["secret"].as_str();
3328    let namespace_filter = params["namespace_filter"].as_str();
3329    let agent_filter = params["agent_filter"].as_str();
3330    let created_by =
3331        crate::identity::resolve_agent_id(None, mcp_client).map_err(|e| e.to_string())?;
3332
3333    // P5 (G9): optional structured per-event-type opt-in. Callers pass
3334    // `event_types: ["memory_store", "memory_link_created"]` to scope a
3335    // subscription to a narrow event subset. When omitted, the legacy
3336    // `events` (comma-separated / `*`) field governs — preserves
3337    // backward compatibility for pre-P5 subscribers.
3338    let event_types: Option<Vec<String>> = params["event_types"].as_array().map(|arr| {
3339        arr.iter()
3340            .filter_map(|v| v.as_str().map(str::to_string))
3341            .collect()
3342    });
3343
3344    // Require the caller to be a registered agent (#301 item 4).
3345    // MCP stdio is single-tenant per process, but the same tool set is
3346    // exposed on the HTTP daemon where a caller might not be attested.
3347    // Registration in `_agents` is cheap (single memory_agent_register
3348    // call) and provides an audit trail; refusing unregistered
3349    // subscribers closes the "any MCP client owns the webhook fleet"
3350    // hole flagged by the v0.6.0 security review.
3351    let registered = crate::db::list_agents(conn)
3352        .map_err(|e| e.to_string())?
3353        .into_iter()
3354        .any(|a| a.agent_id == created_by);
3355    if !registered {
3356        return Err(format!(
3357            "agent {created_by:?} is not registered; call memory_agent_register before memory_subscribe"
3358        ));
3359    }
3360
3361    crate::subscriptions::validate_url(url).map_err(|e| e.to_string())?;
3362
3363    let id = crate::subscriptions::insert(
3364        conn,
3365        &crate::subscriptions::NewSubscription {
3366            url,
3367            events,
3368            secret,
3369            namespace_filter,
3370            agent_filter,
3371            created_by: Some(&created_by),
3372            event_types: event_types.as_deref(),
3373        },
3374    )
3375    .map_err(|e| e.to_string())?;
3376
3377    let mut response = json!({
3378        "id": id,
3379        "url": url,
3380        "events": events,
3381        "namespace_filter": namespace_filter,
3382        "agent_filter": agent_filter,
3383        "created_by": created_by,
3384    });
3385    if let Some(et) = &event_types {
3386        response["event_types"] = json!(et);
3387    }
3388    Ok(response)
3389}
3390
3391pub(crate) fn handle_unsubscribe(
3392    conn: &rusqlite::Connection,
3393    params: &Value,
3394) -> Result<Value, String> {
3395    let id = params["id"].as_str().ok_or("id is required")?;
3396    let removed = crate::subscriptions::delete(conn, id).map_err(|e| e.to_string())?;
3397    Ok(json!({"id": id, "removed": removed}))
3398}
3399
3400pub(crate) fn handle_list_subscriptions(conn: &rusqlite::Connection) -> Result<Value, String> {
3401    let subs = crate::subscriptions::list(conn).map_err(|e| e.to_string())?;
3402    Ok(json!({"count": subs.len(), "subscriptions": subs}))
3403}
3404
3405fn handle_pending_list(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3406    let status = params["status"].as_str();
3407    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(100))
3408        .unwrap_or(usize::MAX)
3409        .min(1000);
3410    let items = db::list_pending_actions(conn, status, limit).map_err(|e| e.to_string())?;
3411    Ok(json!({"count": items.len(), "pending": items}))
3412}
3413
3414fn handle_pending_approve(
3415    conn: &rusqlite::Connection,
3416    params: &Value,
3417    mcp_client: Option<&str>,
3418) -> Result<Value, String> {
3419    use crate::db::ApproveOutcome;
3420    let id = params["id"].as_str().ok_or("id is required")?;
3421    validate::validate_id(id).map_err(|e| e.to_string())?;
3422    let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
3423        .map_err(|e| e.to_string())?;
3424    match db::approve_with_approver_type(conn, id, &agent_id).map_err(|e| e.to_string())? {
3425        ApproveOutcome::Approved => {
3426            // Task 1.10: auto-execute the queued action on final approval.
3427            let executed = db::execute_pending_action(conn, id).map_err(|e| e.to_string())?;
3428            Ok(json!({
3429                "approved": true,
3430                "id": id,
3431                "decided_by": agent_id,
3432                "executed": true,
3433                "memory_id": executed,
3434            }))
3435        }
3436        ApproveOutcome::Pending { votes, quorum } => Ok(json!({
3437            "approved": false,
3438            "status": "pending",
3439            "id": id,
3440            "votes": votes,
3441            "quorum": quorum,
3442            "reason": "consensus threshold not yet reached",
3443        })),
3444        ApproveOutcome::Rejected(reason) => Err(format!("approve rejected: {reason}")),
3445    }
3446}
3447
3448fn handle_pending_reject(
3449    conn: &rusqlite::Connection,
3450    params: &Value,
3451    mcp_client: Option<&str>,
3452) -> Result<Value, String> {
3453    let id = params["id"].as_str().ok_or("id is required")?;
3454    validate::validate_id(id).map_err(|e| e.to_string())?;
3455    let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
3456        .map_err(|e| e.to_string())?;
3457    let transitioned =
3458        db::decide_pending_action(conn, id, false, &agent_id).map_err(|e| e.to_string())?;
3459    if !transitioned {
3460        return Err(format!("pending action not found or already decided: {id}"));
3461    }
3462    Ok(json!({"rejected": true, "id": id, "decided_by": agent_id}))
3463}
3464
3465fn handle_archive_list(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3466    let namespace = params["namespace"].as_str();
3467    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(50)).unwrap_or(usize::MAX);
3468    let offset = usize::try_from(params["offset"].as_u64().unwrap_or(0)).unwrap_or(usize::MAX);
3469    let items =
3470        db::list_archived(conn, namespace, limit.min(1000), offset).map_err(|e| e.to_string())?;
3471    Ok(json!({"archived": items, "count": items.len()}))
3472}
3473
3474fn handle_archive_restore(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3475    let id = params["id"].as_str().ok_or("id is required")?;
3476    crate::validate::validate_id(id).map_err(|e| e.to_string())?;
3477    let restored = db::restore_archived(conn, id).map_err(|e| e.to_string())?;
3478    if !restored {
3479        return Err("not found in archive".into());
3480    }
3481    Ok(json!({"restored": true, "id": id}))
3482}
3483
3484fn handle_archive_purge(conn: &rusqlite::Connection, params: &Value) -> Result<Value, String> {
3485    let older_than_days = params["older_than_days"].as_i64();
3486    let purged = db::purge_archive(conn, older_than_days).map_err(|e| e.to_string())?;
3487    Ok(json!({"purged": purged}))
3488}
3489
3490fn handle_archive_stats(conn: &rusqlite::Connection) -> Result<Value, String> {
3491    db::archive_stats(conn).map_err(|e| e.to_string())
3492}
3493
3494fn handle_gc(conn: &rusqlite::Connection, params: &Value, archive: bool) -> Result<Value, String> {
3495    let dry_run = params["dry_run"].as_bool().unwrap_or(false);
3496    if dry_run {
3497        // Just count expired without deleting
3498        let now = chrono::Utc::now().to_rfc3339();
3499        let count: usize = conn
3500            .query_row(
3501                "SELECT COUNT(*) FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?1",
3502                rusqlite::params![now],
3503                |r| r.get(0),
3504            )
3505            .unwrap_or(0);
3506        return Ok(json!({"collected": count, "dry_run": true}));
3507    }
3508    let count = db::gc(conn, archive).map_err(|e| e.to_string())?;
3509    Ok(json!({"collected": count, "dry_run": false}))
3510}
3511
3512pub(crate) fn handle_session_start(
3513    conn: &rusqlite::Connection,
3514    params: &Value,
3515    llm: Option<&OllamaClient>,
3516) -> Result<Value, String> {
3517    let namespace = params["namespace"].as_str();
3518    let limit = usize::try_from(params["limit"].as_u64().unwrap_or(10)).unwrap_or(usize::MAX);
3519
3520    let results = db::list(
3521        conn,
3522        namespace,
3523        None,
3524        limit.min(50),
3525        0,
3526        None,
3527        None,
3528        None,
3529        None,
3530        None,
3531    )
3532    .map_err(|e| e.to_string())?;
3533
3534    let memories: Vec<Value> = results
3535        .iter()
3536        .map(|mem| {
3537            let mut val = serde_json::to_value(mem).unwrap_or_default();
3538            if let Some(obj) = val.as_object_mut() {
3539                obj.insert("score".to_string(), json!(0.0));
3540            }
3541            val
3542        })
3543        .collect();
3544
3545    let mut response = json!({
3546        "memories": memories,
3547        "count": memories.len(),
3548        "mode": "session_start",
3549    });
3550
3551    if let Some(llm_client) = llm
3552        && !results.is_empty()
3553    {
3554        let pairs: Vec<(String, String)> = results
3555            .iter()
3556            .map(|m| (m.title.clone(), m.content.clone()))
3557            .collect();
3558        match llm_client.summarize_memories(&pairs) {
3559            Ok(summary) => {
3560                response["summary"] = json!(summary);
3561            }
3562            Err(e) => {
3563                tracing::warn!("session_start LLM summary failed: {}", e);
3564            }
3565        }
3566    }
3567
3568    // Auto-register parent chain from filesystem path — disabled by default
3569    // to prevent filesystem structure leakage into the memory database.
3570    // Uncomment or gate behind a config flag if desired.
3571
3572    // Auto-prepend namespace standard (after LLM summary, separate field)
3573    inject_namespace_standard(conn, namespace, &mut response);
3574
3575    Ok(response)
3576}
3577
3578#[allow(clippy::too_many_arguments)]
3579#[allow(clippy::too_many_lines)]
3580#[allow(clippy::too_many_arguments)]
3581fn handle_request(
3582    conn: &rusqlite::Connection,
3583    db_path: &Path,
3584    req: &RpcRequest,
3585    embedder: Option<&Embedder>,
3586    llm: Option<&OllamaClient>,
3587    reranker: Option<&CrossEncoder>,
3588    tier_config: &TierConfig,
3589    vector_index: Option<&VectorIndex>,
3590    resolved_ttl: &crate::config::ResolvedTtl,
3591    resolved_scoring: &crate::config::ResolvedScoring,
3592    archive_on_gc: bool,
3593    autonomous_hooks: bool,
3594    mcp_client: Option<&str>,
3595    profile: &crate::profile::Profile,
3596    mcp_config: Option<&crate::config::McpConfig>,
3597) -> RpcResponse {
3598    let id = req.id.clone().unwrap_or(Value::Null);
3599
3600    // Validate JSON-RPC 2.0 version
3601    if req.jsonrpc != "2.0" {
3602        return err_response(
3603            id,
3604            -32600,
3605            "invalid JSON-RPC version (must be \"2.0\")".into(),
3606        );
3607    }
3608
3609    match req.method.as_str() {
3610        "initialize" => ok_response(
3611            id,
3612            json!({
3613                "protocolVersion": "2024-11-05",
3614                "capabilities": { "tools": {}, "prompts": {} },
3615                "serverInfo": {
3616                    "name": "ai-memory",
3617                    "version": env!("CARGO_PKG_VERSION")
3618                }
3619            }),
3620        ),
3621        "notifications/initialized" | "ping" => ok_response(id, json!({})),
3622        "tools/list" => ok_response(id, tool_definitions_for_profile(profile)),
3623        "prompts/list" => ok_response(id, prompt_definitions()),
3624        "prompts/get" => {
3625            let prompt_name = match req.params["name"].as_str() {
3626                Some(name) if !name.is_empty() => name,
3627                _ => return err_response(id, -32602, "missing or empty prompt name".into()),
3628            };
3629            match prompt_content(prompt_name, &req.params) {
3630                Ok(val) => ok_response(id, val),
3631                Err(e) => err_response(id, -32602, e),
3632            }
3633        }
3634        "tools/call" => {
3635            let tool_name = match req.params["name"].as_str() {
3636                Some(name) if !name.is_empty() => name,
3637                _ => return err_response(id, -32602, "missing or empty tool name".into()),
3638            };
3639
3640            // v0.6.4-002 (RFC S28) — reject calls to tools that are not
3641            // loaded under the active profile. The error message names
3642            // the profile that would load the tool, so a confused agent
3643            // can self-correct via `--profile <hint>` or use
3644            // `memory_capabilities --include-schema family=<f>` to opt in
3645            // at runtime (Track C, v0.6.4-006).
3646            if !profile.loads(tool_name) {
3647                let owning_family = crate::profile::Family::for_tool(tool_name);
3648                let hint = match owning_family {
3649                    Some(f) => format!(
3650                        "tool '{tool_name}' is in family '{}' which is not loaded under \
3651                         the active profile. Restart with `--profile <name>` or \
3652                         `--profile core,{}` to load it, or call `memory_capabilities \
3653                         --include-schema family={}` to expand at runtime.",
3654                        f.name(),
3655                        f.name(),
3656                        f.name()
3657                    ),
3658                    None => format!(
3659                        "tool '{tool_name}' is not registered in this build. Call \
3660                         `memory_capabilities` to see available tools."
3661                    ),
3662                };
3663                return err_response(id, -32601, hint);
3664            }
3665
3666            // Pillar 3 / Stream E — emit a structured tracing span around
3667            // every MCP tool dispatch so production observability can
3668            // attribute latency per tool. The span carries the tool name
3669            // and JSON-RPC id; outcome and elapsed wall time are emitted
3670            // as a child event after dispatch returns.
3671            let span = tracing::info_span!(
3672                "mcp_tool_call",
3673                tool = tool_name,
3674                rpc_id = ?id,
3675            );
3676            let _enter = span.enter();
3677            let started = Instant::now();
3678
3679            let empty_obj = json!({});
3680            let arguments = if req.params["arguments"].is_object() {
3681                &req.params["arguments"]
3682            } else {
3683                &empty_obj
3684            };
3685
3686            let result = match tool_name {
3687                "memory_store" => handle_store(
3688                    conn,
3689                    db_path,
3690                    arguments,
3691                    embedder,
3692                    llm,
3693                    vector_index,
3694                    resolved_ttl,
3695                    autonomous_hooks,
3696                    mcp_client,
3697                ),
3698                "memory_recall" => handle_recall(
3699                    conn,
3700                    arguments,
3701                    embedder,
3702                    vector_index,
3703                    reranker,
3704                    archive_on_gc,
3705                    resolved_ttl,
3706                    resolved_scoring,
3707                ),
3708                "memory_search" => handle_search(conn, arguments),
3709                "memory_list" => handle_list(conn, arguments),
3710                "memory_get_taxonomy" => handle_get_taxonomy(conn, arguments),
3711                "memory_check_duplicate" => handle_check_duplicate(conn, arguments, embedder),
3712                "memory_entity_register" => handle_entity_register(conn, arguments, mcp_client),
3713                "memory_entity_get_by_alias" => handle_entity_get_by_alias(conn, arguments),
3714                "memory_kg_timeline" => handle_kg_timeline(conn, arguments),
3715                "memory_kg_invalidate" => handle_kg_invalidate(conn, arguments),
3716                "memory_kg_query" => handle_kg_query(conn, arguments),
3717                "memory_delete" => {
3718                    handle_delete(conn, db_path, arguments, vector_index, mcp_client)
3719                }
3720                "memory_promote" => handle_promote(conn, db_path, arguments, mcp_client),
3721                "memory_pending_list" => handle_pending_list(conn, arguments),
3722                "memory_pending_approve" => handle_pending_approve(conn, arguments, mcp_client),
3723                "memory_pending_reject" => handle_pending_reject(conn, arguments, mcp_client),
3724                "memory_forget" => handle_forget(conn, arguments, archive_on_gc),
3725                "memory_stats" => handle_stats(conn, db_path),
3726                "memory_update" => handle_update(conn, arguments, embedder, vector_index),
3727                "memory_get" => handle_get(conn, arguments),
3728                "memory_link" => handle_link(conn, db_path, arguments),
3729                "memory_get_links" => handle_get_links(conn, arguments),
3730                "memory_consolidate" => handle_consolidate(
3731                    conn,
3732                    db_path,
3733                    arguments,
3734                    llm,
3735                    embedder,
3736                    vector_index,
3737                    mcp_client,
3738                ),
3739                "memory_capabilities" => {
3740                    // v0.6.4-006 — runtime expansion via family enumeration.
3741                    // When `family` is set, route to the family-listing path
3742                    // and short-circuit the global capabilities document.
3743                    if let Some(fam_name) = arguments.get("family").and_then(Value::as_str) {
3744                        let include_schema = arguments
3745                            .get("include_schema")
3746                            .and_then(Value::as_bool)
3747                            .unwrap_or(false);
3748                        // v0.6.4-008 — agent_id resolution for the
3749                        // allowlist gate. Caller's explicit
3750                        // `arguments.agent_id` wins; otherwise fall
3751                        // back to the MCP `clientInfo.name` captured
3752                        // at initialize time.
3753                        let aid = arguments
3754                            .get("agent_id")
3755                            .and_then(Value::as_str)
3756                            .or(mcp_client);
3757                        handle_capabilities_family(
3758                            fam_name,
3759                            include_schema,
3760                            profile,
3761                            mcp_config,
3762                            aid,
3763                            Some(conn),
3764                        )
3765                    } else {
3766                        // P1 honesty patch: optional `accept` argument lets MCP
3767                        // clients opt into the legacy v1 shape, mirroring the
3768                        // HTTP `Accept-Capabilities` header.
3769                        let accept = arguments
3770                            .get("accept")
3771                            .and_then(Value::as_str)
3772                            .map_or(CapabilitiesAccept::V2, CapabilitiesAccept::parse);
3773                        // v0.6.4-006 — when no family is requested, augment
3774                        // the v2 response with a top-level `families` field
3775                        // describing the family taxonomy and which families
3776                        // the active profile loads. Backward-compat: v1
3777                        // shape never gets the families overlay.
3778                        let result = handle_capabilities_with_conn(
3779                            tier_config,
3780                            reranker,
3781                            embedder.is_some(),
3782                            Some(conn),
3783                            accept,
3784                        );
3785                        result.map(|mut value| {
3786                            if matches!(accept, CapabilitiesAccept::V2) {
3787                                if let Some(obj) = value.as_object_mut() {
3788                                    obj.insert("families".to_string(), families_overview(profile));
3789                                }
3790                            }
3791                            value
3792                        })
3793                    }
3794                }
3795                "memory_expand_query" => handle_expand_query(llm, arguments),
3796                "memory_auto_tag" => handle_auto_tag(conn, llm, arguments),
3797                "memory_detect_contradiction" => handle_detect_contradiction(conn, llm, arguments),
3798                "memory_archive_list" => handle_archive_list(conn, arguments),
3799                "memory_archive_restore" => handle_archive_restore(conn, arguments),
3800                "memory_archive_purge" => handle_archive_purge(conn, arguments),
3801                "memory_archive_stats" => handle_archive_stats(conn),
3802                "memory_gc" => handle_gc(conn, arguments, archive_on_gc),
3803                "memory_session_start" => handle_session_start(conn, arguments, llm),
3804                "memory_namespace_set_standard" => handle_namespace_set_standard(conn, arguments),
3805                "memory_namespace_get_standard" => handle_namespace_get_standard(conn, arguments),
3806                "memory_namespace_clear_standard" => {
3807                    handle_namespace_clear_standard(conn, arguments)
3808                }
3809                "memory_agent_register" => handle_agent_register(conn, arguments),
3810                "memory_agent_list" => handle_agent_list(conn),
3811                "memory_notify" => handle_notify(conn, arguments, resolved_ttl, mcp_client),
3812                "memory_inbox" => handle_inbox(conn, arguments, mcp_client),
3813                "memory_subscribe" => handle_subscribe(conn, arguments, mcp_client),
3814                "memory_unsubscribe" => handle_unsubscribe(conn, arguments),
3815                "memory_list_subscriptions" => handle_list_subscriptions(conn),
3816                // Ultrareview #349: unknown tool is a JSON-RPC 2.0
3817                // "method not found" condition — return -32601, not
3818                // an ok_response with `isError: true`. Clients that
3819                // switch on error code can then misroute / retry
3820                // correctly. We surface the tool name in `data` so
3821                // clients can log it without parsing the message.
3822                unknown => {
3823                    return err_response(id, -32601, format!("unknown tool: {unknown}"));
3824                }
3825            };
3826
3827            // Outcome + elapsed reported under the `mcp_tool_call` span so
3828            // exporters can chart per-tool p95/p99 against PERFORMANCE.md
3829            // budgets without needing per-handler instrumentation.
3830            let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
3831            match &result {
3832                Ok(_) => tracing::info!(elapsed_ms, "ok"),
3833                Err(err) => tracing::warn!(elapsed_ms, error = %err, "err"),
3834            }
3835
3836            // PR-5 (issue #487): MCP-dispatch-level audit emission for
3837            // mutation/recall tools that the per-handler instrumentation
3838            // doesn't already cover. `memory_store` and `memory_delete`
3839            // each emit their own canonical event from inside the
3840            // handler so we skip them here to avoid double-counting.
3841            audit_emit_for_mcp_dispatch(tool_name, arguments, &result, mcp_client);
3842
3843            match result {
3844                Ok(val) => {
3845                    // Check if TOON format requested for recall/search/list
3846                    let format_str = arguments
3847                        .get("format")
3848                        .and_then(|v| v.as_str())
3849                        .unwrap_or("toon_compact");
3850                    let text = match format_str {
3851                        "toon"
3852                            if matches!(
3853                                tool_name,
3854                                "memory_recall" | "memory_list" | "memory_session_start"
3855                            ) =>
3856                        {
3857                            crate::toon::memories_to_toon(&val, false)
3858                        }
3859                        "toon_compact"
3860                            if matches!(
3861                                tool_name,
3862                                "memory_recall" | "memory_list" | "memory_session_start"
3863                            ) =>
3864                        {
3865                            crate::toon::memories_to_toon(&val, true)
3866                        }
3867                        "toon" if tool_name == "memory_search" => {
3868                            crate::toon::search_to_toon(&val, false)
3869                        }
3870                        "toon_compact" if tool_name == "memory_search" => {
3871                            crate::toon::search_to_toon(&val, true)
3872                        }
3873                        _ => serde_json::to_string_pretty(&val).unwrap_or_default(),
3874                    };
3875                    ok_response(
3876                        id,
3877                        json!({
3878                            "content": [{
3879                                "type": "text",
3880                                "text": text
3881                            }]
3882                        }),
3883                    )
3884                }
3885                Err(e) => ok_response(
3886                    id,
3887                    json!({
3888                        "content": [{"type": "text", "text": e}],
3889                        "isError": true
3890                    }),
3891                ),
3892            }
3893        }
3894        _ => err_response(id, -32601, format!("method not found: {}", req.method)),
3895    }
3896}
3897
3898/// Run the MCP server over stdio. Blocks until stdin closes.
3899/// Initializes components based on the requested feature tier.
3900///
3901/// `profile` (v0.6.4-001) selects the tool surface advertised through
3902/// `tools/list`. Today the parameter is plumbed through and recorded in
3903/// the boot manifest; the family-scoped registration filter that
3904/// actually gates which tools land in `tools/list` is wired in
3905/// v0.6.4-002 (#522). Until that lands, every profile shows the full
3906/// 43-tool surface — the resolution step still runs so the parse error
3907/// path is exercised (and asserted in the integration tests).
3908#[allow(clippy::too_many_lines)]
3909pub fn run_mcp_server(
3910    db_path: &Path,
3911    tier: FeatureTier,
3912    app_config: &AppConfig,
3913    profile: &crate::profile::Profile,
3914) -> anyhow::Result<()> {
3915    // Pillar 3 / Stream E — wire `tracing` for the MCP entrypoint so the
3916    // per-tool spans added in `handle_request` actually surface. The
3917    // writer is pinned to stderr because stdio JSON-RPC owns stdout;
3918    // emitting trace lines there would corrupt the protocol. `try_init`
3919    // is a no-op if a subscriber was already installed by another
3920    // command in the same process.
3921    let _ = tracing_subscriber::fmt()
3922        .with_env_filter(
3923            tracing_subscriber::EnvFilter::try_from_default_env()
3924                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("ai_memory=info")),
3925        )
3926        .with_writer(std::io::stderr)
3927        .try_init();
3928
3929    let conn = db::open(db_path)?;
3930    let stdin = io::stdin();
3931    let mut stdout = io::stdout();
3932
3933    let mut tier_config = tier.config();
3934    eprintln!("ai-memory: requested tier = {}", tier.as_str());
3935    // v0.6.4-001 — log resolved profile so an operator inspecting MCP
3936    // boot stderr can immediately see which tool surface is active.
3937    // Family-scoped filtering of tools/list arrives in v0.6.4-002.
3938    let family_names: Vec<&'static str> = profile.families().iter().map(|f| f.name()).collect();
3939    eprintln!(
3940        "ai-memory: profile = {} families ({}); expected tool count = {}",
3941        profile.families().len(),
3942        family_names.join(", "),
3943        profile.expected_tool_count()
3944    );
3945
3946    // Apply config.toml overrides — tiers gate features, models are independently configurable
3947    // Only override if the tier actually uses an LLM (smart/autonomous)
3948    if tier_config.llm_model.is_some()
3949        && let Some(ref llm_override) = app_config.llm_model
3950    {
3951        match llm_override.as_str() {
3952            "gemma4:e2b" => {
3953                tier_config.llm_model = Some(crate::config::LlmModel::Gemma4E2B);
3954                eprintln!("ai-memory: llm_model override from config: gemma4:e2b");
3955            }
3956            "gemma4:e4b" => {
3957                tier_config.llm_model = Some(crate::config::LlmModel::Gemma4E4B);
3958                eprintln!("ai-memory: llm_model override from config: gemma4:e4b");
3959            }
3960            other => eprintln!("ai-memory: unknown llm_model '{other}', using tier default"),
3961        }
3962    }
3963
3964    // Apply embedding model override from config.toml
3965    if tier_config.embedding_model.is_some()
3966        && let Some(ref emb_override) = app_config.embedding_model
3967    {
3968        match emb_override.as_str() {
3969            "mini_lm_l6_v2" => {
3970                tier_config.embedding_model = Some(crate::config::EmbeddingModel::MiniLmL6V2);
3971                eprintln!("ai-memory: embedding_model override from config: mini_lm_l6_v2 (local)");
3972            }
3973            "nomic_embed_v15" => {
3974                tier_config.embedding_model = Some(crate::config::EmbeddingModel::NomicEmbedV15);
3975                eprintln!(
3976                    "ai-memory: embedding_model override from config: nomic_embed_v15 (Ollama)"
3977                );
3978            }
3979            other => {
3980                eprintln!("ai-memory: unknown embedding_model '{other}', using tier default");
3981            }
3982        }
3983    }
3984
3985    // --- Initialize LLM (smart tier and above) — before embedder so Ollama
3986    //     client can be shared with nomic embedder ---
3987    let llm: Option<Arc<OllamaClient>> = if let Some(ref llm_model) = tier_config.llm_model {
3988        let model_id = llm_model.ollama_model_id();
3989        eprintln!(
3990            "ai-memory: connecting to Ollama for {} ...",
3991            llm_model.display_name()
3992        );
3993        let ollama_url = app_config.effective_ollama_url();
3994        match OllamaClient::new_with_url(ollama_url, model_id) {
3995            Ok(client) => {
3996                eprintln!("ai-memory: Ollama connected, ensuring model {model_id} is available...");
3997                if let Err(e) = client.ensure_model() {
3998                    eprintln!("ai-memory: model pull failed: {e} (LLM features disabled)");
3999                    None
4000                } else {
4001                    eprintln!("ai-memory: LLM ready ({})", llm_model.display_name());
4002                    Some(Arc::new(client))
4003                }
4004            }
4005            Err(e) => {
4006                eprintln!("ai-memory: Ollama not available: {e} (LLM features disabled)");
4007                None
4008            }
4009        }
4010    } else {
4011        None
4012    };
4013
4014    // --- Initialize embedder (semantic tier and above) ---
4015    // Use a separate embed client if embed_url is configured differently from ollama_url
4016    let embed_client: Option<Arc<OllamaClient>> = {
4017        let embed_url = app_config.effective_embed_url();
4018        let ollama_url = app_config.effective_ollama_url();
4019        if embed_url == ollama_url {
4020            llm.clone()
4021        } else {
4022            // Separate embed URL configured — create a dedicated client for embeddings
4023            eprintln!("ai-memory: using separate embed URL: {embed_url}");
4024            match OllamaClient::new_with_url(embed_url, "nomic-embed-text") {
4025                Ok(client) => Some(Arc::new(client)),
4026                Err(e) => {
4027                    eprintln!("ai-memory: embed client failed: {e}, falling back to LLM client");
4028                    llm.clone()
4029                }
4030            }
4031        }
4032    };
4033    let embedder = if let Some(ref emb_model) = tier_config.embedding_model {
4034        match Embedder::for_model(*emb_model, embed_client) {
4035            Ok(emb) => {
4036                eprintln!("ai-memory: embedder loaded ({})", emb.model_description());
4037                // Backfill embeddings for memories that don't have them
4038                match db::get_unembedded_ids(&conn) {
4039                    Ok(unembedded) if !unembedded.is_empty() => {
4040                        eprintln!("ai-memory: backfilling {} memories...", unembedded.len());
4041                        let mut ok = 0usize;
4042                        for (id, title, content) in &unembedded {
4043                            let text = format!("{title} {content}");
4044                            match emb.embed(&text) {
4045                                Ok(embedding) => {
4046                                    if db::set_embedding(&conn, id, &embedding).is_ok() {
4047                                        ok += 1;
4048                                    }
4049                                }
4050                                Err(e) => {
4051                                    eprintln!(
4052                                        "ai-memory: embed failed for {}: {}",
4053                                        &id[..8.min(id.len())],
4054                                        e
4055                                    );
4056                                }
4057                            }
4058                        }
4059                        eprintln!("ai-memory: backfilled {}/{}", ok, unembedded.len());
4060                    }
4061                    _ => {}
4062                }
4063                Some(emb)
4064            }
4065            Err(e) => {
4066                eprintln!("ai-memory: embedder failed: {e}");
4067                None
4068            }
4069        }
4070    } else {
4071        None
4072    };
4073
4074    // --- Build HNSW vector index (semantic tier and above) ---
4075    let vector_index = if embedder.is_some() {
4076        match db::get_all_embeddings(&conn) {
4077            Ok(entries) if !entries.is_empty() => {
4078                eprintln!(
4079                    "ai-memory: building HNSW index ({} vectors)...",
4080                    entries.len()
4081                );
4082                let idx = VectorIndex::build(entries);
4083                eprintln!("ai-memory: HNSW index ready ({} entries)", idx.len());
4084                Some(idx)
4085            }
4086            _ => {
4087                eprintln!("ai-memory: no embeddings for HNSW index, using linear scan");
4088                Some(VectorIndex::empty())
4089            }
4090        }
4091    } else {
4092        None
4093    };
4094
4095    // --- Initialize cross-encoder reranker (autonomous tier) ---
4096    let reranker = if tier_config.cross_encoder {
4097        eprintln!("ai-memory: loading neural cross-encoder (ms-marco-MiniLM-L-6-v2)...");
4098        let ce = CrossEncoder::new_neural();
4099        if ce.is_neural() {
4100            eprintln!("ai-memory: neural cross-encoder ready");
4101        } else {
4102            eprintln!("ai-memory: using lexical cross-encoder fallback");
4103        }
4104        Some(ce)
4105    } else {
4106        None
4107    };
4108
4109    // Report effective tier
4110    let effective_tier = if llm.is_some() && embedder.is_some() && reranker.is_some() {
4111        "autonomous"
4112    } else if llm.is_some() && embedder.is_some() {
4113        "smart"
4114    } else if embedder.is_some() {
4115        "semantic"
4116    } else {
4117        "keyword"
4118    };
4119    eprintln!("ai-memory MCP server started (stdio, tier={effective_tier})");
4120
4121    // Captured from the MCP `initialize` handshake's `clientInfo.name`.
4122    // Used by `crate::identity` to synthesize an `ai:<client>@<host>:pid-<pid>`
4123    // agent_id when the caller doesn't supply one explicitly.
4124    let mut mcp_client_name: Option<String> = None;
4125
4126    for line in stdin.lock().lines() {
4127        let line = line?;
4128        if line.trim().is_empty() {
4129            continue;
4130        }
4131
4132        let req: RpcRequest = match serde_json::from_str(&line) {
4133            Ok(r) => r,
4134            Err(e) => {
4135                let resp = err_response(Value::Null, -32700, format!("parse error: {e}"));
4136                let out = serde_json::to_string(&resp)?;
4137                writeln!(stdout, "{out}")?;
4138                stdout.flush()?;
4139                continue;
4140            }
4141        };
4142
4143        // Capture clientInfo.name on initialize (even if id is Null / notification-style).
4144        if req.method == "initialize"
4145            && let Some(name) = req.params["clientInfo"]["name"].as_str()
4146            && !name.is_empty()
4147        {
4148            mcp_client_name = Some(name.to_string());
4149        }
4150
4151        // Notifications have no id — no response expected per JSON-RPC spec
4152        if req.id.is_none() || req.id == Some(Value::Null) {
4153            continue;
4154        }
4155
4156        let resolved_ttl = app_config.effective_ttl();
4157        let resolved_scoring = app_config.effective_scoring();
4158        let archive_on_gc = app_config.effective_archive_on_gc();
4159        let autonomous_hooks = app_config.effective_autonomous_hooks();
4160        let resp = handle_request(
4161            &conn,
4162            db_path,
4163            &req,
4164            embedder.as_ref(),
4165            llm.as_deref(),
4166            reranker.as_ref(),
4167            &tier_config,
4168            vector_index.as_ref(),
4169            &resolved_ttl,
4170            &resolved_scoring,
4171            archive_on_gc,
4172            autonomous_hooks,
4173            mcp_client_name.as_deref(),
4174            profile,
4175            app_config.mcp.as_ref(),
4176        );
4177        let out = serde_json::to_string(&resp)?;
4178        writeln!(stdout, "{out}")?;
4179        stdout.flush()?;
4180    }
4181
4182    let _ = db::checkpoint(&conn);
4183    eprintln!("ai-memory MCP server stopped");
4184    Ok(())
4185}
4186
4187#[cfg(test)]
4188mod tests {
4189    use super::*;
4190    use serde_json::json;
4191
4192    #[test]
4193    fn tool_definitions_returns_43_tools() {
4194        // v0.6.3 adds memory_get_taxonomy (Pillar 1 / Stream A),
4195        // memory_check_duplicate (Pillar 2 / Stream D),
4196        // memory_entity_register + memory_entity_get_by_alias
4197        // (Pillar 2 / Stream B), and memory_kg_timeline +
4198        // memory_kg_invalidate + memory_kg_query (Pillar 2 / Stream C)
4199        // on top of the 36-tool v0.6.0.0 surface.
4200        let defs = tool_definitions();
4201        let tools = defs["tools"].as_array().unwrap();
4202        assert_eq!(tools.len(), 43);
4203    }
4204
4205    /// v0.6.4-002 acceptance gate (RFC §S25/S26): `--profile core`
4206    /// registers exactly 5 family tools + 1 always-on bootstrap
4207    /// (memory_capabilities) = 6 visible tools. `--profile full`
4208    /// registers all 43.
4209    #[test]
4210    fn tool_definitions_for_profile_core_registers_5_plus_capabilities() {
4211        let defs = tool_definitions_for_profile(&crate::profile::Profile::core());
4212        let tools = defs["tools"].as_array().unwrap();
4213        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4214        // Exactly the 5 core tools + memory_capabilities bootstrap.
4215        assert_eq!(
4216            tools.len(),
4217            6,
4218            "core profile should register 5 core tools + memory_capabilities; got {names:?}"
4219        );
4220        for required in [
4221            "memory_store",
4222            "memory_recall",
4223            "memory_list",
4224            "memory_get",
4225            "memory_search",
4226            "memory_capabilities",
4227        ] {
4228            assert!(
4229                names.contains(&required),
4230                "core profile missing {required}; got {names:?}"
4231            );
4232        }
4233        // None of the non-core tools should leak through.
4234        for excluded in [
4235            "memory_kg_query",
4236            "memory_consolidate",
4237            "memory_archive_list",
4238            "memory_subscribe",
4239            "memory_promote",
4240        ] {
4241            assert!(
4242                !names.contains(&excluded),
4243                "core profile leaked {excluded}; got {names:?}"
4244            );
4245        }
4246    }
4247
4248    #[test]
4249    fn tool_definitions_for_profile_full_registers_43() {
4250        let defs = tool_definitions_for_profile(&crate::profile::Profile::full());
4251        let tools = defs["tools"].as_array().unwrap();
4252        assert_eq!(
4253            tools.len(),
4254            43,
4255            "full profile must reproduce v0.6.3 surface 1:1"
4256        );
4257    }
4258
4259    #[test]
4260    fn tool_definitions_for_profile_graph_registers_thirteen_plus_capabilities() {
4261        let defs = tool_definitions_for_profile(&crate::profile::Profile::graph());
4262        let tools = defs["tools"].as_array().unwrap();
4263        // 5 core + 8 graph + 1 always-on capabilities = 14 (capabilities
4264        // is in meta but loaded as bootstrap; without it we'd have 13).
4265        assert_eq!(
4266            tools.len(),
4267            14,
4268            "graph profile = core(5) + graph(8) + capabilities-bootstrap(1)"
4269        );
4270    }
4271
4272    /// RFC §S30: custom comma-list `core,graph` registers union.
4273    #[test]
4274    fn tool_definitions_for_profile_custom_core_comma_graph_registers_union() {
4275        let p = crate::profile::Profile::parse("core,graph").unwrap();
4276        let defs = tool_definitions_for_profile(&p);
4277        let tools = defs["tools"].as_array().unwrap();
4278        assert_eq!(tools.len(), 14, "core,graph = 5 + 8 + capabilities");
4279    }
4280
4281    // ---- v0.6.4-006 — capabilities family enum + include_schema ----
4282
4283    #[test]
4284    fn families_overview_lists_all_eight_with_correct_loaded_flags() {
4285        let p = crate::profile::Profile::core();
4286        let v = families_overview(&p);
4287        let families = v["families"].as_array().unwrap();
4288        assert_eq!(families.len(), 8, "all eight families must appear");
4289
4290        let core_row = families.iter().find(|r| r["name"] == "core").unwrap();
4291        assert_eq!(core_row["loaded"], true);
4292        assert_eq!(core_row["tool_count"], 5);
4293        let graph_row = families.iter().find(|r| r["name"] == "graph").unwrap();
4294        assert_eq!(graph_row["loaded"], false);
4295        assert_eq!(graph_row["tool_count"], 8);
4296
4297        let always_on = v["always_on"].as_array().unwrap();
4298        assert_eq!(always_on.len(), 1);
4299        assert_eq!(always_on[0], "memory_capabilities");
4300    }
4301
4302    #[test]
4303    fn handle_capabilities_family_lists_tool_names() {
4304        let p = crate::profile::Profile::core();
4305        let v = handle_capabilities_family("graph", false, &p, None, None, None).unwrap();
4306        assert_eq!(v["family"], "graph");
4307        assert_eq!(v["loaded_under_active_profile"], false);
4308        let tools = v["tools"].as_array().unwrap();
4309        assert_eq!(tools.len(), 8);
4310        // Spot-check known graph tool present.
4311        assert!(tools.iter().any(|t| t == "memory_kg_query"));
4312    }
4313
4314    #[test]
4315    fn handle_capabilities_family_include_schema_returns_full_definitions() {
4316        let p = crate::profile::Profile::core();
4317        let v = handle_capabilities_family("graph", true, &p, None, None, None).unwrap();
4318        assert_eq!(v["family"], "graph");
4319        let tools = v["tools"].as_array().unwrap();
4320        assert_eq!(tools.len(), 8);
4321        // Each row must carry the full MCP tool definition shape.
4322        for tool in tools {
4323            assert!(tool.get("name").is_some(), "missing name");
4324            assert!(tool.get("description").is_some(), "missing description");
4325            assert!(tool.get("inputSchema").is_some(), "missing inputSchema");
4326        }
4327    }
4328
4329    #[test]
4330    fn handle_capabilities_family_unknown_returns_diagnostic_err() {
4331        let p = crate::profile::Profile::core();
4332        let err = handle_capabilities_family("xyz", false, &p, None, None, None).unwrap_err();
4333        assert!(err.contains("xyz"));
4334        assert!(err.contains("Valid families"));
4335        assert!(err.contains("core"));
4336        assert!(err.contains("graph"));
4337    }
4338
4339    #[test]
4340    fn handle_capabilities_family_empty_name_errors() {
4341        let p = crate::profile::Profile::core();
4342        let err = handle_capabilities_family("", false, &p, None, None, None).unwrap_err();
4343        assert!(err.contains("must not be empty"));
4344    }
4345
4346    #[test]
4347    fn tool_definitions_include_check_duplicate() {
4348        let defs = tool_definitions();
4349        let tools = defs["tools"].as_array().unwrap();
4350        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4351        assert!(names.contains(&"memory_check_duplicate"));
4352    }
4353
4354    #[test]
4355    fn tool_definitions_include_entity_registry_tools() {
4356        let defs = tool_definitions();
4357        let tools = defs["tools"].as_array().unwrap();
4358        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4359        assert!(names.contains(&"memory_entity_register"));
4360        assert!(names.contains(&"memory_entity_get_by_alias"));
4361    }
4362
4363    #[test]
4364    fn tool_definitions_include_kg_timeline() {
4365        let defs = tool_definitions();
4366        let tools = defs["tools"].as_array().unwrap();
4367        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4368        assert!(names.contains(&"memory_kg_timeline"));
4369    }
4370
4371    #[test]
4372    fn tool_definitions_include_kg_invalidate() {
4373        let defs = tool_definitions();
4374        let tools = defs["tools"].as_array().unwrap();
4375        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4376        assert!(names.contains(&"memory_kg_invalidate"));
4377    }
4378
4379    #[test]
4380    fn tool_definitions_include_kg_query() {
4381        let defs = tool_definitions();
4382        let tools = defs["tools"].as_array().unwrap();
4383        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4384        assert!(names.contains(&"memory_kg_query"));
4385    }
4386
4387    #[test]
4388    fn tool_definitions_include_agent_register_and_list() {
4389        let defs = tool_definitions();
4390        let names: Vec<&str> = defs["tools"]
4391            .as_array()
4392            .unwrap()
4393            .iter()
4394            .filter_map(|t| t["name"].as_str())
4395            .collect();
4396        assert!(names.contains(&"memory_agent_register"));
4397        assert!(names.contains(&"memory_agent_list"));
4398    }
4399
4400    #[test]
4401    fn tool_definitions_include_notify_and_inbox() {
4402        // v0.6.0.0 agent-to-agent messaging primitive.
4403        let defs = tool_definitions();
4404        let names: Vec<&str> = defs["tools"]
4405            .as_array()
4406            .unwrap()
4407            .iter()
4408            .filter_map(|t| t["name"].as_str())
4409            .collect();
4410        assert!(names.contains(&"memory_notify"));
4411        assert!(names.contains(&"memory_inbox"));
4412    }
4413
4414    #[test]
4415    fn messages_namespace_is_prefixed() {
4416        assert_eq!(super::messages_namespace_for("alice"), "_messages/alice");
4417        assert_eq!(
4418            super::messages_namespace_for("ai:claude-opus-4.7"),
4419            "_messages/ai:claude-opus-4.7"
4420        );
4421    }
4422
4423    #[test]
4424    fn tool_definitions_all_have_names() {
4425        let defs = tool_definitions();
4426        let tools = defs["tools"].as_array().unwrap();
4427        for tool in tools {
4428            assert!(tool["name"].as_str().unwrap().starts_with("memory_"));
4429        }
4430    }
4431
4432    #[test]
4433    fn tool_definitions_recall_has_toon_default() {
4434        let defs = tool_definitions();
4435        let tools = defs["tools"].as_array().unwrap();
4436        let recall = tools.iter().find(|t| t["name"] == "memory_recall").unwrap();
4437        let format_schema = &recall["inputSchema"]["properties"]["format"];
4438        assert_eq!(format_schema["default"], "toon_compact");
4439    }
4440
4441    #[test]
4442    fn prompt_definitions_returns_2() {
4443        let defs = prompt_definitions();
4444        let prompts = defs["prompts"].as_array().unwrap();
4445        assert_eq!(prompts.len(), 2);
4446        assert_eq!(prompts[0]["name"], "recall-first");
4447        assert_eq!(prompts[1]["name"], "memory-workflow");
4448    }
4449
4450    #[test]
4451    fn prompt_definitions_recall_first_has_arguments() {
4452        let defs = prompt_definitions();
4453        let prompts = defs["prompts"].as_array().unwrap();
4454        let recall_first = &prompts[0];
4455        let args = recall_first["arguments"].as_array().unwrap();
4456        assert_eq!(args.len(), 1);
4457        assert_eq!(args[0]["name"], "namespace");
4458    }
4459
4460    #[test]
4461    fn prompt_content_recall_first() {
4462        let params = json!({});
4463        let result = prompt_content("recall-first", &params).unwrap();
4464        let msgs = result["messages"].as_array().unwrap();
4465        assert_eq!(msgs.len(), 1);
4466        let text = msgs[0]["content"]["text"].as_str().unwrap();
4467        assert!(text.contains("RECALL FIRST"));
4468        assert!(text.contains("TOON"));
4469        assert!(text.contains("memory_recall"));
4470        assert!(text.contains("memory_store"));
4471        assert!(text.contains("DEDUP"));
4472    }
4473
4474    #[test]
4475    fn prompt_content_recall_first_with_namespace() {
4476        let params = json!({"arguments": {"namespace": "my-project"}});
4477        let result = prompt_content("recall-first", &params).unwrap();
4478        let text = result["messages"][0]["content"]["text"].as_str().unwrap();
4479        assert!(text.contains("my-project"));
4480    }
4481
4482    #[test]
4483    fn prompt_content_memory_workflow() {
4484        let params = json!({});
4485        let result = prompt_content("memory-workflow", &params).unwrap();
4486        let text = result["messages"][0]["content"]["text"].as_str().unwrap();
4487        assert!(text.contains("STORE"));
4488        assert!(text.contains("RECALL"));
4489        assert!(text.contains("SEARCH"));
4490        assert!(text.contains("CONSOLIDATE"));
4491        assert!(text.contains("TOON"));
4492    }
4493
4494    #[test]
4495    fn prompt_content_unknown() {
4496        let params = json!({});
4497        let result = prompt_content("nonexistent", &params);
4498        assert!(result.is_err());
4499        assert!(result.unwrap_err().contains("unknown prompt"));
4500    }
4501
4502    #[test]
4503    fn prompt_content_role_is_user() {
4504        let params = json!({});
4505        let result = prompt_content("recall-first", &params).unwrap();
4506        assert_eq!(result["messages"][0]["role"], "user");
4507    }
4508
4509    #[test]
4510    fn ok_response_structure() {
4511        let resp = ok_response(json!(1), json!({"test": true}));
4512        assert_eq!(resp.jsonrpc, "2.0");
4513        assert_eq!(resp.id, json!(1));
4514        assert!(resp.result.is_some());
4515        assert!(resp.error.is_none());
4516    }
4517
4518    #[test]
4519    fn err_response_structure() {
4520        let resp = err_response(json!(1), -32600, "test error".to_string());
4521        assert_eq!(resp.jsonrpc, "2.0");
4522        assert!(resp.error.is_some());
4523        let err = resp.error.unwrap();
4524        assert_eq!(err.code, -32600);
4525        assert_eq!(err.message, "test error");
4526    }
4527
4528    /// Buffer-backed `MakeWriter` so `tracing` output can be asserted on
4529    /// without polluting test stdout/stderr or installing a global
4530    /// subscriber. Used by the Stream E span coverage tests below.
4531    #[derive(Clone)]
4532    struct VecWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
4533
4534    impl std::io::Write for VecWriter {
4535        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4536            self.0.lock().unwrap().extend_from_slice(buf);
4537            Ok(buf.len())
4538        }
4539        fn flush(&mut self) -> std::io::Result<()> {
4540            Ok(())
4541        }
4542    }
4543
4544    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for VecWriter {
4545        type Writer = VecWriter;
4546        fn make_writer(&'a self) -> Self::Writer {
4547            self.clone()
4548        }
4549    }
4550
4551    fn run_with_capture<F: FnOnce()>(f: F) -> String {
4552        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
4553        let writer = VecWriter(buf.clone());
4554        let subscriber = tracing_subscriber::fmt()
4555            .with_writer(writer)
4556            .with_max_level(tracing::Level::INFO)
4557            .with_ansi(false)
4558            .finish();
4559        tracing::subscriber::with_default(subscriber, f);
4560        String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default()
4561    }
4562
4563    fn make_tools_call(tool: &str, args: Value) -> RpcRequest {
4564        RpcRequest {
4565            jsonrpc: "2.0".into(),
4566            id: Some(json!(1)),
4567            method: "tools/call".into(),
4568            params: json!({ "name": tool, "arguments": args }),
4569        }
4570    }
4571
4572    /// Pillar 3 / Stream E coverage — every successful `tools/call` must
4573    /// emit a `mcp_tool_call` span carrying the tool name plus an `ok`
4574    /// event with `elapsed_ms`. This is the single point of latency
4575    /// instrumentation production exporters key off.
4576    #[test]
4577    fn tools_call_emits_span_with_tool_name_and_elapsed_ms() {
4578        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4579        let tier_config = FeatureTier::Keyword.config();
4580        let resolved_ttl = crate::config::ResolvedTtl::default();
4581        let resolved_scoring = crate::config::ResolvedScoring::default();
4582        let req = make_tools_call("memory_list", json!({"limit": 1}));
4583
4584        let captured = run_with_capture(|| {
4585            let resp = handle_request(
4586                &conn,
4587                std::path::Path::new(":memory:"),
4588                &req,
4589                None,
4590                None,
4591                None,
4592                &tier_config,
4593                None,
4594                &resolved_ttl,
4595                &resolved_scoring,
4596                true,
4597                false,
4598                None,
4599                &crate::profile::Profile::full(),
4600                None,
4601            );
4602            assert!(resp.error.is_none(), "expected ok rpc response");
4603        });
4604
4605        assert!(
4606            captured.contains("mcp_tool_call"),
4607            "missing span name in: {captured}"
4608        );
4609        assert!(
4610            captured.contains("memory_list"),
4611            "missing tool field in: {captured}"
4612        );
4613        assert!(
4614            captured.contains("elapsed_ms"),
4615            "missing elapsed_ms field in: {captured}"
4616        );
4617        assert!(
4618            captured.contains(" ok"),
4619            "missing ok outcome event in: {captured}"
4620        );
4621    }
4622
4623    /// Failure path — when the underlying handler returns an `Err`, the
4624    /// span emits a `warn` level event with the error message so on-call
4625    /// dashboards can alert on per-tool error rate.
4626    #[test]
4627    fn tools_call_emits_warn_event_on_handler_error() {
4628        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4629        let tier_config = FeatureTier::Keyword.config();
4630        let resolved_ttl = crate::config::ResolvedTtl::default();
4631        let resolved_scoring = crate::config::ResolvedScoring::default();
4632        // memory_get with a missing/invalid id is a deterministic Err
4633        // path: validate_id rejects empty strings.
4634        let req = make_tools_call("memory_get", json!({"id": ""}));
4635
4636        let captured = run_with_capture(|| {
4637            let resp = handle_request(
4638                &conn,
4639                std::path::Path::new(":memory:"),
4640                &req,
4641                None,
4642                None,
4643                None,
4644                &tier_config,
4645                None,
4646                &resolved_ttl,
4647                &resolved_scoring,
4648                true,
4649                false,
4650                None,
4651                &crate::profile::Profile::full(),
4652                None,
4653            );
4654            // Handler errs are returned as ok_response with isError=true,
4655            // not RpcError, by design (the JSON-RPC layer is reserved for
4656            // protocol-level failures).
4657            assert!(resp.error.is_none());
4658        });
4659
4660        assert!(
4661            captured.contains("mcp_tool_call"),
4662            "missing span in err path: {captured}"
4663        );
4664        assert!(
4665            captured.contains("memory_get"),
4666            "missing tool field in err path: {captured}"
4667        );
4668        assert!(
4669            captured.contains("WARN"),
4670            "missing WARN level on err path: {captured}"
4671        );
4672        assert!(
4673            captured.contains("err"),
4674            "missing err outcome in: {captured}"
4675        );
4676    }
4677    /// Parametrized smoke matrix for all 43 MCP tools (Justice of MCP pathway).
4678    /// Tier 1: happy path with canonical valid args.
4679    /// Tier 2: required arg validation (missing required arg → error).
4680    #[test]
4681    #[allow(clippy::too_many_lines)]
4682    fn mcp_tools_smoke_matrix() {
4683        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4684        let tier_config = FeatureTier::Keyword.config();
4685        let resolved_ttl = crate::config::ResolvedTtl::default();
4686        let resolved_scoring = crate::config::ResolvedScoring::default();
4687
4688        struct ToolCase {
4689            name: &'static str,
4690            valid_args: Value,
4691            required_arg: Option<&'static str>, // first required arg name for error test
4692        }
4693
4694        let cases: &[ToolCase] = &[
4695            ToolCase {
4696                name: "memory_store",
4697                valid_args: json!({"title": "test", "content": "test content"}),
4698                required_arg: Some("title"),
4699            },
4700            ToolCase {
4701                name: "memory_recall",
4702                valid_args: json!({"context": "test"}),
4703                required_arg: Some("context"),
4704            },
4705            ToolCase {
4706                name: "memory_search",
4707                valid_args: json!({"query": "test"}),
4708                required_arg: Some("query"),
4709            },
4710            ToolCase {
4711                name: "memory_list",
4712                valid_args: json!({}),
4713                required_arg: None,
4714            },
4715            ToolCase {
4716                name: "memory_get_taxonomy",
4717                valid_args: json!({}),
4718                required_arg: None,
4719            },
4720            ToolCase {
4721                name: "memory_check_duplicate",
4722                valid_args: json!({"title": "test", "content": "test content"}),
4723                required_arg: Some("title"),
4724            },
4725            ToolCase {
4726                name: "memory_entity_register",
4727                valid_args: json!({"canonical_name": "Entity", "namespace": "test"}),
4728                required_arg: Some("canonical_name"),
4729            },
4730            ToolCase {
4731                name: "memory_entity_get_by_alias",
4732                valid_args: json!({"alias": "test"}),
4733                required_arg: Some("alias"),
4734            },
4735            ToolCase {
4736                name: "memory_kg_timeline",
4737                valid_args: json!({"source_id": "fake-id-for-test"}),
4738                required_arg: Some("source_id"),
4739            },
4740            ToolCase {
4741                name: "memory_kg_invalidate",
4742                valid_args: json!({"source_id": "s", "target_id": "t", "relation": "related_to"}),
4743                required_arg: Some("source_id"),
4744            },
4745            ToolCase {
4746                name: "memory_kg_query",
4747                valid_args: json!({"source_id": "fake-id-for-test"}),
4748                required_arg: Some("source_id"),
4749            },
4750            ToolCase {
4751                name: "memory_delete",
4752                valid_args: json!({"id": "fake-id-for-test"}),
4753                required_arg: Some("id"),
4754            },
4755            ToolCase {
4756                name: "memory_promote",
4757                valid_args: json!({"id": "fake-id-for-test"}),
4758                required_arg: Some("id"),
4759            },
4760            ToolCase {
4761                name: "memory_forget",
4762                valid_args: json!({}),
4763                required_arg: None,
4764            },
4765            ToolCase {
4766                name: "memory_stats",
4767                valid_args: json!({}),
4768                required_arg: None,
4769            },
4770            ToolCase {
4771                name: "memory_update",
4772                valid_args: json!({"id": "fake-id-for-test"}),
4773                required_arg: Some("id"),
4774            },
4775            ToolCase {
4776                name: "memory_get",
4777                valid_args: json!({"id": "fake-id-for-test"}),
4778                required_arg: Some("id"),
4779            },
4780            ToolCase {
4781                name: "memory_link",
4782                valid_args: json!({"source_id": "s", "target_id": "t"}),
4783                required_arg: Some("source_id"),
4784            },
4785            ToolCase {
4786                name: "memory_get_links",
4787                valid_args: json!({"id": "fake-id-for-test"}),
4788                required_arg: Some("id"),
4789            },
4790            ToolCase {
4791                name: "memory_consolidate",
4792                valid_args: json!({"ids": ["id1", "id2"], "title": "consolidated"}),
4793                required_arg: Some("ids"),
4794            },
4795            ToolCase {
4796                name: "memory_capabilities",
4797                valid_args: json!({}),
4798                required_arg: None,
4799            },
4800            ToolCase {
4801                name: "memory_expand_query",
4802                valid_args: json!({"query": "test"}),
4803                required_arg: Some("query"),
4804            },
4805            ToolCase {
4806                name: "memory_auto_tag",
4807                valid_args: json!({"id": "fake-id-for-test"}),
4808                required_arg: Some("id"),
4809            },
4810            ToolCase {
4811                name: "memory_detect_contradiction",
4812                valid_args: json!({"id_a": "a", "id_b": "b"}),
4813                required_arg: Some("id_a"),
4814            },
4815            ToolCase {
4816                name: "memory_archive_list",
4817                valid_args: json!({}),
4818                required_arg: None,
4819            },
4820            ToolCase {
4821                name: "memory_archive_restore",
4822                valid_args: json!({"id": "fake-id-for-test"}),
4823                required_arg: Some("id"),
4824            },
4825            ToolCase {
4826                name: "memory_archive_purge",
4827                valid_args: json!({}),
4828                required_arg: None,
4829            },
4830            ToolCase {
4831                name: "memory_archive_stats",
4832                valid_args: json!({}),
4833                required_arg: None,
4834            },
4835            ToolCase {
4836                name: "memory_gc",
4837                valid_args: json!({}),
4838                required_arg: None,
4839            },
4840            ToolCase {
4841                name: "memory_session_start",
4842                valid_args: json!({}),
4843                required_arg: None,
4844            },
4845            ToolCase {
4846                name: "memory_namespace_set_standard",
4847                valid_args: json!({"namespace": "test", "id": "fake-id-for-test"}),
4848                required_arg: Some("namespace"),
4849            },
4850            ToolCase {
4851                name: "memory_namespace_get_standard",
4852                valid_args: json!({"namespace": "test"}),
4853                required_arg: Some("namespace"),
4854            },
4855            ToolCase {
4856                name: "memory_namespace_clear_standard",
4857                valid_args: json!({"namespace": "test"}),
4858                required_arg: Some("namespace"),
4859            },
4860            ToolCase {
4861                name: "memory_pending_list",
4862                valid_args: json!({}),
4863                required_arg: None,
4864            },
4865            ToolCase {
4866                name: "memory_pending_approve",
4867                valid_args: json!({"id": "fake-id-for-test"}),
4868                required_arg: Some("id"),
4869            },
4870            ToolCase {
4871                name: "memory_pending_reject",
4872                valid_args: json!({"id": "fake-id-for-test"}),
4873                required_arg: Some("id"),
4874            },
4875            ToolCase {
4876                name: "memory_agent_register",
4877                valid_args: json!({"agent_id": "test-agent", "agent_type": "human"}),
4878                required_arg: Some("agent_id"),
4879            },
4880            ToolCase {
4881                name: "memory_agent_list",
4882                valid_args: json!({}),
4883                required_arg: None,
4884            },
4885            ToolCase {
4886                name: "memory_notify",
4887                valid_args: json!({"target_agent_id": "agent", "title": "msg", "payload": "body"}),
4888                required_arg: Some("target_agent_id"),
4889            },
4890            ToolCase {
4891                name: "memory_inbox",
4892                valid_args: json!({}),
4893                required_arg: None,
4894            },
4895            ToolCase {
4896                name: "memory_subscribe",
4897                valid_args: json!({"url": "https://example.com/webhook"}),
4898                required_arg: Some("url"),
4899            },
4900            ToolCase {
4901                name: "memory_unsubscribe",
4902                valid_args: json!({"id": "fake-id-for-test"}),
4903                required_arg: Some("id"),
4904            },
4905            ToolCase {
4906                name: "memory_list_subscriptions",
4907                valid_args: json!({}),
4908                required_arg: None,
4909            },
4910        ];
4911
4912        // Tier 1: happy path tests
4913        for case in cases {
4914            let req = make_tools_call(case.name, case.valid_args.clone());
4915            let resp = handle_request(
4916                &conn,
4917                std::path::Path::new(":memory:"),
4918                &req,
4919                None,
4920                None,
4921                None,
4922                &tier_config,
4923                None,
4924                &resolved_ttl,
4925                &resolved_scoring,
4926                true,
4927                false,
4928                None,
4929                &crate::profile::Profile::full(),
4930                None,
4931            );
4932            assert!(
4933                resp.error.is_none(),
4934                "happy path failed for {}: {:?}",
4935                case.name,
4936                resp.error
4937            );
4938            assert!(
4939                resp.result.is_some(),
4940                "missing result for happy path {}: {:?}",
4941                case.name,
4942                resp
4943            );
4944        }
4945
4946        // Tier 2: required arg validation
4947        for case in cases {
4948            if let Some(required_arg) = case.required_arg {
4949                let mut bad_args = case.valid_args.clone();
4950                bad_args.as_object_mut().unwrap().remove(required_arg);
4951
4952                let req = make_tools_call(case.name, bad_args);
4953                let resp = handle_request(
4954                    &conn,
4955                    std::path::Path::new(":memory:"),
4956                    &req,
4957                    None,
4958                    None,
4959                    None,
4960                    &tier_config,
4961                    None,
4962                    &resolved_ttl,
4963                    &resolved_scoring,
4964                    true,
4965                    false,
4966                    None,
4967                    &crate::profile::Profile::full(),
4968                    None,
4969                );
4970
4971                // Missing required args should produce an error response (handler returns Err)
4972                // which becomes an ok_response with isError=true, not a JSON-RPC error
4973                assert!(
4974                    resp.error.is_none() || resp.result.is_some(),
4975                    "unexpected RPC-layer error for {} (missing {}) should be handler-level Err",
4976                    case.name,
4977                    required_arg
4978                );
4979            }
4980        }
4981    }
4982
4983    // =====================================================================
4984    // W9 / Closer M9 — mcp.rs sweep
4985    //
4986    // Targets the four areas identified in the W9 close-out: tool-handler
4987    // happy/error pairs (per family), JSON-RPC framing (parse / unknown
4988    // method / invalid params), `auto_register_path_hierarchy`, and
4989    // `inject_namespace_standard`. All tests append-only at end of the
4990    // tests module — production code is untouched.
4991    //
4992    // Inner-fn factor-out: `dispatch_line` is added below as a test-only
4993    // helper that mirrors the parse-and-dispatch loop in `run_mcp_server`.
4994    // It is `#[cfg(test)]` and lives inside the `tests` module so it
4995    // does NOT leak into the public surface (no production callers are
4996    // affected). This is the minimum needed to drive parse-error /
4997    // truncation / two-requests-per-line cases without spinning up the
4998    // real stdio loop.
4999    // =====================================================================
5000
5001    /// Build a fully-defaulted handle_request invocation against an
5002    /// in-memory connection. Returns the response so individual tests
5003    /// can assert on `error` / `result` shape.
5004    fn invoke_handle_request(conn: &rusqlite::Connection, req: &RpcRequest) -> RpcResponse {
5005        let tier_config = FeatureTier::Keyword.config();
5006        let resolved_ttl = crate::config::ResolvedTtl::default();
5007        let resolved_scoring = crate::config::ResolvedScoring::default();
5008        handle_request(
5009            conn,
5010            std::path::Path::new(":memory:"),
5011            req,
5012            None,
5013            None,
5014            None,
5015            &tier_config,
5016            None,
5017            &resolved_ttl,
5018            &resolved_scoring,
5019            true,
5020            false,
5021            None,
5022            &crate::profile::Profile::full(),
5023            None,
5024        )
5025    }
5026
5027    /// Test-only helper that mirrors the parse-then-dispatch portion of
5028    /// `run_mcp_server`'s stdin loop for a single line. Returns:
5029    /// - `Some(RpcResponse)` for any line that produces a response
5030    ///   (including parse errors and successful dispatches),
5031    /// - `None` for lines that should not produce a response (blank
5032    ///   lines, valid notifications without an id).
5033    ///
5034    /// This is the minimum factor-out needed to exercise the framing
5035    /// branches that live inside `run_mcp_server` (parse error, blank
5036    /// skip, notification skip) without spinning up real stdio.
5037    fn dispatch_line(conn: &rusqlite::Connection, line: &str) -> Option<RpcResponse> {
5038        if line.trim().is_empty() {
5039            return None;
5040        }
5041        let req: RpcRequest = match serde_json::from_str(line) {
5042            Ok(r) => r,
5043            Err(e) => {
5044                return Some(err_response(
5045                    Value::Null,
5046                    -32700,
5047                    format!("parse error: {e}"),
5048                ));
5049            }
5050        };
5051        if req.id.is_none() || req.id == Some(Value::Null) {
5052            return None;
5053        }
5054        Some(invoke_handle_request(conn, &req))
5055    }
5056
5057    // ------------------------------------------------------------------
5058    // Tool-handler happy-path coverage (paired with error tests below).
5059    // The smoke matrix above already confirms every tool dispatches; the
5060    // tests below assert on the *shape* of the success result so a
5061    // handler that silently changes its return key set fails loudly.
5062    // ------------------------------------------------------------------
5063
5064    #[test]
5065    fn handle_store_happy_returns_id_and_tier() {
5066        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5067        let req = make_tools_call(
5068            "memory_store",
5069            json!({"title": "t", "content": "c", "namespace": "m9-store", "tier": "short"}),
5070        );
5071        let resp = invoke_handle_request(&conn, &req);
5072        assert!(resp.error.is_none());
5073        let text = resp.result.unwrap()["content"][0]["text"]
5074            .as_str()
5075            .unwrap()
5076            .to_string();
5077        let val: Value = serde_json::from_str(&text).unwrap();
5078        assert!(val["id"].is_string());
5079        assert_eq!(val["tier"], "short");
5080    }
5081
5082    #[test]
5083    fn handle_store_error_missing_title() {
5084        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5085        let req = make_tools_call("memory_store", json!({"content": "c"}));
5086        let resp = invoke_handle_request(&conn, &req);
5087        // Handler-level errors come back as ok_response with isError=true.
5088        let result = resp.result.unwrap();
5089        assert_eq!(result["isError"], true);
5090    }
5091
5092    #[test]
5093    fn handle_recall_happy_returns_memories_array() {
5094        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5095        let req = make_tools_call(
5096            "memory_recall",
5097            json!({"context": "anything", "format": "json"}),
5098        );
5099        let resp = invoke_handle_request(&conn, &req);
5100        assert!(resp.error.is_none());
5101        let text = resp.result.unwrap()["content"][0]["text"]
5102            .as_str()
5103            .unwrap()
5104            .to_string();
5105        let val: Value = serde_json::from_str(&text).unwrap();
5106        assert!(val["memories"].is_array());
5107        assert!(val["count"].is_u64());
5108    }
5109
5110    #[test]
5111    fn handle_recall_budget_tokens_zero_returns_empty() {
5112        // Phase P6 (R1): budget_tokens=0 is now a valid request — the
5113        // user explicitly asked for zero context. Returns an empty
5114        // memories array with meta.budget_overflow=false (the user
5115        // didn't overflow anything, they asked for nothing). Supersedes
5116        // the v0.6.3 Ultrareview #348 hard-reject of 0.
5117        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5118        let req = make_tools_call(
5119            "memory_recall",
5120            json!({"context": "x", "budget_tokens": 0, "format": "json"}),
5121        );
5122        let resp = invoke_handle_request(&conn, &req);
5123        assert!(resp.error.is_none(), "budget_tokens=0 must not error");
5124        let text = resp.result.unwrap()["content"][0]["text"]
5125            .as_str()
5126            .unwrap()
5127            .to_string();
5128        let val: Value = serde_json::from_str(&text).unwrap();
5129        assert_eq!(val["count"], 0, "budget_tokens=0 returns zero memories");
5130        assert_eq!(val["budget_tokens"], 0);
5131        assert_eq!(val["tokens_used"], 0);
5132        assert_eq!(val["meta"]["budget_overflow"], false);
5133        assert_eq!(val["meta"]["budget_tokens_used"], 0);
5134        assert_eq!(val["meta"]["budget_tokens_remaining"], 0);
5135    }
5136
5137    #[test]
5138    fn handle_search_happy_returns_results_array() {
5139        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5140        let req = make_tools_call(
5141            "memory_search",
5142            json!({"query": "needle", "format": "json"}),
5143        );
5144        let resp = invoke_handle_request(&conn, &req);
5145        assert!(resp.error.is_none());
5146        let text = resp.result.unwrap()["content"][0]["text"]
5147            .as_str()
5148            .unwrap()
5149            .to_string();
5150        let val: Value = serde_json::from_str(&text).unwrap();
5151        assert!(val["results"].is_array());
5152        assert!(val["count"].is_u64());
5153    }
5154
5155    #[test]
5156    fn handle_search_error_missing_query() {
5157        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5158        let req = make_tools_call("memory_search", json!({}));
5159        let resp = invoke_handle_request(&conn, &req);
5160        let result = resp.result.unwrap();
5161        assert_eq!(result["isError"], true);
5162    }
5163
5164    #[test]
5165    fn handle_get_happy_returns_memory() {
5166        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5167        // Insert a memory directly to know the id.
5168        let mem = Memory {
5169            id: uuid::Uuid::new_v4().to_string(),
5170            tier: Tier::Mid,
5171            namespace: "m9-get".into(),
5172            title: "t".into(),
5173            content: "c".into(),
5174            tags: vec![],
5175            priority: 5,
5176            confidence: 1.0,
5177            source: "test".into(),
5178            access_count: 0,
5179            created_at: chrono::Utc::now().to_rfc3339(),
5180            updated_at: chrono::Utc::now().to_rfc3339(),
5181            last_accessed_at: None,
5182            expires_at: None,
5183            metadata: json!({}),
5184        };
5185        let id = db::insert(&conn, &mem).unwrap();
5186        let req = make_tools_call("memory_get", json!({"id": id}));
5187        let resp = invoke_handle_request(&conn, &req);
5188        assert!(resp.error.is_none());
5189        let text = resp.result.unwrap()["content"][0]["text"]
5190            .as_str()
5191            .unwrap()
5192            .to_string();
5193        let val: Value = serde_json::from_str(&text).unwrap();
5194        assert_eq!(val["title"], "t");
5195        assert_eq!(val["namespace"], "m9-get");
5196        assert!(val["links"].is_array());
5197    }
5198
5199    #[test]
5200    fn handle_get_error_unknown_id() {
5201        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5202        let req = make_tools_call(
5203            "memory_get",
5204            json!({"id": "00000000-0000-0000-0000-000000000000"}),
5205        );
5206        let resp = invoke_handle_request(&conn, &req);
5207        let result = resp.result.unwrap();
5208        assert_eq!(result["isError"], true);
5209        assert!(
5210            result["content"][0]["text"]
5211                .as_str()
5212                .unwrap()
5213                .contains("not found")
5214        );
5215    }
5216
5217    #[test]
5218    fn handle_list_happy_returns_memories_array() {
5219        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5220        let req = make_tools_call("memory_list", json!({"format": "json"}));
5221        let resp = invoke_handle_request(&conn, &req);
5222        assert!(resp.error.is_none());
5223        let text = resp.result.unwrap()["content"][0]["text"]
5224            .as_str()
5225            .unwrap()
5226            .to_string();
5227        let val: Value = serde_json::from_str(&text).unwrap();
5228        assert!(val["memories"].is_array());
5229    }
5230
5231    #[test]
5232    fn handle_list_error_invalid_agent_id() {
5233        // Invalid agent_id (contains a space) is rejected upstream.
5234        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5235        let req = make_tools_call("memory_list", json!({"agent_id": "has space"}));
5236        let resp = invoke_handle_request(&conn, &req);
5237        let result = resp.result.unwrap();
5238        assert_eq!(result["isError"], true);
5239    }
5240
5241    #[test]
5242    fn handle_delete_happy_removes_existing_memory() {
5243        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5244        let mem = Memory {
5245            id: uuid::Uuid::new_v4().to_string(),
5246            tier: Tier::Mid,
5247            namespace: "m9-del".into(),
5248            title: "t".into(),
5249            content: "c".into(),
5250            tags: vec![],
5251            priority: 5,
5252            confidence: 1.0,
5253            source: "test".into(),
5254            access_count: 0,
5255            created_at: chrono::Utc::now().to_rfc3339(),
5256            updated_at: chrono::Utc::now().to_rfc3339(),
5257            last_accessed_at: None,
5258            expires_at: None,
5259            metadata: json!({}),
5260        };
5261        let id = db::insert(&conn, &mem).unwrap();
5262        let req = make_tools_call("memory_delete", json!({"id": id}));
5263        let resp = invoke_handle_request(&conn, &req);
5264        assert!(resp.error.is_none());
5265        let text = resp.result.unwrap()["content"][0]["text"]
5266            .as_str()
5267            .unwrap()
5268            .to_string();
5269        let val: Value = serde_json::from_str(&text).unwrap();
5270        assert_eq!(val["deleted"], true);
5271    }
5272
5273    #[test]
5274    fn handle_delete_error_empty_id() {
5275        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5276        let req = make_tools_call("memory_delete", json!({"id": ""}));
5277        let resp = invoke_handle_request(&conn, &req);
5278        let result = resp.result.unwrap();
5279        assert_eq!(result["isError"], true);
5280    }
5281
5282    #[test]
5283    fn handle_link_happy_returns_linked_true() {
5284        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5285        let mut ids = Vec::new();
5286        for tag in ["a", "b"] {
5287            let mem = Memory {
5288                id: uuid::Uuid::new_v4().to_string(),
5289                tier: Tier::Mid,
5290                namespace: "m9-link".into(),
5291                title: tag.into(),
5292                content: "c".into(),
5293                tags: vec![],
5294                priority: 5,
5295                confidence: 1.0,
5296                source: "test".into(),
5297                access_count: 0,
5298                created_at: chrono::Utc::now().to_rfc3339(),
5299                updated_at: chrono::Utc::now().to_rfc3339(),
5300                last_accessed_at: None,
5301                expires_at: None,
5302                metadata: json!({}),
5303            };
5304            ids.push(db::insert(&conn, &mem).unwrap());
5305        }
5306        let req = make_tools_call(
5307            "memory_link",
5308            json!({"source_id": ids[0], "target_id": ids[1], "relation": "related_to"}),
5309        );
5310        let resp = invoke_handle_request(&conn, &req);
5311        assert!(resp.error.is_none());
5312        let text = resp.result.unwrap()["content"][0]["text"]
5313            .as_str()
5314            .unwrap()
5315            .to_string();
5316        let val: Value = serde_json::from_str(&text).unwrap();
5317        assert_eq!(val["linked"], true);
5318    }
5319
5320    #[test]
5321    fn handle_link_error_missing_target_id() {
5322        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5323        let req = make_tools_call("memory_link", json!({"source_id": "x"}));
5324        let resp = invoke_handle_request(&conn, &req);
5325        let result = resp.result.unwrap();
5326        assert_eq!(result["isError"], true);
5327    }
5328
5329    #[test]
5330    fn handle_promote_error_unknown_id() {
5331        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5332        let req = make_tools_call(
5333            "memory_promote",
5334            json!({"id": "00000000-0000-0000-0000-000000000000"}),
5335        );
5336        let resp = invoke_handle_request(&conn, &req);
5337        let result = resp.result.unwrap();
5338        assert_eq!(result["isError"], true);
5339    }
5340
5341    #[test]
5342    fn handle_consolidate_error_missing_summary_keyword_tier() {
5343        // Keyword tier has no LLM, so `summary` is required.
5344        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5345        let req = make_tools_call(
5346            "memory_consolidate",
5347            json!({"ids": ["a", "b"], "title": "t"}),
5348        );
5349        let resp = invoke_handle_request(&conn, &req);
5350        let result = resp.result.unwrap();
5351        assert_eq!(result["isError"], true);
5352        let msg = result["content"][0]["text"].as_str().unwrap();
5353        assert!(msg.contains("summary"));
5354    }
5355
5356    #[test]
5357    fn handle_capabilities_happy_returns_tier_struct() {
5358        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5359        let req = make_tools_call("memory_capabilities", json!({}));
5360        let resp = invoke_handle_request(&conn, &req);
5361        assert!(resp.error.is_none());
5362        let text = resp.result.unwrap()["content"][0]["text"]
5363            .as_str()
5364            .unwrap()
5365            .to_string();
5366        let val: Value = serde_json::from_str(&text).unwrap();
5367        assert!(val["tier"].is_string());
5368        assert!(val["features"].is_object());
5369    }
5370
5371    /// v0.6.3.1 (capabilities schema v2 — P1 honesty patch).
5372    /// Every new top-level block is present with the expected shape.
5373    /// Dropped fields (`rule_summary`, `by_event`, `subscribers`,
5374    /// `default_timeout_seconds`) must be absent from v2 output.
5375    #[test]
5376    fn mcp_capabilities_v2_schema_includes_all_blocks() {
5377        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5378        let req = make_tools_call("memory_capabilities", json!({}));
5379        let resp = invoke_handle_request(&conn, &req);
5380        let text = resp.result.unwrap()["content"][0]["text"]
5381            .as_str()
5382            .unwrap()
5383            .to_string();
5384        let val: Value = serde_json::from_str(&text).unwrap();
5385
5386        assert_eq!(val["schema_version"], "2", "schema_version bumped to 2");
5387
5388        // permissions block — `mode` flipped from "ask" to "advisory"
5389        // (P1 honesty patch: no enforcement gate exists pre-P4).
5390        assert!(val["permissions"].is_object(), "permissions block present");
5391        assert_eq!(val["permissions"]["mode"], "advisory");
5392        assert!(val["permissions"]["active_rules"].is_number());
5393        assert!(
5394            val["permissions"].get("rule_summary").is_none(),
5395            "v2 drops rule_summary (no per-rule serializer)"
5396        );
5397        // v0.6.3.1 (P4, audit G1): inheritance posture must be reported
5398        // as "enforced" so consumers can distinguish a fixed deployment
5399        // from a pre-fix one (which historically returned "display_only").
5400        assert_eq!(val["permissions"]["inheritance"], "enforced");
5401
5402        // hooks block — `by_event` dropped (no event registry).
5403        assert!(val["hooks"].is_object(), "hooks block present");
5404        assert!(val["hooks"]["registered_count"].is_number());
5405        assert!(
5406            val["hooks"].get("by_event").is_none(),
5407            "v2 drops hooks.by_event (no event registry)"
5408        );
5409
5410        // compaction block — planned-feature shape (P1 honesty patch).
5411        assert!(val["compaction"].is_object(), "compaction block present");
5412        assert_eq!(val["compaction"]["planned"], true);
5413        assert_eq!(val["compaction"]["enabled"], false);
5414        assert_eq!(val["compaction"]["version"], "v0.8+");
5415        assert!(val["compaction"].get("interval_minutes").is_none());
5416        assert!(val["compaction"].get("last_run_at").is_none());
5417        assert!(val["compaction"].get("last_run_stats").is_none());
5418
5419        // approval block — `subscribers` and `default_timeout_seconds`
5420        // dropped (no subscription API, no sweeper).
5421        assert!(val["approval"].is_object(), "approval block present");
5422        assert!(val["approval"]["pending_requests"].is_number());
5423        assert!(
5424            val["approval"].get("subscribers").is_none(),
5425            "v2 drops approval.subscribers (no subscription API)"
5426        );
5427        assert!(
5428            val["approval"].get("default_timeout_seconds").is_none(),
5429            "v2 drops approval.default_timeout_seconds (no sweeper)"
5430        );
5431
5432        // transcripts block — planned-feature shape (P1 honesty patch).
5433        assert!(val["transcripts"].is_object(), "transcripts block present");
5434        assert_eq!(val["transcripts"]["planned"], true);
5435        assert_eq!(val["transcripts"]["enabled"], false);
5436        assert_eq!(val["transcripts"]["version"], "v0.7+");
5437
5438        // memory_reflection: planned-feature object (was bool in v1).
5439        assert_eq!(val["features"]["memory_reflection"]["planned"], true);
5440        assert_eq!(val["features"]["memory_reflection"]["enabled"], false);
5441
5442        // Live runtime overlays: keyword-tier daemon with no embedder
5443        // and no reranker → disabled / off.
5444        assert_eq!(val["features"]["recall_mode_active"], "disabled");
5445        assert_eq!(val["features"]["reranker_active"], "off");
5446    }
5447
5448    /// v0.6.3.1 (P1 honesty patch). Default v2 response keeps the legacy
5449    /// top-level keys (`tier`, `version`, `features`, `models`) so old
5450    /// path-readers don't break, even though `memory_reflection` was
5451    /// reshaped into an object.
5452    #[test]
5453    fn mcp_capabilities_v2_backwards_compatible() {
5454        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5455        let req = make_tools_call("memory_capabilities", json!({}));
5456        let resp = invoke_handle_request(&conn, &req);
5457        let text = resp.result.unwrap()["content"][0]["text"]
5458            .as_str()
5459            .unwrap()
5460            .to_string();
5461        let val: Value = serde_json::from_str(&text).unwrap();
5462
5463        // v1 top-level keys preserved at the same paths
5464        assert!(val["tier"].is_string(), "v1: tier preserved");
5465        assert!(val["version"].is_string(), "v1: version preserved");
5466        assert!(val["features"].is_object(), "v1: features preserved");
5467        assert!(val["models"].is_object(), "v1: models preserved");
5468
5469        // Well-known v1 sub-fields still resolve.
5470        assert!(val["features"]["keyword_search"].is_boolean());
5471        assert!(val["features"]["semantic_search"].is_boolean());
5472        assert!(val["features"]["embedder_loaded"].is_boolean());
5473        assert!(val["models"]["embedding"].is_string());
5474        assert!(val["models"]["llm"].is_string());
5475        assert!(val["models"]["cross_encoder"].is_string());
5476    }
5477
5478    /// P1 honesty patch: explicit `accept = "v1"` returns the legacy
5479    /// shape (no `schema_version`, `memory_reflection` is a bool, no
5480    /// v2-only blocks).
5481    #[test]
5482    fn mcp_capabilities_accept_v1_returns_legacy_shape() {
5483        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5484        let req = make_tools_call("memory_capabilities", json!({"accept": "v1"}));
5485        let resp = invoke_handle_request(&conn, &req);
5486        let text = resp.result.unwrap()["content"][0]["text"]
5487            .as_str()
5488            .unwrap()
5489            .to_string();
5490        let val: Value = serde_json::from_str(&text).unwrap();
5491
5492        // v1 has no schema_version
5493        assert!(val.get("schema_version").is_none());
5494        // v2-only blocks are absent
5495        assert!(val.get("permissions").is_none());
5496        assert!(val.get("hooks").is_none());
5497        assert!(val.get("compaction").is_none());
5498        assert!(val.get("approval").is_none());
5499        assert!(val.get("transcripts").is_none());
5500        // v1 features.memory_reflection is a bool (not the v2 object)
5501        assert!(val["features"]["memory_reflection"].is_boolean());
5502        // v1 features carry no recall_mode_active / reranker_active
5503        assert!(val["features"].get("recall_mode_active").is_none());
5504        assert!(val["features"].get("reranker_active").is_none());
5505    }
5506
5507    /// v0.6.3 (capabilities schema v2). `approval.pending_requests`
5508    /// reflects the live `pending_actions` count — the one block that is
5509    /// already wired through to a real subsystem instead of zero-state.
5510    #[test]
5511    fn mcp_capabilities_pending_requests_reflects_db() {
5512        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5513        // Insert a pending action by hand (the queue path is exercised
5514        // elsewhere; here we only need the count to bump).
5515        let payload = serde_json::json!({"foo": "bar"}).to_string();
5516        conn.execute(
5517            "INSERT INTO pending_actions (id, action_type, memory_id, namespace,
5518                payload, requested_by, requested_at, status)
5519             VALUES ('p-1', 'store', NULL, 'global', ?1, 'agent-1',
5520                '2026-04-27T00:00:00Z', 'pending')",
5521            rusqlite::params![payload],
5522        )
5523        .unwrap();
5524
5525        let req = make_tools_call("memory_capabilities", json!({}));
5526        let resp = invoke_handle_request(&conn, &req);
5527        let text = resp.result.unwrap()["content"][0]["text"]
5528            .as_str()
5529            .unwrap()
5530            .to_string();
5531        let val: Value = serde_json::from_str(&text).unwrap();
5532
5533        assert_eq!(
5534            val["approval"]["pending_requests"], 1,
5535            "pending_actions(status=pending) count surfaces live"
5536        );
5537    }
5538
5539    #[test]
5540    fn handle_subscribe_error_unregistered_agent() {
5541        // memory_subscribe refuses unregistered callers (#301 item 4).
5542        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5543        let req = make_tools_call(
5544            "memory_subscribe",
5545            json!({"url": "https://example.com/hook"}),
5546        );
5547        let resp = invoke_handle_request(&conn, &req);
5548        let result = resp.result.unwrap();
5549        assert_eq!(result["isError"], true);
5550        let msg = result["content"][0]["text"].as_str().unwrap();
5551        assert!(msg.contains("not registered"));
5552    }
5553
5554    // ------------------------------------------------------------------
5555    // JSON-RPC framing — drives `dispatch_line` and `handle_request`.
5556    // ------------------------------------------------------------------
5557
5558    #[test]
5559    fn test_jsonrpc_handles_well_formed_request() {
5560        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5561        let line = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
5562        let resp = dispatch_line(&conn, line).expect("expected response");
5563        assert!(resp.error.is_none());
5564        let result = resp.result.unwrap();
5565        assert!(result["tools"].is_array());
5566    }
5567
5568    #[test]
5569    fn test_jsonrpc_handles_malformed_json() {
5570        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5571        // Garbage on a single line.
5572        let resp = dispatch_line(&conn, "this is not json at all").expect("expected response");
5573        let err = resp.error.unwrap();
5574        assert_eq!(err.code, -32700);
5575        assert!(err.message.contains("parse error"));
5576        // Spec: id MUST be Null for parse errors.
5577        assert_eq!(resp.id, Value::Null);
5578    }
5579
5580    #[test]
5581    fn test_jsonrpc_handles_truncated_request() {
5582        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5583        // Incomplete JSON object — serde_json must reject.
5584        let resp = dispatch_line(&conn, r#"{"jsonrpc":"2.0","id":1,"method":"#)
5585            .expect("expected response");
5586        let err = resp.error.unwrap();
5587        assert_eq!(err.code, -32700);
5588    }
5589
5590    #[test]
5591    fn test_jsonrpc_handles_two_requests_per_line() {
5592        // The MCP framing is line-delimited JSON: one request per line.
5593        // If a peer accidentally pastes two JSON objects on one line
5594        // (`{...}{...}`), serde_json::from_str must reject as parse
5595        // error rather than silently process the first.
5596        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5597        let line = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"} {"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
5598        let resp = dispatch_line(&conn, line).expect("expected response");
5599        let err = resp.error.unwrap();
5600        assert_eq!(err.code, -32700);
5601    }
5602
5603    #[test]
5604    fn test_jsonrpc_handles_blank_line() {
5605        // Blank lines are skipped (no response).
5606        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5607        assert!(dispatch_line(&conn, "").is_none());
5608        assert!(dispatch_line(&conn, "   \t  ").is_none());
5609    }
5610
5611    #[test]
5612    fn test_jsonrpc_handles_notification_no_response() {
5613        // Requests without an `id` are JSON-RPC notifications — no
5614        // response should be emitted per spec.
5615        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5616        let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
5617        assert!(dispatch_line(&conn, line).is_none());
5618        // Explicit id:null is also a notification.
5619        let line_null = r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized"}"#;
5620        assert!(dispatch_line(&conn, line_null).is_none());
5621    }
5622
5623    #[test]
5624    fn test_jsonrpc_handles_method_not_found() {
5625        // Unknown JSON-RPC method must return -32601.
5626        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5627        let req = RpcRequest {
5628            jsonrpc: "2.0".into(),
5629            id: Some(json!(7)),
5630            method: "no/such/method".into(),
5631            params: json!({}),
5632        };
5633        let resp = invoke_handle_request(&conn, &req);
5634        let err = resp.error.unwrap();
5635        assert_eq!(err.code, -32601);
5636        assert!(err.message.contains("method not found"));
5637    }
5638
5639    #[test]
5640    fn test_jsonrpc_handles_invalid_params() {
5641        // tools/call with a missing tool name must surface -32602.
5642        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5643        let req = RpcRequest {
5644            jsonrpc: "2.0".into(),
5645            id: Some(json!(8)),
5646            method: "tools/call".into(),
5647            params: json!({"arguments": {}}),
5648        };
5649        let resp = invoke_handle_request(&conn, &req);
5650        let err = resp.error.unwrap();
5651        assert_eq!(err.code, -32602);
5652    }
5653
5654    #[test]
5655    fn test_jsonrpc_handles_unknown_tool_returns_minus_32601() {
5656        // Ultrareview #349: unknown tool = method-not-found, not isError.
5657        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5658        let req = make_tools_call("memory_does_not_exist", json!({}));
5659        let resp = invoke_handle_request(&conn, &req);
5660        let err = resp.error.unwrap();
5661        assert_eq!(err.code, -32601);
5662        assert!(err.message.contains("memory_does_not_exist"));
5663    }
5664
5665    #[test]
5666    fn test_jsonrpc_rejects_wrong_version() {
5667        // jsonrpc field must be exactly "2.0" — anything else = -32600.
5668        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5669        let req = RpcRequest {
5670            jsonrpc: "1.0".into(),
5671            id: Some(json!(1)),
5672            method: "tools/list".into(),
5673            params: json!({}),
5674        };
5675        let resp = invoke_handle_request(&conn, &req);
5676        let err = resp.error.unwrap();
5677        assert_eq!(err.code, -32600);
5678    }
5679
5680    #[test]
5681    fn test_jsonrpc_handles_initialize() {
5682        // Initialize handshake returns serverInfo + protocolVersion.
5683        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5684        let req = RpcRequest {
5685            jsonrpc: "2.0".into(),
5686            id: Some(json!(1)),
5687            method: "initialize".into(),
5688            params: json!({"clientInfo": {"name": "test-client"}}),
5689        };
5690        let resp = invoke_handle_request(&conn, &req);
5691        assert!(resp.error.is_none());
5692        let result = resp.result.unwrap();
5693        assert_eq!(result["protocolVersion"], "2024-11-05");
5694        assert_eq!(result["serverInfo"]["name"], "ai-memory");
5695    }
5696
5697    // ------------------------------------------------------------------
5698    // auto_register_path_hierarchy — exercises the bail-out branches.
5699    //
5700    // The function only mutates rows whose `parent_namespace IS NULL`,
5701    // walking from `cwd().parent()` up to the home directory. The
5702    // working directory in `cargo test` is the crate root, which
5703    // typically lives under `home`, so the walk runs but finds no
5704    // matching parent (no namespace_meta row for any ancestor dir
5705    // name). Tests below cover: (1) no-op when an explicit parent is
5706    // already set, (2) no-op when the namespace has no row, (3) safe
5707    // call with an empty-string namespace, (4) idempotency.
5708    // ------------------------------------------------------------------
5709
5710    #[test]
5711    fn test_auto_register_creates_top_level_namespace() {
5712        // With no namespace_meta row at all, the walk finds nothing
5713        // and the table stays empty (silent no-op, never panics).
5714        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5715        super::auto_register_path_hierarchy(&conn, "m9-top");
5716        let count: i64 = conn
5717            .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
5718            .unwrap();
5719        assert_eq!(count, 0);
5720    }
5721
5722    #[test]
5723    fn test_auto_register_creates_nested_hierarchy() {
5724        // Pre-seed a row for "repo/team/sub" with parent NULL. The walk
5725        // looks for any ancestor *directory name* that has a standard;
5726        // since none of the test-runner's cwd ancestors will collide
5727        // with synthetic namespace names, the row's parent stays NULL.
5728        // The contract tested is: function tolerates nested-form inputs
5729        // without panicking and never overwrites a row whose parent is
5730        // already set.
5731        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5732        // Insert a synthetic standard for "m9-parent" so the walk *could*
5733        // match if cwd happened to be inside a "m9-parent" dir; in
5734        // practice it won't, so the row's parent stays NULL.
5735        let mem = Memory {
5736            id: uuid::Uuid::new_v4().to_string(),
5737            tier: Tier::Long,
5738            namespace: "m9-parent".into(),
5739            title: "parent standard".into(),
5740            content: "...".into(),
5741            tags: vec![],
5742            priority: 5,
5743            confidence: 1.0,
5744            source: "test".into(),
5745            access_count: 0,
5746            created_at: chrono::Utc::now().to_rfc3339(),
5747            updated_at: chrono::Utc::now().to_rfc3339(),
5748            last_accessed_at: None,
5749            expires_at: None,
5750            metadata: json!({}),
5751        };
5752        let std_id = db::insert(&conn, &mem).unwrap();
5753        db::set_namespace_standard(&conn, "m9-parent", &std_id, None).unwrap();
5754        // Seed a child row with parent NULL.
5755        let child_mem = Memory {
5756            id: uuid::Uuid::new_v4().to_string(),
5757            tier: Tier::Long,
5758            namespace: "repo/team/sub".into(),
5759            title: "child".into(),
5760            content: "...".into(),
5761            tags: vec![],
5762            priority: 5,
5763            confidence: 1.0,
5764            source: "test".into(),
5765            access_count: 0,
5766            created_at: chrono::Utc::now().to_rfc3339(),
5767            updated_at: chrono::Utc::now().to_rfc3339(),
5768            last_accessed_at: None,
5769            expires_at: None,
5770            metadata: json!({}),
5771        };
5772        let child_id = db::insert(&conn, &child_mem).unwrap();
5773        db::set_namespace_standard(&conn, "repo/team/sub", &child_id, None).unwrap();
5774        // Run the walk — must not panic, must not corrupt rows.
5775        super::auto_register_path_hierarchy(&conn, "repo/team/sub");
5776        // The seeded standard is still readable.
5777        let id = db::get_namespace_standard(&conn, "repo/team/sub")
5778            .unwrap()
5779            .unwrap();
5780        assert_eq!(id, child_id);
5781    }
5782
5783    #[test]
5784    fn test_auto_register_idempotent() {
5785        // Calling twice must not corrupt state — even when no match is
5786        // found, the second call observes the same DB.
5787        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5788        super::auto_register_path_hierarchy(&conn, "m9-idem");
5789        super::auto_register_path_hierarchy(&conn, "m9-idem");
5790        let count: i64 = conn
5791            .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
5792            .unwrap();
5793        assert_eq!(count, 0);
5794    }
5795
5796    #[test]
5797    fn test_auto_register_handles_empty_string_or_root() {
5798        // Empty / root-y inputs must not panic. The walk is a pure
5799        // observer when the namespace_meta row is absent.
5800        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5801        super::auto_register_path_hierarchy(&conn, "");
5802        super::auto_register_path_hierarchy(&conn, "/");
5803        super::auto_register_path_hierarchy(&conn, "*");
5804        // Still no rows, no crash.
5805        let count: i64 = conn
5806            .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
5807            .unwrap();
5808        assert_eq!(count, 0);
5809    }
5810
5811    #[test]
5812    fn test_auto_register_skips_when_explicit_parent_set() {
5813        // Early-return path: if `get_namespace_parent` already returns
5814        // Some, the walk is skipped entirely. We verify by calling the
5815        // function and asserting that the explicit parent is preserved.
5816        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5817        // Seed two memories so we can register parent and child.
5818        let parent_mem = Memory {
5819            id: uuid::Uuid::new_v4().to_string(),
5820            tier: Tier::Long,
5821            namespace: "m9-explicit-parent".into(),
5822            title: "p".into(),
5823            content: "c".into(),
5824            tags: vec![],
5825            priority: 5,
5826            confidence: 1.0,
5827            source: "test".into(),
5828            access_count: 0,
5829            created_at: chrono::Utc::now().to_rfc3339(),
5830            updated_at: chrono::Utc::now().to_rfc3339(),
5831            last_accessed_at: None,
5832            expires_at: None,
5833            metadata: json!({}),
5834        };
5835        let parent_id = db::insert(&conn, &parent_mem).unwrap();
5836        db::set_namespace_standard(&conn, "m9-explicit-parent", &parent_id, None).unwrap();
5837
5838        let child_mem = Memory {
5839            id: uuid::Uuid::new_v4().to_string(),
5840            tier: Tier::Long,
5841            namespace: "m9-explicit-child".into(),
5842            title: "c".into(),
5843            content: "c".into(),
5844            tags: vec![],
5845            priority: 5,
5846            confidence: 1.0,
5847            source: "test".into(),
5848            access_count: 0,
5849            created_at: chrono::Utc::now().to_rfc3339(),
5850            updated_at: chrono::Utc::now().to_rfc3339(),
5851            last_accessed_at: None,
5852            expires_at: None,
5853            metadata: json!({}),
5854        };
5855        let child_id = db::insert(&conn, &child_mem).unwrap();
5856        db::set_namespace_standard(
5857            &conn,
5858            "m9-explicit-child",
5859            &child_id,
5860            Some("m9-explicit-parent"),
5861        )
5862        .unwrap();
5863
5864        // Pre-condition: parent is set.
5865        assert_eq!(
5866            db::get_namespace_parent(&conn, "m9-explicit-child"),
5867            Some("m9-explicit-parent".to_string())
5868        );
5869        super::auto_register_path_hierarchy(&conn, "m9-explicit-child");
5870        // Post-condition: parent unchanged.
5871        assert_eq!(
5872            db::get_namespace_parent(&conn, "m9-explicit-child"),
5873            Some("m9-explicit-parent".to_string())
5874        );
5875    }
5876
5877    // ------------------------------------------------------------------
5878    // inject_namespace_standard — coverage for the four shape branches.
5879    // ------------------------------------------------------------------
5880
5881    fn make_recall_response(memories: Vec<Value>) -> Value {
5882        let count = memories.len();
5883        json!({
5884            "memories": memories,
5885            "count": count,
5886            "mode": "keyword",
5887        })
5888    }
5889
5890    fn seed_namespace_standard(
5891        conn: &rusqlite::Connection,
5892        namespace: &str,
5893        title: &str,
5894    ) -> String {
5895        let mem = Memory {
5896            id: uuid::Uuid::new_v4().to_string(),
5897            tier: Tier::Long,
5898            namespace: namespace.into(),
5899            title: title.into(),
5900            content: "policy text".into(),
5901            tags: vec!["_standard".into()],
5902            priority: 5,
5903            confidence: 1.0,
5904            source: "test".into(),
5905            access_count: 0,
5906            created_at: chrono::Utc::now().to_rfc3339(),
5907            updated_at: chrono::Utc::now().to_rfc3339(),
5908            last_accessed_at: None,
5909            expires_at: None,
5910            metadata: json!({}),
5911        };
5912        let id = db::insert(conn, &mem).unwrap();
5913        db::set_namespace_standard(conn, namespace, &id, None).unwrap();
5914        id
5915    }
5916
5917    #[test]
5918    fn test_inject_namespace_standard_attaches_when_present() {
5919        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5920        let std_id = seed_namespace_standard(&conn, "m9-inject-attach", "S");
5921        let mut resp = make_recall_response(vec![]);
5922        super::inject_namespace_standard(&conn, Some("m9-inject-attach"), &mut resp);
5923        assert!(resp["standard"].is_object(), "expected attached standard");
5924        assert_eq!(resp["standard"]["id"].as_str().unwrap(), std_id);
5925    }
5926
5927    #[test]
5928    fn test_inject_namespace_standard_skips_when_absent() {
5929        // No standard set anywhere → response is unchanged (no
5930        // `standard` / `standards` field added).
5931        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5932        let mut resp = make_recall_response(vec![]);
5933        let before = resp.clone();
5934        super::inject_namespace_standard(&conn, Some("m9-inject-empty"), &mut resp);
5935        assert_eq!(resp, before);
5936        assert!(resp.get("standard").is_none());
5937        assert!(resp.get("standards").is_none());
5938    }
5939
5940    #[test]
5941    fn test_inject_namespace_standard_top_of_recall_response() {
5942        // The standard's own memory must be filtered OUT of the
5943        // `memories` array so the client doesn't see the policy
5944        // duplicated as a result + as the `standard` field.
5945        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5946        let std_id = seed_namespace_standard(&conn, "m9-inject-dedup", "S");
5947        // Pretend recall returned the standard as one of its hits.
5948        let dup = json!({"id": std_id, "title": "S", "content": "policy text"});
5949        let other = json!({"id": "other-id", "title": "noise", "content": "x"});
5950        let mut resp = make_recall_response(vec![dup.clone(), other.clone()]);
5951        super::inject_namespace_standard(&conn, Some("m9-inject-dedup"), &mut resp);
5952        assert_eq!(resp["standard"]["id"].as_str().unwrap(), std_id);
5953        let memories = resp["memories"].as_array().unwrap();
5954        assert_eq!(memories.len(), 1);
5955        assert_eq!(memories[0]["id"], "other-id");
5956        assert_eq!(resp["count"], 1);
5957    }
5958
5959    #[test]
5960    fn test_inject_namespace_standard_preserves_other_response_fields() {
5961        // Mode / count / unrelated fields must survive injection.
5962        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5963        seed_namespace_standard(&conn, "m9-inject-preserve", "S");
5964        let mut resp = json!({
5965            "memories": [],
5966            "count": 0,
5967            "mode": "hybrid",
5968            "diagnostics": {"latency_ms": 42},
5969        });
5970        super::inject_namespace_standard(&conn, Some("m9-inject-preserve"), &mut resp);
5971        assert_eq!(resp["mode"], "hybrid");
5972        assert_eq!(resp["diagnostics"]["latency_ms"], 42);
5973        assert!(resp["standard"].is_object());
5974    }
5975
5976    #[test]
5977    fn test_inject_namespace_standard_no_namespace_uses_global() {
5978        // When `namespace` is None, only the global "*" standard is
5979        // consulted. We seed "*" and assert it's attached.
5980        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5981        seed_namespace_standard(&conn, "*", "global standard");
5982        let mut resp = make_recall_response(vec![]);
5983        super::inject_namespace_standard(&conn, None, &mut resp);
5984        assert_eq!(resp["standard"]["title"], "global standard");
5985    }
5986
5987    #[test]
5988    fn test_inject_namespace_standard_multiple_levels_emits_array() {
5989        // When more than one standard applies (global + namespace),
5990        // the response gets a `standards` array, not a single
5991        // `standard` object.
5992        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5993        seed_namespace_standard(&conn, "*", "GLOBAL");
5994        seed_namespace_standard(&conn, "m9-multi", "LOCAL");
5995        let mut resp = make_recall_response(vec![]);
5996        super::inject_namespace_standard(&conn, Some("m9-multi"), &mut resp);
5997        assert!(resp["standards"].is_array());
5998        let arr = resp["standards"].as_array().unwrap();
5999        assert_eq!(arr.len(), 2);
6000        // Order: global ("*") first, then namespace-specific.
6001        assert_eq!(arr[0]["title"], "GLOBAL");
6002        assert_eq!(arr[1]["title"], "LOCAL");
6003        assert!(resp.get("standard").is_none());
6004    }
6005
6006    // =====================================================================
6007    // W12 / Closer W12-A — mcp.rs deeper sweep
6008    //
6009    // M9 covered the first 40 tests. W12-A targets the residual ~750
6010    // uncovered lines with focus on:
6011    //   1) Less-common tool handlers (archive_*, kg_*, agent_*, notify,
6012    //      inbox, namespace_*, pending_*, gc, session_start)
6013    //   2) Per-handler error branches not hit by the smoke matrix's "drop
6014    //      one required arg" pass — invalid argument shape, validation
6015    //      failures, "not found" lookups
6016    //   3) JSON-RPC framing edge cases beyond M9's six (nested method
6017    //      strings, unicode, empty params, prompts/list, prompts/get
6018    //      errors, ping)
6019    //   4) Helper-fn coverage holes — `inject_namespace_standard` shape
6020    //      branches, `auto_register_path_hierarchy` walk variants
6021    //
6022    // All tests use the test-only `invoke_handle_request` helper from
6023    // M9 to avoid repeating the 13-arg call site.
6024    // =====================================================================
6025
6026    // ------------------------------------------------------------------
6027    // Less-common tool handlers — happy paths
6028    // ------------------------------------------------------------------
6029
6030    #[test]
6031    fn handle_archive_list_returns_empty_when_no_archived() {
6032        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6033        let req = make_tools_call("memory_archive_list", json!({}));
6034        let resp = invoke_handle_request(&conn, &req);
6035        assert!(resp.error.is_none());
6036        let text = resp.result.unwrap()["content"][0]["text"]
6037            .as_str()
6038            .unwrap()
6039            .to_string();
6040        let val: Value = serde_json::from_str(&text).unwrap();
6041        assert_eq!(val["count"], 0);
6042        assert!(val["archived"].is_array());
6043    }
6044
6045    #[test]
6046    fn handle_archive_list_with_namespace_filter() {
6047        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6048        let req = make_tools_call(
6049            "memory_archive_list",
6050            json!({"namespace": "w12-archive", "limit": 5, "offset": 0}),
6051        );
6052        let resp = invoke_handle_request(&conn, &req);
6053        assert!(resp.error.is_none());
6054    }
6055
6056    #[test]
6057    fn handle_archive_restore_unknown_id_returns_error() {
6058        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6059        let req = make_tools_call(
6060            "memory_archive_restore",
6061            json!({"id": "00000000-0000-0000-0000-000000000000"}),
6062        );
6063        let resp = invoke_handle_request(&conn, &req);
6064        let result = resp.result.unwrap();
6065        assert_eq!(result["isError"], true);
6066        let msg = result["content"][0]["text"].as_str().unwrap();
6067        assert!(msg.contains("archive") || msg.contains("not found"));
6068    }
6069
6070    #[test]
6071    fn handle_archive_purge_with_older_than_zero() {
6072        // older_than_days=0 → purges all entries; on an empty DB this is
6073        // a no-op that still hits the success branch.
6074        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6075        let req = make_tools_call("memory_archive_purge", json!({"older_than_days": 0}));
6076        let resp = invoke_handle_request(&conn, &req);
6077        assert!(resp.error.is_none());
6078        let text = resp.result.unwrap()["content"][0]["text"]
6079            .as_str()
6080            .unwrap()
6081            .to_string();
6082        let val: Value = serde_json::from_str(&text).unwrap();
6083        assert!(val["purged"].is_u64() || val["purged"].is_i64());
6084    }
6085
6086    #[test]
6087    fn handle_archive_stats_returns_struct() {
6088        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6089        let req = make_tools_call("memory_archive_stats", json!({}));
6090        let resp = invoke_handle_request(&conn, &req);
6091        assert!(resp.error.is_none());
6092        let text = resp.result.unwrap()["content"][0]["text"]
6093            .as_str()
6094            .unwrap()
6095            .to_string();
6096        let val: Value = serde_json::from_str(&text).unwrap();
6097        // Stats fields vary; just confirm the response is an object/value.
6098        assert!(val.is_object() || val.is_number() || val.is_array());
6099    }
6100
6101    #[test]
6102    fn handle_kg_timeline_unknown_source_returns_empty_events() {
6103        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6104        let req = make_tools_call(
6105            "memory_kg_timeline",
6106            json!({"source_id": "00000000-0000-0000-0000-000000000000"}),
6107        );
6108        let resp = invoke_handle_request(&conn, &req);
6109        assert!(resp.error.is_none());
6110        let text = resp.result.unwrap()["content"][0]["text"]
6111            .as_str()
6112            .unwrap()
6113            .to_string();
6114        let val: Value = serde_json::from_str(&text).unwrap();
6115        assert!(val["events"].is_array());
6116        assert_eq!(val["count"], 0);
6117    }
6118
6119    #[test]
6120    fn handle_kg_timeline_with_since_until_filters() {
6121        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6122        let req = make_tools_call(
6123            "memory_kg_timeline",
6124            json!({
6125                "source_id": "00000000-0000-0000-0000-000000000000",
6126                "since": "2024-01-01T00:00:00Z",
6127                "until": "2025-01-01T00:00:00Z",
6128                "limit": 50,
6129            }),
6130        );
6131        let resp = invoke_handle_request(&conn, &req);
6132        assert!(resp.error.is_none());
6133    }
6134
6135    #[test]
6136    fn handle_kg_timeline_invalid_since_returns_error() {
6137        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6138        let req = make_tools_call(
6139            "memory_kg_timeline",
6140            json!({
6141                "source_id": "00000000-0000-0000-0000-000000000000",
6142                "since": "this-is-not-a-timestamp",
6143            }),
6144        );
6145        let resp = invoke_handle_request(&conn, &req);
6146        let result = resp.result.unwrap();
6147        assert_eq!(result["isError"], true);
6148    }
6149
6150    #[test]
6151    fn handle_kg_invalidate_no_match_returns_found_false() {
6152        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6153        let req = make_tools_call(
6154            "memory_kg_invalidate",
6155            json!({
6156                "source_id": "00000000-0000-0000-0000-000000000000",
6157                "target_id": "11111111-1111-1111-1111-111111111111",
6158                "relation": "related_to",
6159            }),
6160        );
6161        let resp = invoke_handle_request(&conn, &req);
6162        assert!(resp.error.is_none());
6163        let text = resp.result.unwrap()["content"][0]["text"]
6164            .as_str()
6165            .unwrap()
6166            .to_string();
6167        let val: Value = serde_json::from_str(&text).unwrap();
6168        assert_eq!(val["found"], false);
6169    }
6170
6171    #[test]
6172    fn handle_kg_invalidate_with_explicit_valid_until() {
6173        // Seed source + target memories and a link, then invalidate with
6174        // an explicit timestamp — drives the Some(ts) validation branch
6175        // and the Some(res) match arm.
6176        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6177        let src = Memory {
6178            id: uuid::Uuid::new_v4().to_string(),
6179            tier: Tier::Long,
6180            namespace: "w12-kg".into(),
6181            title: "src".into(),
6182            content: "c".into(),
6183            tags: vec![],
6184            priority: 5,
6185            confidence: 1.0,
6186            source: "test".into(),
6187            access_count: 0,
6188            created_at: chrono::Utc::now().to_rfc3339(),
6189            updated_at: chrono::Utc::now().to_rfc3339(),
6190            last_accessed_at: None,
6191            expires_at: None,
6192            metadata: json!({}),
6193        };
6194        let mut tgt = src.clone();
6195        tgt.id = uuid::Uuid::new_v4().to_string();
6196        tgt.title = "tgt".into();
6197        let src_id = db::insert(&conn, &src).unwrap();
6198        let tgt_id = db::insert(&conn, &tgt).unwrap();
6199        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
6200
6201        let req = make_tools_call(
6202            "memory_kg_invalidate",
6203            json!({
6204                "source_id": src_id,
6205                "target_id": tgt_id,
6206                "relation": "related_to",
6207                "valid_until": "2025-01-01T00:00:00Z",
6208            }),
6209        );
6210        let resp = invoke_handle_request(&conn, &req);
6211        assert!(resp.error.is_none());
6212        let text = resp.result.unwrap()["content"][0]["text"]
6213            .as_str()
6214            .unwrap()
6215            .to_string();
6216        let val: Value = serde_json::from_str(&text).unwrap();
6217        assert_eq!(val["found"], true);
6218        assert_eq!(val["valid_until"], "2025-01-01T00:00:00Z");
6219    }
6220
6221    #[test]
6222    fn handle_kg_invalidate_invalid_valid_until_format() {
6223        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6224        let req = make_tools_call(
6225            "memory_kg_invalidate",
6226            json!({
6227                "source_id": "00000000-0000-0000-0000-000000000000",
6228                "target_id": "11111111-1111-1111-1111-111111111111",
6229                "relation": "related_to",
6230                "valid_until": "not-a-date",
6231            }),
6232        );
6233        let resp = invoke_handle_request(&conn, &req);
6234        let result = resp.result.unwrap();
6235        assert_eq!(result["isError"], true);
6236    }
6237
6238    #[test]
6239    fn handle_kg_query_with_max_depth_and_filters() {
6240        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6241        let req = make_tools_call(
6242            "memory_kg_query",
6243            json!({
6244                "source_id": "00000000-0000-0000-0000-000000000000",
6245                "max_depth": 2,
6246                "valid_at": "2025-01-01T00:00:00Z",
6247                "allowed_agents": ["agent-a", "agent-b"],
6248                "limit": 10,
6249            }),
6250        );
6251        let resp = invoke_handle_request(&conn, &req);
6252        assert!(resp.error.is_none());
6253        let text = resp.result.unwrap()["content"][0]["text"]
6254            .as_str()
6255            .unwrap()
6256            .to_string();
6257        let val: Value = serde_json::from_str(&text).unwrap();
6258        assert_eq!(val["max_depth"], 2);
6259        assert!(val["memories"].is_array());
6260    }
6261
6262    #[test]
6263    fn handle_kg_query_invalid_valid_at() {
6264        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6265        let req = make_tools_call(
6266            "memory_kg_query",
6267            json!({
6268                "source_id": "00000000-0000-0000-0000-000000000000",
6269                "valid_at": "garbage",
6270            }),
6271        );
6272        let resp = invoke_handle_request(&conn, &req);
6273        let result = resp.result.unwrap();
6274        assert_eq!(result["isError"], true);
6275    }
6276
6277    #[test]
6278    fn handle_kg_query_rejects_invalid_agent_id() {
6279        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6280        let req = make_tools_call(
6281            "memory_kg_query",
6282            json!({
6283                "source_id": "00000000-0000-0000-0000-000000000000",
6284                "allowed_agents": ["bad agent with spaces!!"],
6285            }),
6286        );
6287        let resp = invoke_handle_request(&conn, &req);
6288        let result = resp.result.unwrap();
6289        assert_eq!(result["isError"], true);
6290    }
6291
6292    #[test]
6293    fn handle_session_start_happy_returns_memories() {
6294        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6295        // Seed a memory so list returns at least one row.
6296        let mem = Memory {
6297            id: uuid::Uuid::new_v4().to_string(),
6298            tier: Tier::Long,
6299            namespace: "w12-session".into(),
6300            title: "seed".into(),
6301            content: "c".into(),
6302            tags: vec![],
6303            priority: 5,
6304            confidence: 1.0,
6305            source: "test".into(),
6306            access_count: 0,
6307            created_at: chrono::Utc::now().to_rfc3339(),
6308            updated_at: chrono::Utc::now().to_rfc3339(),
6309            last_accessed_at: None,
6310            expires_at: None,
6311            metadata: json!({}),
6312        };
6313        db::insert(&conn, &mem).unwrap();
6314        let req = make_tools_call(
6315            "memory_session_start",
6316            json!({"namespace": "w12-session", "limit": 5, "format": "json"}),
6317        );
6318        let resp = invoke_handle_request(&conn, &req);
6319        assert!(resp.error.is_none());
6320        let text = resp.result.unwrap()["content"][0]["text"]
6321            .as_str()
6322            .unwrap()
6323            .to_string();
6324        let val: Value = serde_json::from_str(&text).unwrap();
6325        assert_eq!(val["mode"], "session_start");
6326        assert!(val["memories"].is_array());
6327    }
6328
6329    #[test]
6330    fn handle_session_start_empty_namespace_returns_zero() {
6331        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6332        let req = make_tools_call(
6333            "memory_session_start",
6334            json!({"namespace": "w12-empty-ns", "format": "json"}),
6335        );
6336        let resp = invoke_handle_request(&conn, &req);
6337        assert!(resp.error.is_none());
6338        let text = resp.result.unwrap()["content"][0]["text"]
6339            .as_str()
6340            .unwrap()
6341            .to_string();
6342        let val: Value = serde_json::from_str(&text).unwrap();
6343        assert_eq!(val["count"], 0);
6344    }
6345
6346    #[test]
6347    fn handle_inbox_returns_empty_for_unregistered_caller() {
6348        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6349        let req = make_tools_call("memory_inbox", json!({"agent_id": "test-bot"}));
6350        let resp = invoke_handle_request(&conn, &req);
6351        assert!(resp.error.is_none());
6352        let text = resp.result.unwrap()["content"][0]["text"]
6353            .as_str()
6354            .unwrap()
6355            .to_string();
6356        let val: Value = serde_json::from_str(&text).unwrap();
6357        assert_eq!(val["agent_id"], "test-bot");
6358        assert!(val["namespace"].as_str().unwrap().starts_with("_messages/"));
6359        assert_eq!(val["count"], 0);
6360    }
6361
6362    #[test]
6363    fn handle_inbox_with_unread_only_filter() {
6364        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6365        let req = make_tools_call(
6366            "memory_inbox",
6367            json!({"agent_id": "test-bot", "unread_only": true, "limit": 10}),
6368        );
6369        let resp = invoke_handle_request(&conn, &req);
6370        assert!(resp.error.is_none());
6371        let text = resp.result.unwrap()["content"][0]["text"]
6372            .as_str()
6373            .unwrap()
6374            .to_string();
6375        let val: Value = serde_json::from_str(&text).unwrap();
6376        assert_eq!(val["unread_only"], true);
6377    }
6378
6379    #[test]
6380    fn handle_notify_happy_returns_message_id() {
6381        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6382        let req = make_tools_call(
6383            "memory_notify",
6384            json!({
6385                "target_agent_id": "alice",
6386                "title": "hello",
6387                "payload": "world",
6388                "tier": "mid",
6389                "priority": 5,
6390            }),
6391        );
6392        let resp = invoke_handle_request(&conn, &req);
6393        assert!(resp.error.is_none());
6394        let text = resp.result.unwrap()["content"][0]["text"]
6395            .as_str()
6396            .unwrap()
6397            .to_string();
6398        let val: Value = serde_json::from_str(&text).unwrap();
6399        assert!(val["id"].is_string());
6400        assert_eq!(val["to"], "alice");
6401        assert_eq!(val["namespace"], "_messages/alice");
6402    }
6403
6404    #[test]
6405    fn handle_notify_invalid_tier_returns_error() {
6406        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6407        let req = make_tools_call(
6408            "memory_notify",
6409            json!({
6410                "target_agent_id": "bob",
6411                "title": "hi",
6412                "payload": "p",
6413                "tier": "bogus-tier",
6414            }),
6415        );
6416        let resp = invoke_handle_request(&conn, &req);
6417        let result = resp.result.unwrap();
6418        assert_eq!(result["isError"], true);
6419        let msg = result["content"][0]["text"].as_str().unwrap();
6420        assert!(msg.contains("invalid tier"));
6421    }
6422
6423    #[test]
6424    fn handle_agent_register_then_list() {
6425        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6426        // Register — `agent_type` must match the closed set or `ai:<name>`.
6427        let req = make_tools_call(
6428            "memory_agent_register",
6429            json!({
6430                "agent_id": "w12-bot",
6431                "agent_type": "ai:w12-bot",
6432                "capabilities": ["read", "write"],
6433            }),
6434        );
6435        let resp = invoke_handle_request(&conn, &req);
6436        assert!(resp.error.is_none());
6437        let text = resp.result.unwrap()["content"][0]["text"]
6438            .as_str()
6439            .unwrap()
6440            .to_string();
6441        let val: Value = serde_json::from_str(&text).unwrap();
6442        assert_eq!(val["registered"], true);
6443        // List
6444        let req2 = make_tools_call("memory_agent_list", json!({}));
6445        let resp2 = invoke_handle_request(&conn, &req2);
6446        assert!(resp2.error.is_none());
6447        let text2 = resp2.result.unwrap()["content"][0]["text"]
6448            .as_str()
6449            .unwrap()
6450            .to_string();
6451        let val2: Value = serde_json::from_str(&text2).unwrap();
6452        assert!(val2["count"].as_u64().unwrap() >= 1);
6453    }
6454
6455    #[test]
6456    fn handle_agent_register_invalid_type_rejects() {
6457        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6458        let req = make_tools_call(
6459            "memory_agent_register",
6460            json!({"agent_id": "w12-bot2", "agent_type": "  not-allowed-type with spaces  "}),
6461        );
6462        let resp = invoke_handle_request(&conn, &req);
6463        let result = resp.result.unwrap();
6464        assert_eq!(result["isError"], true);
6465    }
6466
6467    #[test]
6468    fn handle_namespace_set_get_clear_round_trip() {
6469        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6470        // Seed a memory we can use as the standard
6471        let mem = Memory {
6472            id: uuid::Uuid::new_v4().to_string(),
6473            tier: Tier::Long,
6474            namespace: "w12-ns".into(),
6475            title: "policy".into(),
6476            content: "be excellent".into(),
6477            tags: vec![],
6478            priority: 5,
6479            confidence: 1.0,
6480            source: "test".into(),
6481            access_count: 0,
6482            created_at: chrono::Utc::now().to_rfc3339(),
6483            updated_at: chrono::Utc::now().to_rfc3339(),
6484            last_accessed_at: None,
6485            expires_at: None,
6486            metadata: json!({}),
6487        };
6488        let std_id = db::insert(&conn, &mem).unwrap();
6489
6490        // Set
6491        let set_req = make_tools_call(
6492            "memory_namespace_set_standard",
6493            json!({"namespace": "w12-ns", "id": std_id.clone()}),
6494        );
6495        let set_resp = invoke_handle_request(&conn, &set_req);
6496        assert!(set_resp.error.is_none());
6497        let set_text = set_resp.result.unwrap()["content"][0]["text"]
6498            .as_str()
6499            .unwrap()
6500            .to_string();
6501        let set_val: Value = serde_json::from_str(&set_text).unwrap();
6502        assert_eq!(set_val["set"], true);
6503
6504        // Get
6505        let get_req = make_tools_call(
6506            "memory_namespace_get_standard",
6507            json!({"namespace": "w12-ns"}),
6508        );
6509        let get_resp = invoke_handle_request(&conn, &get_req);
6510        assert!(get_resp.error.is_none());
6511        let get_text = get_resp.result.unwrap()["content"][0]["text"]
6512            .as_str()
6513            .unwrap()
6514            .to_string();
6515        let get_val: Value = serde_json::from_str(&get_text).unwrap();
6516        assert_eq!(get_val["standard_id"], std_id);
6517
6518        // Clear
6519        let clr_req = make_tools_call(
6520            "memory_namespace_clear_standard",
6521            json!({"namespace": "w12-ns"}),
6522        );
6523        let clr_resp = invoke_handle_request(&conn, &clr_req);
6524        assert!(clr_resp.error.is_none());
6525        let clr_text = clr_resp.result.unwrap()["content"][0]["text"]
6526            .as_str()
6527            .unwrap()
6528            .to_string();
6529        let clr_val: Value = serde_json::from_str(&clr_text).unwrap();
6530        assert_eq!(clr_val["cleared"], true);
6531    }
6532
6533    #[test]
6534    fn handle_namespace_get_standard_missing_returns_null() {
6535        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6536        let req = make_tools_call(
6537            "memory_namespace_get_standard",
6538            json!({"namespace": "w12-no-standard-here"}),
6539        );
6540        let resp = invoke_handle_request(&conn, &req);
6541        assert!(resp.error.is_none());
6542        let text = resp.result.unwrap()["content"][0]["text"]
6543            .as_str()
6544            .unwrap()
6545            .to_string();
6546        let val: Value = serde_json::from_str(&text).unwrap();
6547        assert!(val["standard_id"].is_null());
6548    }
6549
6550    #[test]
6551    fn handle_namespace_get_standard_inherit_returns_chain() {
6552        // Seed two standards: one global "*" and one for "w12-inh", and
6553        // request --inherit so the resolved chain branch fires.
6554        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6555        seed_namespace_standard(&conn, "*", "global rule");
6556        seed_namespace_standard(&conn, "w12-inh", "specific rule");
6557        let req = make_tools_call(
6558            "memory_namespace_get_standard",
6559            json!({"namespace": "w12-inh", "inherit": true}),
6560        );
6561        let resp = invoke_handle_request(&conn, &req);
6562        assert!(resp.error.is_none());
6563        let text = resp.result.unwrap()["content"][0]["text"]
6564            .as_str()
6565            .unwrap()
6566            .to_string();
6567        let val: Value = serde_json::from_str(&text).unwrap();
6568        assert!(val["chain"].is_array());
6569        assert!(val["standards"].is_array());
6570        assert!(val["count"].as_u64().unwrap() >= 1);
6571    }
6572
6573    #[test]
6574    fn handle_namespace_set_standard_with_invalid_governance_rejected() {
6575        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6576        let mem = Memory {
6577            id: uuid::Uuid::new_v4().to_string(),
6578            tier: Tier::Long,
6579            namespace: "w12-gov".into(),
6580            title: "p".into(),
6581            content: "c".into(),
6582            tags: vec![],
6583            priority: 5,
6584            confidence: 1.0,
6585            source: "test".into(),
6586            access_count: 0,
6587            created_at: chrono::Utc::now().to_rfc3339(),
6588            updated_at: chrono::Utc::now().to_rfc3339(),
6589            last_accessed_at: None,
6590            expires_at: None,
6591            metadata: json!({}),
6592        };
6593        let id = db::insert(&conn, &mem).unwrap();
6594        let req = make_tools_call(
6595            "memory_namespace_set_standard",
6596            json!({
6597                "namespace": "w12-gov",
6598                "id": id,
6599                "governance": {"this": "is not a valid policy"},
6600            }),
6601        );
6602        let resp = invoke_handle_request(&conn, &req);
6603        let result = resp.result.unwrap();
6604        assert_eq!(result["isError"], true);
6605        let msg = result["content"][0]["text"].as_str().unwrap();
6606        assert!(msg.contains("invalid governance") || msg.contains("governance"));
6607    }
6608
6609    #[test]
6610    fn handle_namespace_set_standard_invalid_namespace_rejected() {
6611        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6612        let req = make_tools_call(
6613            "memory_namespace_set_standard",
6614            json!({"namespace": "bad ns with spaces!!", "id": "any"}),
6615        );
6616        let resp = invoke_handle_request(&conn, &req);
6617        let result = resp.result.unwrap();
6618        assert_eq!(result["isError"], true);
6619    }
6620
6621    #[test]
6622    fn handle_pending_list_happy_returns_array() {
6623        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6624        let req = make_tools_call(
6625            "memory_pending_list",
6626            json!({"status": "pending", "limit": 100}),
6627        );
6628        let resp = invoke_handle_request(&conn, &req);
6629        assert!(resp.error.is_none());
6630        let text = resp.result.unwrap()["content"][0]["text"]
6631            .as_str()
6632            .unwrap()
6633            .to_string();
6634        let val: Value = serde_json::from_str(&text).unwrap();
6635        assert!(val["pending"].is_array());
6636        assert!(val["count"].is_u64());
6637    }
6638
6639    #[test]
6640    fn handle_pending_approve_unknown_id_returns_error() {
6641        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6642        let req = make_tools_call(
6643            "memory_pending_approve",
6644            json!({"id": "00000000-0000-0000-0000-000000000000", "agent_id": "human:approver"}),
6645        );
6646        let resp = invoke_handle_request(&conn, &req);
6647        let result = resp.result.unwrap();
6648        // Either isError true or a not-found / rejected response — both
6649        // exercise the unknown-id code path in approve_with_approver_type.
6650        assert!(result.is_object());
6651    }
6652
6653    #[test]
6654    fn handle_pending_reject_unknown_id_returns_not_found() {
6655        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6656        let req = make_tools_call(
6657            "memory_pending_reject",
6658            json!({"id": "00000000-0000-0000-0000-000000000000", "agent_id": "human:rejector"}),
6659        );
6660        let resp = invoke_handle_request(&conn, &req);
6661        let result = resp.result.unwrap();
6662        assert_eq!(result["isError"], true);
6663        let msg = result["content"][0]["text"].as_str().unwrap();
6664        assert!(msg.contains("not found") || msg.contains("already decided"));
6665    }
6666
6667    #[test]
6668    fn handle_gc_dry_run_returns_count_without_deleting() {
6669        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6670        let req = make_tools_call("memory_gc", json!({"dry_run": true}));
6671        let resp = invoke_handle_request(&conn, &req);
6672        assert!(resp.error.is_none());
6673        let text = resp.result.unwrap()["content"][0]["text"]
6674            .as_str()
6675            .unwrap()
6676            .to_string();
6677        let val: Value = serde_json::from_str(&text).unwrap();
6678        assert_eq!(val["dry_run"], true);
6679        assert!(val["collected"].is_u64() || val["collected"].is_i64());
6680    }
6681
6682    #[test]
6683    fn handle_gc_actual_run_returns_zero_on_empty_db() {
6684        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6685        let req = make_tools_call("memory_gc", json!({}));
6686        let resp = invoke_handle_request(&conn, &req);
6687        assert!(resp.error.is_none());
6688        let text = resp.result.unwrap()["content"][0]["text"]
6689            .as_str()
6690            .unwrap()
6691            .to_string();
6692        let val: Value = serde_json::from_str(&text).unwrap();
6693        assert_eq!(val["dry_run"], false);
6694    }
6695
6696    #[test]
6697    fn handle_forget_dry_run_with_filters() {
6698        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6699        let req = make_tools_call(
6700            "memory_forget",
6701            json!({"namespace": "w12-forget", "tier": "short", "dry_run": true}),
6702        );
6703        let resp = invoke_handle_request(&conn, &req);
6704        assert!(resp.error.is_none());
6705        let text = resp.result.unwrap()["content"][0]["text"]
6706            .as_str()
6707            .unwrap()
6708            .to_string();
6709        let val: Value = serde_json::from_str(&text).unwrap();
6710        assert_eq!(val["dry_run"], true);
6711    }
6712
6713    #[test]
6714    fn handle_forget_actual_with_namespace() {
6715        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6716        let req = make_tools_call(
6717            "memory_forget",
6718            json!({"namespace": "w12-forget-actual", "dry_run": false}),
6719        );
6720        let resp = invoke_handle_request(&conn, &req);
6721        assert!(resp.error.is_none());
6722    }
6723
6724    #[test]
6725    fn handle_unsubscribe_unknown_returns_false() {
6726        // db::subscriptions::delete returns a bool — false when no row
6727        // matched. The handler propagates that verbatim.
6728        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6729        let req = make_tools_call(
6730            "memory_unsubscribe",
6731            json!({"id": "00000000-0000-0000-0000-000000000000"}),
6732        );
6733        let resp = invoke_handle_request(&conn, &req);
6734        assert!(resp.error.is_none());
6735        let text = resp.result.unwrap()["content"][0]["text"]
6736            .as_str()
6737            .unwrap()
6738            .to_string();
6739        let val: Value = serde_json::from_str(&text).unwrap();
6740        // Either a bool false or numeric 0 — the contract is "no row removed".
6741        assert!(
6742            val["removed"] == json!(false) || val["removed"] == json!(0),
6743            "unexpected removed value: {:?}",
6744            val["removed"]
6745        );
6746    }
6747
6748    #[test]
6749    fn handle_list_subscriptions_returns_array() {
6750        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6751        let req = make_tools_call("memory_list_subscriptions", json!({}));
6752        let resp = invoke_handle_request(&conn, &req);
6753        assert!(resp.error.is_none());
6754    }
6755
6756    #[test]
6757    fn handle_entity_register_happy() {
6758        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6759        let req = make_tools_call(
6760            "memory_entity_register",
6761            json!({
6762                "canonical_name": "Hugo Boss",
6763                "namespace": "w12-people",
6764                "aliases": ["HB", "Hugo"],
6765            }),
6766        );
6767        let resp = invoke_handle_request(&conn, &req);
6768        assert!(resp.error.is_none());
6769        let text = resp.result.unwrap()["content"][0]["text"]
6770            .as_str()
6771            .unwrap()
6772            .to_string();
6773        let val: Value = serde_json::from_str(&text).unwrap();
6774        assert!(val["entity_id"].is_string());
6775        assert_eq!(val["canonical_name"], "Hugo Boss");
6776    }
6777
6778    #[test]
6779    fn handle_entity_register_invalid_namespace() {
6780        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6781        let req = make_tools_call(
6782            "memory_entity_register",
6783            json!({"canonical_name": "X", "namespace": "INVALID NS!"}),
6784        );
6785        let resp = invoke_handle_request(&conn, &req);
6786        let result = resp.result.unwrap();
6787        assert_eq!(result["isError"], true);
6788    }
6789
6790    #[test]
6791    fn handle_entity_get_by_alias_not_found_returns_null() {
6792        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6793        let req = make_tools_call(
6794            "memory_entity_get_by_alias",
6795            json!({"alias": "no-such-alias", "namespace": "w12-people"}),
6796        );
6797        let resp = invoke_handle_request(&conn, &req);
6798        assert!(resp.error.is_none());
6799        let text = resp.result.unwrap()["content"][0]["text"]
6800            .as_str()
6801            .unwrap()
6802            .to_string();
6803        let val: Value = serde_json::from_str(&text).unwrap();
6804        assert_eq!(val["found"], false);
6805    }
6806
6807    #[test]
6808    fn handle_get_taxonomy_with_prefix_and_depth() {
6809        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6810        let req = make_tools_call(
6811            "memory_get_taxonomy",
6812            json!({"namespace_prefix": "w12-tax", "depth": 4, "limit": 100}),
6813        );
6814        let resp = invoke_handle_request(&conn, &req);
6815        assert!(resp.error.is_none());
6816        let text = resp.result.unwrap()["content"][0]["text"]
6817            .as_str()
6818            .unwrap()
6819            .to_string();
6820        let val: Value = serde_json::from_str(&text).unwrap();
6821        assert!(val["tree"].is_object() || val["tree"].is_array());
6822    }
6823
6824    #[test]
6825    fn handle_get_taxonomy_strips_trailing_slash() {
6826        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6827        let req = make_tools_call(
6828            "memory_get_taxonomy",
6829            json!({"namespace_prefix": "w12-tax/", "depth": 2}),
6830        );
6831        let resp = invoke_handle_request(&conn, &req);
6832        // Trailing-slash forgiveness branch: must not error.
6833        assert!(resp.error.is_none());
6834    }
6835
6836    #[test]
6837    fn handle_get_taxonomy_invalid_prefix_after_strip() {
6838        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6839        let req = make_tools_call(
6840            "memory_get_taxonomy",
6841            json!({"namespace_prefix": "BAD NS!"}),
6842        );
6843        let resp = invoke_handle_request(&conn, &req);
6844        let result = resp.result.unwrap();
6845        assert_eq!(result["isError"], true);
6846    }
6847
6848    #[test]
6849    fn handle_check_duplicate_no_embedder_errors() {
6850        // Without embedder, check_duplicate must error (it requires
6851        // semantic tier or above).
6852        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6853        let req = make_tools_call(
6854            "memory_check_duplicate",
6855            json!({"title": "T", "content": "C"}),
6856        );
6857        let resp = invoke_handle_request(&conn, &req);
6858        let result = resp.result.unwrap();
6859        assert_eq!(result["isError"], true);
6860        let msg = result["content"][0]["text"].as_str().unwrap();
6861        assert!(msg.contains("embedder") || msg.contains("semantic"));
6862    }
6863
6864    #[test]
6865    fn handle_expand_query_no_llm_errors() {
6866        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6867        let req = make_tools_call("memory_expand_query", json!({"query": "test"}));
6868        let resp = invoke_handle_request(&conn, &req);
6869        let result = resp.result.unwrap();
6870        assert_eq!(result["isError"], true);
6871        let msg = result["content"][0]["text"].as_str().unwrap();
6872        assert!(msg.contains("smart") || msg.contains("LLM") || msg.contains("Ollama"));
6873    }
6874
6875    #[test]
6876    fn handle_auto_tag_no_llm_errors() {
6877        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6878        let req = make_tools_call(
6879            "memory_auto_tag",
6880            json!({"id": "00000000-0000-0000-0000-000000000000"}),
6881        );
6882        let resp = invoke_handle_request(&conn, &req);
6883        let result = resp.result.unwrap();
6884        assert_eq!(result["isError"], true);
6885    }
6886
6887    #[test]
6888    fn handle_detect_contradiction_no_llm_errors() {
6889        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6890        let req = make_tools_call(
6891            "memory_detect_contradiction",
6892            json!({"id_a": "00000000-0000-0000-0000-000000000000", "id_b": "11111111-1111-1111-1111-111111111111"}),
6893        );
6894        let resp = invoke_handle_request(&conn, &req);
6895        let result = resp.result.unwrap();
6896        assert_eq!(result["isError"], true);
6897    }
6898
6899    #[test]
6900    fn handle_update_unknown_id_returns_not_found() {
6901        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6902        let req = make_tools_call(
6903            "memory_update",
6904            json!({
6905                "id": "00000000-0000-0000-0000-000000000000",
6906                "title": "new title",
6907            }),
6908        );
6909        let resp = invoke_handle_request(&conn, &req);
6910        let result = resp.result.unwrap();
6911        assert_eq!(result["isError"], true);
6912        let msg = result["content"][0]["text"].as_str().unwrap();
6913        assert!(msg.contains("not found"));
6914    }
6915
6916    #[test]
6917    fn handle_update_invalid_priority_rejected() {
6918        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6919        // First insert a memory we can target.
6920        let mem = Memory {
6921            id: uuid::Uuid::new_v4().to_string(),
6922            tier: Tier::Long,
6923            namespace: "w12-update".into(),
6924            title: "t".into(),
6925            content: "c".into(),
6926            tags: vec![],
6927            priority: 5,
6928            confidence: 1.0,
6929            source: "test".into(),
6930            access_count: 0,
6931            created_at: chrono::Utc::now().to_rfc3339(),
6932            updated_at: chrono::Utc::now().to_rfc3339(),
6933            last_accessed_at: None,
6934            expires_at: None,
6935            metadata: json!({}),
6936        };
6937        let id = db::insert(&conn, &mem).unwrap();
6938        let req = make_tools_call(
6939            "memory_update",
6940            json!({"id": id, "priority": 99_i64}), // out of 1..=10 range
6941        );
6942        let resp = invoke_handle_request(&conn, &req);
6943        let result = resp.result.unwrap();
6944        assert_eq!(result["isError"], true);
6945    }
6946
6947    #[test]
6948    fn handle_update_with_metadata_object_accepted() {
6949        // Drives the metadata-is-object branch which validates and merges
6950        // the agent_id-preserving payload.
6951        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6952        let mem = Memory {
6953            id: uuid::Uuid::new_v4().to_string(),
6954            tier: Tier::Mid,
6955            namespace: "w12-meta".into(),
6956            title: "t".into(),
6957            content: "c".into(),
6958            tags: vec![],
6959            priority: 5,
6960            confidence: 1.0,
6961            source: "test".into(),
6962            access_count: 0,
6963            created_at: chrono::Utc::now().to_rfc3339(),
6964            updated_at: chrono::Utc::now().to_rfc3339(),
6965            last_accessed_at: None,
6966            expires_at: None,
6967            metadata: json!({}),
6968        };
6969        let id = db::insert(&conn, &mem).unwrap();
6970        let req = make_tools_call(
6971            "memory_update",
6972            json!({
6973                "id": id,
6974                "metadata": {"custom": "field", "numbers": [1, 2, 3]},
6975            }),
6976        );
6977        let resp = invoke_handle_request(&conn, &req);
6978        assert!(resp.error.is_none());
6979    }
6980
6981    #[test]
6982    fn handle_get_links_unknown_id_returns_empty() {
6983        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6984        let req = make_tools_call(
6985            "memory_get_links",
6986            json!({"id": "00000000-0000-0000-0000-000000000000"}),
6987        );
6988        let resp = invoke_handle_request(&conn, &req);
6989        assert!(resp.error.is_none());
6990        let text = resp.result.unwrap()["content"][0]["text"]
6991            .as_str()
6992            .unwrap()
6993            .to_string();
6994        let val: Value = serde_json::from_str(&text).unwrap();
6995        assert!(val["links"].is_array());
6996        assert_eq!(val["count"], 0);
6997    }
6998
6999    #[test]
7000    fn handle_link_invalid_relation_rejected() {
7001        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7002        let req = make_tools_call(
7003            "memory_link",
7004            json!({
7005                "source_id": "00000000-0000-0000-0000-000000000000",
7006                "target_id": "11111111-1111-1111-1111-111111111111",
7007                "relation": "BADRELATIONNOTALLOWED",
7008            }),
7009        );
7010        let resp = invoke_handle_request(&conn, &req);
7011        let result = resp.result.unwrap();
7012        assert_eq!(result["isError"], true);
7013    }
7014
7015    #[test]
7016    fn handle_promote_to_namespace_with_explicit_target() {
7017        // Vertical-promote branch: when `to_namespace` is provided, the
7018        // memory is cloned to an ancestor namespace and linked with
7019        // `derived_from`. db::promote_to_namespace requires the target
7020        // to be an ancestor of the source's namespace, so use a
7021        // hierarchical namespace.
7022        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7023        let mem = Memory {
7024            id: uuid::Uuid::new_v4().to_string(),
7025            tier: Tier::Mid,
7026            namespace: "w12-parent/w12-child".into(),
7027            title: "t".into(),
7028            content: "c".into(),
7029            tags: vec![],
7030            priority: 5,
7031            confidence: 1.0,
7032            source: "test".into(),
7033            access_count: 0,
7034            created_at: chrono::Utc::now().to_rfc3339(),
7035            updated_at: chrono::Utc::now().to_rfc3339(),
7036            last_accessed_at: None,
7037            expires_at: None,
7038            metadata: json!({}),
7039        };
7040        let id = db::insert(&conn, &mem).unwrap();
7041        let req = make_tools_call(
7042            "memory_promote",
7043            json!({"id": id, "to_namespace": "w12-parent"}),
7044        );
7045        let resp = invoke_handle_request(&conn, &req);
7046        assert!(resp.error.is_none());
7047        let text = resp.result.unwrap()["content"][0]["text"]
7048            .as_str()
7049            .unwrap()
7050            .to_string();
7051        let val: Value = serde_json::from_str(&text).unwrap();
7052        assert_eq!(val["mode"], "vertical");
7053        assert!(val["clone_id"].is_string());
7054    }
7055
7056    #[test]
7057    fn handle_promote_invalid_to_namespace_rejected() {
7058        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7059        let mem = Memory {
7060            id: uuid::Uuid::new_v4().to_string(),
7061            tier: Tier::Mid,
7062            namespace: "w12-pm".into(),
7063            title: "t".into(),
7064            content: "c".into(),
7065            tags: vec![],
7066            priority: 5,
7067            confidence: 1.0,
7068            source: "test".into(),
7069            access_count: 0,
7070            created_at: chrono::Utc::now().to_rfc3339(),
7071            updated_at: chrono::Utc::now().to_rfc3339(),
7072            last_accessed_at: None,
7073            expires_at: None,
7074            metadata: json!({}),
7075        };
7076        let id = db::insert(&conn, &mem).unwrap();
7077        let req = make_tools_call(
7078            "memory_promote",
7079            json!({"id": id, "to_namespace": "BAD NS WITH SPACES"}),
7080        );
7081        let resp = invoke_handle_request(&conn, &req);
7082        let result = resp.result.unwrap();
7083        assert_eq!(result["isError"], true);
7084    }
7085
7086    #[test]
7087    fn handle_consolidate_with_explicit_summary_no_llm() {
7088        // Drives the "explicit summary" branch (no LLM call needed).
7089        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7090        let mem_a = Memory {
7091            id: uuid::Uuid::new_v4().to_string(),
7092            tier: Tier::Mid,
7093            namespace: "w12-cons".into(),
7094            title: "a".into(),
7095            content: "alpha".into(),
7096            tags: vec![],
7097            priority: 5,
7098            confidence: 1.0,
7099            source: "test".into(),
7100            access_count: 0,
7101            created_at: chrono::Utc::now().to_rfc3339(),
7102            updated_at: chrono::Utc::now().to_rfc3339(),
7103            last_accessed_at: None,
7104            expires_at: None,
7105            metadata: json!({}),
7106        };
7107        let mut mem_b = mem_a.clone();
7108        mem_b.id = uuid::Uuid::new_v4().to_string();
7109        mem_b.title = "b".into();
7110        mem_b.content = "beta".into();
7111        let id_a = db::insert(&conn, &mem_a).unwrap();
7112        let id_b = db::insert(&conn, &mem_b).unwrap();
7113
7114        let req = make_tools_call(
7115            "memory_consolidate",
7116            json!({
7117                "ids": [id_a, id_b],
7118                "title": "merged",
7119                "summary": "merged summary",
7120                "namespace": "w12-cons",
7121            }),
7122        );
7123        let resp = invoke_handle_request(&conn, &req);
7124        assert!(resp.error.is_none());
7125        let text = resp.result.unwrap()["content"][0]["text"]
7126            .as_str()
7127            .unwrap()
7128            .to_string();
7129        let val: Value = serde_json::from_str(&text).unwrap();
7130        assert!(val["id"].is_string());
7131        assert_eq!(val["consolidated"], 2);
7132    }
7133
7134    #[test]
7135    fn handle_consolidate_non_string_id_rejected() {
7136        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7137        let req = make_tools_call(
7138            "memory_consolidate",
7139            json!({"ids": [42, "valid-id"], "title": "t", "summary": "s"}),
7140        );
7141        let resp = invoke_handle_request(&conn, &req);
7142        let result = resp.result.unwrap();
7143        assert_eq!(result["isError"], true);
7144        let msg = result["content"][0]["text"].as_str().unwrap();
7145        assert!(msg.contains("must be a string"));
7146    }
7147
7148    // ------------------------------------------------------------------
7149    // JSON-RPC framing — additional edge cases beyond M9's six.
7150    // ------------------------------------------------------------------
7151
7152    #[test]
7153    fn test_jsonrpc_handles_ping() {
7154        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7155        let req = RpcRequest {
7156            jsonrpc: "2.0".into(),
7157            id: Some(json!(1)),
7158            method: "ping".into(),
7159            params: json!({}),
7160        };
7161        let resp = invoke_handle_request(&conn, &req);
7162        assert!(resp.error.is_none());
7163    }
7164
7165    #[test]
7166    fn test_jsonrpc_handles_notifications_initialized() {
7167        // The client→server "I'm ready" notification — handler returns
7168        // the same empty body as ping.
7169        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7170        let req = RpcRequest {
7171            jsonrpc: "2.0".into(),
7172            id: Some(json!(2)),
7173            method: "notifications/initialized".into(),
7174            params: json!({}),
7175        };
7176        let resp = invoke_handle_request(&conn, &req);
7177        assert!(resp.error.is_none());
7178    }
7179
7180    #[test]
7181    fn test_jsonrpc_prompts_list_returns_array() {
7182        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7183        let req = RpcRequest {
7184            jsonrpc: "2.0".into(),
7185            id: Some(json!(3)),
7186            method: "prompts/list".into(),
7187            params: json!({}),
7188        };
7189        let resp = invoke_handle_request(&conn, &req);
7190        assert!(resp.error.is_none());
7191        let result = resp.result.unwrap();
7192        assert!(result["prompts"].is_array());
7193    }
7194
7195    #[test]
7196    fn test_jsonrpc_prompts_get_known_name_returns_messages() {
7197        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7198        let req = RpcRequest {
7199            jsonrpc: "2.0".into(),
7200            id: Some(json!(4)),
7201            method: "prompts/get".into(),
7202            params: json!({"name": "recall-first"}),
7203        };
7204        let resp = invoke_handle_request(&conn, &req);
7205        assert!(resp.error.is_none());
7206        let result = resp.result.unwrap();
7207        assert!(result["messages"].is_array());
7208    }
7209
7210    #[test]
7211    fn test_jsonrpc_prompts_get_with_namespace_arg_includes_hint() {
7212        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7213        let req = RpcRequest {
7214            jsonrpc: "2.0".into(),
7215            id: Some(json!(5)),
7216            method: "prompts/get".into(),
7217            params: json!({"name": "recall-first", "arguments": {"namespace": "w12-test"}}),
7218        };
7219        let resp = invoke_handle_request(&conn, &req);
7220        assert!(resp.error.is_none());
7221        let result = resp.result.unwrap();
7222        let text = result["messages"][0]["content"]["text"].as_str().unwrap();
7223        assert!(text.contains("w12-test"));
7224    }
7225
7226    #[test]
7227    fn test_jsonrpc_prompts_get_unknown_name_returns_error() {
7228        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7229        let req = RpcRequest {
7230            jsonrpc: "2.0".into(),
7231            id: Some(json!(6)),
7232            method: "prompts/get".into(),
7233            params: json!({"name": "no-such-prompt"}),
7234        };
7235        let resp = invoke_handle_request(&conn, &req);
7236        let err = resp.error.unwrap();
7237        assert_eq!(err.code, -32602);
7238    }
7239
7240    #[test]
7241    fn test_jsonrpc_prompts_get_missing_name_returns_error() {
7242        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7243        let req = RpcRequest {
7244            jsonrpc: "2.0".into(),
7245            id: Some(json!(7)),
7246            method: "prompts/get".into(),
7247            params: json!({}),
7248        };
7249        let resp = invoke_handle_request(&conn, &req);
7250        let err = resp.error.unwrap();
7251        assert_eq!(err.code, -32602);
7252    }
7253
7254    #[test]
7255    fn test_jsonrpc_prompts_get_memory_workflow() {
7256        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7257        let req = RpcRequest {
7258            jsonrpc: "2.0".into(),
7259            id: Some(json!(8)),
7260            method: "prompts/get".into(),
7261            params: json!({"name": "memory-workflow"}),
7262        };
7263        let resp = invoke_handle_request(&conn, &req);
7264        assert!(resp.error.is_none());
7265        let result = resp.result.unwrap();
7266        assert!(result["messages"].is_array());
7267    }
7268
7269    #[test]
7270    fn test_jsonrpc_tools_call_empty_tool_name_rejected() {
7271        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7272        let req = RpcRequest {
7273            jsonrpc: "2.0".into(),
7274            id: Some(json!(9)),
7275            method: "tools/call".into(),
7276            params: json!({"name": ""}),
7277        };
7278        let resp = invoke_handle_request(&conn, &req);
7279        let err = resp.error.unwrap();
7280        assert_eq!(err.code, -32602);
7281    }
7282
7283    #[test]
7284    fn test_jsonrpc_tools_call_arguments_not_object_uses_empty() {
7285        // arguments=null is replaced with an empty object before dispatch.
7286        // Combined with a tool that has no required args, this path
7287        // exercises the `is_object()` false branch of the arguments
7288        // resolution.
7289        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7290        let req = RpcRequest {
7291            jsonrpc: "2.0".into(),
7292            id: Some(json!(10)),
7293            method: "tools/call".into(),
7294            params: json!({"name": "memory_capabilities", "arguments": null}),
7295        };
7296        let resp = invoke_handle_request(&conn, &req);
7297        // Capabilities accepts no args; with empty defaults it succeeds.
7298        assert!(resp.error.is_none());
7299    }
7300
7301    #[test]
7302    fn test_jsonrpc_tools_call_unicode_in_args() {
7303        // Unicode strings round-trip through serde_json without issue —
7304        // verifies the dispatch path doesn't choke on non-ASCII args.
7305        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7306        let req = make_tools_call(
7307            "memory_store",
7308            json!({"title": "тест", "content": "日本語 ✨", "namespace": "w12-unicode"}),
7309        );
7310        let resp = invoke_handle_request(&conn, &req);
7311        assert!(resp.error.is_none());
7312    }
7313
7314    #[test]
7315    fn test_jsonrpc_dispatch_line_with_id_zero_treated_as_request() {
7316        // id=0 is a valid JSON-RPC id (numeric, non-null). Must NOT be
7317        // treated as a notification.
7318        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7319        let line = r#"{"jsonrpc":"2.0","id":0,"method":"tools/list"}"#;
7320        let resp = dispatch_line(&conn, line);
7321        assert!(resp.is_some());
7322    }
7323
7324    #[test]
7325    fn test_jsonrpc_dispatch_line_string_id_passes_through() {
7326        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7327        let line = r#"{"jsonrpc":"2.0","id":"call-abc","method":"tools/list"}"#;
7328        let resp = dispatch_line(&conn, line).expect("expected response");
7329        assert_eq!(resp.id, json!("call-abc"));
7330    }
7331
7332    // ------------------------------------------------------------------
7333    // Helper-fn coverage — build_namespace_chain branches.
7334    // ------------------------------------------------------------------
7335
7336    #[test]
7337    fn test_build_namespace_chain_global_only() {
7338        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7339        let chain = super::build_namespace_chain(&conn, "*");
7340        assert_eq!(chain, vec!["*".to_string()]);
7341    }
7342
7343    #[test]
7344    fn test_build_namespace_chain_simple_namespace() {
7345        // A flat namespace produces ["*", "ns"].
7346        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7347        let chain = super::build_namespace_chain(&conn, "w12-flat");
7348        assert!(chain.contains(&"*".to_string()));
7349        assert!(chain.contains(&"w12-flat".to_string()));
7350    }
7351
7352    #[test]
7353    fn test_build_namespace_chain_nested_yields_ancestors() {
7354        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7355        let chain = super::build_namespace_chain(&conn, "a/b/c");
7356        // Must contain "*" and the full chain top-down.
7357        assert_eq!(chain.first().unwrap(), "*");
7358        assert!(chain.contains(&"a/b/c".to_string()));
7359        // Top-down order: a precedes a/b precedes a/b/c.
7360        let pos_a = chain.iter().position(|s| s == "a").unwrap();
7361        let pos_ab = chain.iter().position(|s| s == "a/b").unwrap();
7362        let pos_abc = chain.iter().position(|s| s == "a/b/c").unwrap();
7363        assert!(pos_a < pos_ab && pos_ab < pos_abc);
7364    }
7365
7366    #[test]
7367    fn test_build_namespace_chain_with_explicit_parent() {
7368        // Seeding an explicit `parent_namespace` row should prepend that
7369        // ancestor before the /-derived chain.
7370        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7371        // Insert a row in namespace_meta so the explicit-parent walk
7372        // has something to traverse. Use db helpers when possible.
7373        let parent_mem = Memory {
7374            id: uuid::Uuid::new_v4().to_string(),
7375            tier: Tier::Long,
7376            namespace: "w12-explicit-grand".into(),
7377            title: "g".into(),
7378            content: "c".into(),
7379            tags: vec![],
7380            priority: 5,
7381            confidence: 1.0,
7382            source: "test".into(),
7383            access_count: 0,
7384            created_at: chrono::Utc::now().to_rfc3339(),
7385            updated_at: chrono::Utc::now().to_rfc3339(),
7386            last_accessed_at: None,
7387            expires_at: None,
7388            metadata: json!({}),
7389        };
7390        let pid = db::insert(&conn, &parent_mem).unwrap();
7391        db::set_namespace_standard(&conn, "w12-explicit-grand", &pid, None).unwrap();
7392
7393        let mut child_mem = parent_mem.clone();
7394        child_mem.id = uuid::Uuid::new_v4().to_string();
7395        child_mem.namespace = "w12-explicit-leaf".into();
7396        let cid = db::insert(&conn, &child_mem).unwrap();
7397        db::set_namespace_standard(&conn, "w12-explicit-leaf", &cid, Some("w12-explicit-grand"))
7398            .unwrap();
7399
7400        let chain = super::build_namespace_chain(&conn, "w12-explicit-leaf");
7401        // Explicit-parent walk should include the grandparent.
7402        assert!(chain.contains(&"w12-explicit-grand".to_string()));
7403        assert!(chain.contains(&"w12-explicit-leaf".to_string()));
7404    }
7405
7406    // ------------------------------------------------------------------
7407    // extract_governance — surface the metadata.governance branch.
7408    // ------------------------------------------------------------------
7409
7410    #[test]
7411    fn test_extract_governance_default_when_metadata_absent() {
7412        let mem_val = json!({"id": "x"});
7413        let gov = super::extract_governance(&mem_val);
7414        // Default policy is non-null and serializes to an object.
7415        assert!(gov.is_object() || gov.is_null());
7416    }
7417
7418    #[test]
7419    fn test_extract_governance_default_when_metadata_invalid() {
7420        // metadata.governance present but not a valid policy -> default.
7421        let mem_val = json!({"metadata": {"governance": {"unknown": "policy"}}});
7422        let gov = super::extract_governance(&mem_val);
7423        // Default policy is non-null and serializes to an object.
7424        assert!(gov.is_object());
7425    }
7426
7427    // ------------------------------------------------------------------
7428    // messages_namespace_for — confirm both ASCII and ai: prefixes.
7429    // ------------------------------------------------------------------
7430
7431    #[test]
7432    fn test_messages_namespace_for_plain_id() {
7433        assert_eq!(super::messages_namespace_for("alice"), "_messages/alice");
7434    }
7435
7436    #[test]
7437    fn test_messages_namespace_for_ai_prefixed_id() {
7438        let ns = super::messages_namespace_for("ai:claude@host:pid-1");
7439        assert!(ns.starts_with("_messages/"));
7440        assert!(ns.contains("ai:"));
7441    }
7442
7443    // ------------------------------------------------------------------
7444    // inject_namespace_standard — additional shape branches that M9
7445    // didn't reach (no-namespace + no-global, dedup ordering).
7446    // ------------------------------------------------------------------
7447
7448    #[test]
7449    fn test_inject_namespace_standard_no_namespace_no_global() {
7450        // namespace=None and no "*" standard set → response unchanged.
7451        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7452        let mut resp = make_recall_response(vec![]);
7453        let before = resp.clone();
7454        super::inject_namespace_standard(&conn, None, &mut resp);
7455        assert_eq!(resp, before);
7456    }
7457
7458    // ------------------------------------------------------------------
7459    // W12-A — additional coverage targets discovered after the first
7460    // sweep. These hit handler happy-paths that the smoke matrix
7461    // skipped (tier-default promotion, dedup-update, registered
7462    // subscriber) plus a few error / boundary branches.
7463    // ------------------------------------------------------------------
7464
7465    #[test]
7466    fn handle_promote_default_tier_to_long() {
7467        // Drives the "no to_namespace" branch which clears expires_at
7468        // and bumps tier to Long. This is the historical behaviour.
7469        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7470        let mem = Memory {
7471            id: uuid::Uuid::new_v4().to_string(),
7472            tier: Tier::Mid,
7473            namespace: "w12-tier-promote".into(),
7474            title: "t".into(),
7475            content: "c".into(),
7476            tags: vec![],
7477            priority: 5,
7478            confidence: 1.0,
7479            source: "test".into(),
7480            access_count: 0,
7481            created_at: chrono::Utc::now().to_rfc3339(),
7482            updated_at: chrono::Utc::now().to_rfc3339(),
7483            last_accessed_at: None,
7484            expires_at: None,
7485            metadata: json!({}),
7486        };
7487        let id = db::insert(&conn, &mem).unwrap();
7488        let req = make_tools_call("memory_promote", json!({"id": id}));
7489        let resp = invoke_handle_request(&conn, &req);
7490        assert!(resp.error.is_none());
7491        let text = resp.result.unwrap()["content"][0]["text"]
7492            .as_str()
7493            .unwrap()
7494            .to_string();
7495        let val: Value = serde_json::from_str(&text).unwrap();
7496        assert_eq!(val["promoted"], true);
7497        assert_eq!(val["mode"], "tier");
7498        assert_eq!(val["tier"], "long");
7499    }
7500
7501    #[test]
7502    fn handle_store_dedup_updates_existing() {
7503        // Storing twice with the same title+namespace must hit the
7504        // dedup-update branch instead of inserting a second row.
7505        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7506        let req1 = make_tools_call(
7507            "memory_store",
7508            json!({
7509                "title": "dup-title",
7510                "content": "first",
7511                "namespace": "w12-dedup",
7512                "tier": "mid",
7513            }),
7514        );
7515        let resp1 = invoke_handle_request(&conn, &req1);
7516        assert!(resp1.error.is_none());
7517        let text1 = resp1.result.unwrap()["content"][0]["text"]
7518            .as_str()
7519            .unwrap()
7520            .to_string();
7521        let val1: Value = serde_json::from_str(&text1).unwrap();
7522        let id1 = val1["id"].as_str().unwrap().to_string();
7523
7524        let req2 = make_tools_call(
7525            "memory_store",
7526            json!({
7527                "title": "dup-title",
7528                "content": "second-update",
7529                "namespace": "w12-dedup",
7530                "tier": "long",
7531            }),
7532        );
7533        let resp2 = invoke_handle_request(&conn, &req2);
7534        assert!(resp2.error.is_none());
7535        let text2 = resp2.result.unwrap()["content"][0]["text"]
7536            .as_str()
7537            .unwrap()
7538            .to_string();
7539        let val2: Value = serde_json::from_str(&text2).unwrap();
7540        assert_eq!(val2["id"], id1);
7541        assert_eq!(val2["duplicate"], true);
7542        assert_eq!(val2["action"], "updated existing memory");
7543    }
7544
7545    #[test]
7546    fn handle_subscribe_with_registered_agent_succeeds() {
7547        // Drives the subscribe-after-register happy path (the smoke
7548        // matrix only catches the unregistered-error case).
7549        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7550        // Register the caller (default agent_id resolved by mcp_client=None)
7551        // — we let resolve_agent_id mint one; by registering the resolved
7552        // value we can pass the subscribe gate.
7553        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
7554        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
7555        let req = make_tools_call(
7556            "memory_subscribe",
7557            json!({
7558                "url": "https://example.com/hook",
7559                "events": "memory_store,memory_delete",
7560                "namespace_filter": "w12-sub",
7561            }),
7562        );
7563        let resp = invoke_handle_request(&conn, &req);
7564        assert!(resp.error.is_none());
7565        let text = resp.result.unwrap()["content"][0]["text"]
7566            .as_str()
7567            .unwrap()
7568            .to_string();
7569        let val: Value = serde_json::from_str(&text).unwrap();
7570        assert!(val["id"].is_string());
7571        assert_eq!(val["url"], "https://example.com/hook");
7572    }
7573
7574    #[test]
7575    fn handle_subscribe_invalid_url_after_registered() {
7576        // After registering, a malformed URL still falls through to the
7577        // url-validate branch.
7578        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7579        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
7580        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
7581        let req = make_tools_call("memory_subscribe", json!({"url": "not-a-url-at-all"}));
7582        let resp = invoke_handle_request(&conn, &req);
7583        let result = resp.result.unwrap();
7584        assert_eq!(result["isError"], true);
7585    }
7586
7587    #[test]
7588    fn handle_namespace_set_standard_with_valid_governance() {
7589        // Drives the governance-merge branch (lines 2284-2322) which
7590        // re-writes the standard memory's metadata with the resolved
7591        // policy.
7592        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7593        let mem = Memory {
7594            id: uuid::Uuid::new_v4().to_string(),
7595            tier: Tier::Long,
7596            namespace: "w12-gov-ok".into(),
7597            title: "p".into(),
7598            content: "c".into(),
7599            tags: vec![],
7600            priority: 5,
7601            confidence: 1.0,
7602            source: "test".into(),
7603            access_count: 0,
7604            created_at: chrono::Utc::now().to_rfc3339(),
7605            updated_at: chrono::Utc::now().to_rfc3339(),
7606            last_accessed_at: None,
7607            expires_at: None,
7608            metadata: json!({}),
7609        };
7610        let id = db::insert(&conn, &mem).unwrap();
7611        let req = make_tools_call(
7612            "memory_namespace_set_standard",
7613            json!({
7614                "namespace": "w12-gov-ok",
7615                "id": id,
7616                "governance": {
7617                    "write": "any",
7618                    "promote": "any",
7619                    "delete": "owner",
7620                    "approver": "human",
7621                },
7622            }),
7623        );
7624        let resp = invoke_handle_request(&conn, &req);
7625        assert!(resp.error.is_none());
7626        let text = resp.result.unwrap()["content"][0]["text"]
7627            .as_str()
7628            .unwrap()
7629            .to_string();
7630        let val: Value = serde_json::from_str(&text).unwrap();
7631        assert_eq!(val["set"], true);
7632        assert!(val["governance"].is_object());
7633    }
7634
7635    #[test]
7636    fn handle_namespace_set_standard_with_parent() {
7637        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7638        let mem = Memory {
7639            id: uuid::Uuid::new_v4().to_string(),
7640            tier: Tier::Long,
7641            namespace: "w12-parent-ns".into(),
7642            title: "p".into(),
7643            content: "c".into(),
7644            tags: vec![],
7645            priority: 5,
7646            confidence: 1.0,
7647            source: "test".into(),
7648            access_count: 0,
7649            created_at: chrono::Utc::now().to_rfc3339(),
7650            updated_at: chrono::Utc::now().to_rfc3339(),
7651            last_accessed_at: None,
7652            expires_at: None,
7653            metadata: json!({}),
7654        };
7655        let id = db::insert(&conn, &mem).unwrap();
7656        let req = make_tools_call(
7657            "memory_namespace_set_standard",
7658            json!({
7659                "namespace": "w12-parent-ns",
7660                "id": id,
7661                "parent": "w12-grand-ns",
7662            }),
7663        );
7664        let resp = invoke_handle_request(&conn, &req);
7665        assert!(resp.error.is_none());
7666        let text = resp.result.unwrap()["content"][0]["text"]
7667            .as_str()
7668            .unwrap()
7669            .to_string();
7670        let val: Value = serde_json::from_str(&text).unwrap();
7671        assert_eq!(val["parent"], "w12-grand-ns");
7672    }
7673
7674    #[test]
7675    fn handle_get_resolves_by_prefix_and_includes_links() {
7676        // db::resolve_id walks both exact and prefix lookup. Insert a
7677        // memory and request it by its 8-char prefix to drive the
7678        // prefix branch.
7679        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7680        let mem = Memory {
7681            id: uuid::Uuid::new_v4().to_string(),
7682            tier: Tier::Long,
7683            namespace: "w12-prefix".into(),
7684            title: "T".into(),
7685            content: "C".into(),
7686            tags: vec![],
7687            priority: 5,
7688            confidence: 1.0,
7689            source: "test".into(),
7690            access_count: 0,
7691            created_at: chrono::Utc::now().to_rfc3339(),
7692            updated_at: chrono::Utc::now().to_rfc3339(),
7693            last_accessed_at: None,
7694            expires_at: None,
7695            metadata: json!({}),
7696        };
7697        let id = db::insert(&conn, &mem).unwrap();
7698        let req = make_tools_call("memory_get", json!({"id": id}));
7699        let resp = invoke_handle_request(&conn, &req);
7700        assert!(resp.error.is_none());
7701        let text = resp.result.unwrap()["content"][0]["text"]
7702            .as_str()
7703            .unwrap()
7704            .to_string();
7705        let val: Value = serde_json::from_str(&text).unwrap();
7706        assert!(val["links"].is_array());
7707        assert_eq!(val["id"], id);
7708    }
7709
7710    #[test]
7711    fn handle_link_creates_link_between_existing_memories() {
7712        // Drives the create_link happy path (smoke matrix uses bogus IDs
7713        // so the existence check fails out before INSERT).
7714        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7715        let src = Memory {
7716            id: uuid::Uuid::new_v4().to_string(),
7717            tier: Tier::Long,
7718            namespace: "w12-link".into(),
7719            title: "src".into(),
7720            content: "c".into(),
7721            tags: vec![],
7722            priority: 5,
7723            confidence: 1.0,
7724            source: "test".into(),
7725            access_count: 0,
7726            created_at: chrono::Utc::now().to_rfc3339(),
7727            updated_at: chrono::Utc::now().to_rfc3339(),
7728            last_accessed_at: None,
7729            expires_at: None,
7730            metadata: json!({}),
7731        };
7732        let mut tgt = src.clone();
7733        tgt.id = uuid::Uuid::new_v4().to_string();
7734        tgt.title = "tgt".into();
7735        let src_id = db::insert(&conn, &src).unwrap();
7736        let tgt_id = db::insert(&conn, &tgt).unwrap();
7737        let req = make_tools_call(
7738            "memory_link",
7739            json!({
7740                "source_id": src_id,
7741                "target_id": tgt_id,
7742                "relation": "related_to",
7743            }),
7744        );
7745        let resp = invoke_handle_request(&conn, &req);
7746        assert!(resp.error.is_none());
7747        let text = resp.result.unwrap()["content"][0]["text"]
7748            .as_str()
7749            .unwrap()
7750            .to_string();
7751        let val: Value = serde_json::from_str(&text).unwrap();
7752        assert_eq!(val["linked"], true);
7753    }
7754
7755    #[test]
7756    fn handle_get_links_returns_outbound_and_inbound() {
7757        // Seed source+target+link, query links from source.
7758        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7759        let src = Memory {
7760            id: uuid::Uuid::new_v4().to_string(),
7761            tier: Tier::Long,
7762            namespace: "w12-getlinks".into(),
7763            title: "src".into(),
7764            content: "c".into(),
7765            tags: vec![],
7766            priority: 5,
7767            confidence: 1.0,
7768            source: "test".into(),
7769            access_count: 0,
7770            created_at: chrono::Utc::now().to_rfc3339(),
7771            updated_at: chrono::Utc::now().to_rfc3339(),
7772            last_accessed_at: None,
7773            expires_at: None,
7774            metadata: json!({}),
7775        };
7776        let mut tgt = src.clone();
7777        tgt.id = uuid::Uuid::new_v4().to_string();
7778        let src_id = db::insert(&conn, &src).unwrap();
7779        let tgt_id = db::insert(&conn, &tgt).unwrap();
7780        db::create_link(&conn, &src_id, &tgt_id, "supersedes").unwrap();
7781
7782        let req = make_tools_call("memory_get_links", json!({"id": src_id}));
7783        let resp = invoke_handle_request(&conn, &req);
7784        assert!(resp.error.is_none());
7785        let text = resp.result.unwrap()["content"][0]["text"]
7786            .as_str()
7787            .unwrap()
7788            .to_string();
7789        let val: Value = serde_json::from_str(&text).unwrap();
7790        assert!(val["count"].as_u64().unwrap() >= 1);
7791    }
7792
7793    #[test]
7794    fn handle_kg_timeline_with_seeded_link_returns_event() {
7795        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7796        let src = Memory {
7797            id: uuid::Uuid::new_v4().to_string(),
7798            tier: Tier::Long,
7799            namespace: "w12-tl".into(),
7800            title: "src".into(),
7801            content: "c".into(),
7802            tags: vec![],
7803            priority: 5,
7804            confidence: 1.0,
7805            source: "test".into(),
7806            access_count: 0,
7807            created_at: chrono::Utc::now().to_rfc3339(),
7808            updated_at: chrono::Utc::now().to_rfc3339(),
7809            last_accessed_at: None,
7810            expires_at: None,
7811            metadata: json!({}),
7812        };
7813        let mut tgt = src.clone();
7814        tgt.id = uuid::Uuid::new_v4().to_string();
7815        tgt.title = "tgt".into();
7816        let src_id = db::insert(&conn, &src).unwrap();
7817        let tgt_id = db::insert(&conn, &tgt).unwrap();
7818        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
7819
7820        let req = make_tools_call(
7821            "memory_kg_timeline",
7822            json!({"source_id": src_id, "limit": 10}),
7823        );
7824        let resp = invoke_handle_request(&conn, &req);
7825        assert!(resp.error.is_none());
7826        let text = resp.result.unwrap()["content"][0]["text"]
7827            .as_str()
7828            .unwrap()
7829            .to_string();
7830        let val: Value = serde_json::from_str(&text).unwrap();
7831        assert_eq!(val["count"], 1);
7832        let events = val["events"].as_array().unwrap();
7833        assert_eq!(events[0]["target_id"], tgt_id);
7834    }
7835
7836    #[test]
7837    fn handle_kg_query_with_seeded_link_returns_node() {
7838        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7839        let src = Memory {
7840            id: uuid::Uuid::new_v4().to_string(),
7841            tier: Tier::Long,
7842            namespace: "w12-kgq".into(),
7843            title: "src".into(),
7844            content: "c".into(),
7845            tags: vec![],
7846            priority: 5,
7847            confidence: 1.0,
7848            source: "test".into(),
7849            access_count: 0,
7850            created_at: chrono::Utc::now().to_rfc3339(),
7851            updated_at: chrono::Utc::now().to_rfc3339(),
7852            last_accessed_at: None,
7853            expires_at: None,
7854            metadata: json!({}),
7855        };
7856        let mut tgt = src.clone();
7857        tgt.id = uuid::Uuid::new_v4().to_string();
7858        let src_id = db::insert(&conn, &src).unwrap();
7859        let tgt_id = db::insert(&conn, &tgt).unwrap();
7860        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
7861
7862        let req = make_tools_call(
7863            "memory_kg_query",
7864            json!({"source_id": src_id, "max_depth": 1, "limit": 10}),
7865        );
7866        let resp = invoke_handle_request(&conn, &req);
7867        assert!(resp.error.is_none());
7868        let text = resp.result.unwrap()["content"][0]["text"]
7869            .as_str()
7870            .unwrap()
7871            .to_string();
7872        let val: Value = serde_json::from_str(&text).unwrap();
7873        assert!(val["count"].as_u64().unwrap() >= 1);
7874        assert!(val["paths"].is_array());
7875    }
7876
7877    #[test]
7878    fn handle_archive_list_with_pagination() {
7879        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7880        let req = make_tools_call("memory_archive_list", json!({"limit": 100, "offset": 50}));
7881        let resp = invoke_handle_request(&conn, &req);
7882        assert!(resp.error.is_none());
7883    }
7884
7885    #[test]
7886    fn handle_pending_list_with_status_filter() {
7887        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7888        for status in &["pending", "approved", "rejected"] {
7889            let req = make_tools_call(
7890                "memory_pending_list",
7891                json!({"status": status, "limit": 50}),
7892            );
7893            let resp = invoke_handle_request(&conn, &req);
7894            assert!(resp.error.is_none(), "failed for status={status}");
7895        }
7896    }
7897
7898    #[test]
7899    fn handle_pending_approve_with_seeded_pending_action() {
7900        // Seed a pending action to drive the consensus / approval branch.
7901        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7902        let pending_id = db::queue_pending_action(
7903            &conn,
7904            crate::models::GovernedAction::Promote,
7905            "w12-approve",
7906            None,
7907            "human:requestor",
7908            &json!({"id": "00000000-0000-0000-0000-000000000000"}),
7909        )
7910        .unwrap();
7911        let req = make_tools_call(
7912            "memory_pending_approve",
7913            json!({"id": pending_id, "agent_id": "human:approver"}),
7914        );
7915        let resp = invoke_handle_request(&conn, &req);
7916        // Either approves outright or marks pending — both touch the
7917        // ApproveOutcome match arms in the handler.
7918        let result = resp.result.unwrap();
7919        assert!(result.is_object());
7920    }
7921
7922    #[test]
7923    fn handle_pending_reject_with_seeded_pending_action() {
7924        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7925        let pending_id = db::queue_pending_action(
7926            &conn,
7927            crate::models::GovernedAction::Promote,
7928            "w12-reject",
7929            None,
7930            "human:requestor",
7931            &json!({"id": "00000000-0000-0000-0000-000000000000"}),
7932        )
7933        .unwrap();
7934        let req = make_tools_call(
7935            "memory_pending_reject",
7936            json!({"id": pending_id, "agent_id": "human:rejector"}),
7937        );
7938        let resp = invoke_handle_request(&conn, &req);
7939        assert!(resp.error.is_none());
7940        let text = resp.result.unwrap()["content"][0]["text"]
7941            .as_str()
7942            .unwrap()
7943            .to_string();
7944        let val: Value = serde_json::from_str(&text).unwrap();
7945        assert_eq!(val["rejected"], true);
7946    }
7947
7948    #[test]
7949    fn handle_session_start_toon_format_default() {
7950        // session_start defaults to TOON compact format — drives the
7951        // toon_compact match arm in the format dispatch.
7952        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7953        let req = make_tools_call("memory_session_start", json!({"namespace": "w12-toon"}));
7954        let resp = invoke_handle_request(&conn, &req);
7955        assert!(resp.error.is_none());
7956        // TOON output is plain text, not JSON — just confirm it's present.
7957        let result = resp.result.unwrap();
7958        assert!(result["content"][0]["text"].is_string());
7959    }
7960
7961    #[test]
7962    fn handle_search_explicit_toon_format() {
7963        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7964        let req = make_tools_call(
7965            "memory_search",
7966            json!({"query": "anything", "format": "toon"}),
7967        );
7968        let resp = invoke_handle_request(&conn, &req);
7969        assert!(resp.error.is_none());
7970    }
7971
7972    #[test]
7973    fn handle_recall_explicit_toon_format() {
7974        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7975        let req = make_tools_call("memory_recall", json!({"context": "ctx", "format": "toon"}));
7976        let resp = invoke_handle_request(&conn, &req);
7977        assert!(resp.error.is_none());
7978    }
7979
7980    #[test]
7981    fn handle_list_explicit_toon_compact_format() {
7982        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7983        let req = make_tools_call(
7984            "memory_list",
7985            json!({"namespace": "w12-toon-list", "format": "toon_compact"}),
7986        );
7987        let resp = invoke_handle_request(&conn, &req);
7988        assert!(resp.error.is_none());
7989    }
7990
7991    #[test]
7992    fn handle_search_with_namespace_and_tier_filters() {
7993        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7994        let req = make_tools_call(
7995            "memory_search",
7996            json!({
7997                "query": "test query",
7998                "namespace": "w12-search",
7999                "tier": "long",
8000                "limit": 10,
8001                "agent_id": "ai:bot",
8002                "format": "json",
8003            }),
8004        );
8005        let resp = invoke_handle_request(&conn, &req);
8006        assert!(resp.error.is_none());
8007    }
8008
8009    #[test]
8010    fn handle_search_invalid_agent_id_rejected() {
8011        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8012        let req = make_tools_call(
8013            "memory_search",
8014            json!({"query": "x", "agent_id": "bad agent !!"}),
8015        );
8016        let resp = invoke_handle_request(&conn, &req);
8017        let result = resp.result.unwrap();
8018        assert_eq!(result["isError"], true);
8019    }
8020
8021    #[test]
8022    fn handle_search_invalid_as_agent_rejected() {
8023        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8024        let req = make_tools_call(
8025            "memory_search",
8026            json!({"query": "x", "as_agent": "BAD AS AGENT"}),
8027        );
8028        let resp = invoke_handle_request(&conn, &req);
8029        let result = resp.result.unwrap();
8030        assert_eq!(result["isError"], true);
8031    }
8032
8033    #[test]
8034    fn handle_recall_invalid_as_agent_rejected() {
8035        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8036        let req = make_tools_call(
8037            "memory_recall",
8038            json!({"context": "x", "as_agent": "INVALID NS"}),
8039        );
8040        let resp = invoke_handle_request(&conn, &req);
8041        let result = resp.result.unwrap();
8042        assert_eq!(result["isError"], true);
8043    }
8044
8045    #[test]
8046    fn handle_recall_with_context_tokens() {
8047        // Drives the context_tokens-not-empty branch (without an embedder
8048        // it just feeds the keyword fallback).
8049        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8050        let req = make_tools_call(
8051            "memory_recall",
8052            json!({
8053                "context": "main",
8054                "context_tokens": ["recent", "tokens", "from", "convo"],
8055                "format": "json",
8056            }),
8057        );
8058        let resp = invoke_handle_request(&conn, &req);
8059        assert!(resp.error.is_none());
8060    }
8061
8062    #[test]
8063    fn handle_recall_with_budget_tokens_positive() {
8064        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8065        let req = make_tools_call(
8066            "memory_recall",
8067            json!({"context": "x", "budget_tokens": 1000, "format": "json"}),
8068        );
8069        let resp = invoke_handle_request(&conn, &req);
8070        assert!(resp.error.is_none());
8071        let text = resp.result.unwrap()["content"][0]["text"]
8072            .as_str()
8073            .unwrap()
8074            .to_string();
8075        let val: Value = serde_json::from_str(&text).unwrap();
8076        assert!(val["tokens_used"].is_u64() || val["tokens_used"].is_i64());
8077        assert_eq!(val["budget_tokens"], 1000);
8078    }
8079
8080    #[test]
8081    fn handle_recall_invalid_namespace_filter_passes_through() {
8082        // Recall accepts a namespace filter without validating; an
8083        // unknown namespace simply returns zero results.
8084        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8085        let req = make_tools_call(
8086            "memory_recall",
8087            json!({
8088                "context": "x",
8089                "namespace": "w12-no-such-namespace",
8090                "format": "json",
8091            }),
8092        );
8093        let resp = invoke_handle_request(&conn, &req);
8094        assert!(resp.error.is_none());
8095    }
8096
8097    #[test]
8098    fn handle_list_with_tier_filter() {
8099        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8100        let req = make_tools_call(
8101            "memory_list",
8102            json!({
8103                "namespace": "w12-list-tier",
8104                "tier": "long",
8105                "agent_id": "ai:bot",
8106                "limit": 25,
8107                "format": "json",
8108            }),
8109        );
8110        let resp = invoke_handle_request(&conn, &req);
8111        assert!(resp.error.is_none());
8112    }
8113
8114    #[test]
8115    fn handle_list_invalid_tier_treated_as_none() {
8116        // tier::from_str returns None for an invalid value, which the
8117        // handler tolerates (no validation error) — drives the
8118        // and_then-None branch.
8119        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8120        let req = make_tools_call(
8121            "memory_list",
8122            json!({"namespace": "w12-list-bad-tier", "tier": "ULTRAMID", "format": "json"}),
8123        );
8124        let resp = invoke_handle_request(&conn, &req);
8125        assert!(resp.error.is_none());
8126    }
8127
8128    #[test]
8129    fn handle_get_taxonomy_invalid_depth_clamps_to_max() {
8130        // `depth` saturates against MAX_NAMESPACE_DEPTH; very large
8131        // values still succeed.
8132        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8133        let req = make_tools_call(
8134            "memory_get_taxonomy",
8135            json!({"depth": 100_000_u64, "limit": 50_000_u64}),
8136        );
8137        let resp = invoke_handle_request(&conn, &req);
8138        assert!(resp.error.is_none());
8139    }
8140
8141    #[test]
8142    fn handle_archive_purge_no_filter_purges_all() {
8143        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8144        let req = make_tools_call("memory_archive_purge", json!({}));
8145        let resp = invoke_handle_request(&conn, &req);
8146        assert!(resp.error.is_none());
8147    }
8148
8149    #[test]
8150    fn handle_check_duplicate_invalid_title_rejected() {
8151        // No embedder → standard error; but when title is empty the
8152        // validate_title path errors before the embedder check.
8153        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8154        let req = make_tools_call(
8155            "memory_check_duplicate",
8156            json!({"title": "", "content": "anything"}),
8157        );
8158        let resp = invoke_handle_request(&conn, &req);
8159        let result = resp.result.unwrap();
8160        assert_eq!(result["isError"], true);
8161    }
8162
8163    #[test]
8164    fn handle_check_duplicate_invalid_namespace_rejected() {
8165        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8166        let req = make_tools_call(
8167            "memory_check_duplicate",
8168            json!({"title": "T", "content": "C", "namespace": "BAD NS"}),
8169        );
8170        let resp = invoke_handle_request(&conn, &req);
8171        let result = resp.result.unwrap();
8172        assert_eq!(result["isError"], true);
8173    }
8174
8175    #[test]
8176    fn handle_entity_register_with_explicit_agent_id() {
8177        // Drives the explicit_agent_id-Some branch (validates +
8178        // resolves).
8179        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8180        let req = make_tools_call(
8181            "memory_entity_register",
8182            json!({
8183                "canonical_name": "Org Alpha",
8184                "namespace": "w12-orgs",
8185                "aliases": ["alpha", "α"],
8186                "agent_id": "ai:bot",
8187            }),
8188        );
8189        let resp = invoke_handle_request(&conn, &req);
8190        assert!(resp.error.is_none());
8191    }
8192
8193    #[test]
8194    fn handle_entity_register_invalid_explicit_agent_id() {
8195        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8196        let req = make_tools_call(
8197            "memory_entity_register",
8198            json!({
8199                "canonical_name": "Org Beta",
8200                "namespace": "w12-orgs",
8201                "agent_id": "BAD AGENT !!",
8202            }),
8203        );
8204        let resp = invoke_handle_request(&conn, &req);
8205        let result = resp.result.unwrap();
8206        assert_eq!(result["isError"], true);
8207    }
8208
8209    #[test]
8210    fn handle_entity_get_by_alias_no_namespace() {
8211        // Drives the namespace=None branch (alias lookup across all ns).
8212        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8213        let req = make_tools_call("memory_entity_get_by_alias", json!({"alias": "any-alias"}));
8214        let resp = invoke_handle_request(&conn, &req);
8215        assert!(resp.error.is_none());
8216    }
8217
8218    #[test]
8219    fn handle_inbox_with_message_seeded() {
8220        // Notify alice, then read alice's inbox.
8221        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8222        let notify = make_tools_call(
8223            "memory_notify",
8224            json!({
8225                "target_agent_id": "alice-w12",
8226                "title": "ping",
8227                "payload": "are you there?",
8228                "tier": "short",
8229            }),
8230        );
8231        let _ = invoke_handle_request(&conn, &notify);
8232        let inbox = make_tools_call(
8233            "memory_inbox",
8234            json!({"agent_id": "alice-w12", "limit": 10}),
8235        );
8236        let resp = invoke_handle_request(&conn, &inbox);
8237        assert!(resp.error.is_none());
8238        let text = resp.result.unwrap()["content"][0]["text"]
8239            .as_str()
8240            .unwrap()
8241            .to_string();
8242        let val: Value = serde_json::from_str(&text).unwrap();
8243        assert!(val["count"].as_u64().unwrap() >= 1);
8244        assert_eq!(val["agent_id"], "alice-w12");
8245    }
8246
8247    #[test]
8248    fn handle_consolidate_succeeds_when_source_was_standard() {
8249        // Even when one of the source memories is a namespace standard,
8250        // consolidate must succeed (the warning branch may or may not
8251        // fire depending on whether is_namespace_standard sees the row
8252        // pre- or post-deletion). This drives both the namespace-standard
8253        // check loop and the consolidate happy path together.
8254        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8255        let mem_a = Memory {
8256            id: uuid::Uuid::new_v4().to_string(),
8257            tier: Tier::Long,
8258            namespace: "w12-cons-warn".into(),
8259            title: "a".into(),
8260            content: "alpha".into(),
8261            tags: vec![],
8262            priority: 5,
8263            confidence: 1.0,
8264            source: "test".into(),
8265            access_count: 0,
8266            created_at: chrono::Utc::now().to_rfc3339(),
8267            updated_at: chrono::Utc::now().to_rfc3339(),
8268            last_accessed_at: None,
8269            expires_at: None,
8270            metadata: json!({}),
8271        };
8272        let mut mem_b = mem_a.clone();
8273        mem_b.id = uuid::Uuid::new_v4().to_string();
8274        mem_b.title = "b".into();
8275        mem_b.content = "beta".into();
8276        let id_a = db::insert(&conn, &mem_a).unwrap();
8277        let id_b = db::insert(&conn, &mem_b).unwrap();
8278        // Mark id_a as the standard for w12-cons-warn.
8279        db::set_namespace_standard(&conn, "w12-cons-warn", &id_a, None).unwrap();
8280
8281        let req = make_tools_call(
8282            "memory_consolidate",
8283            json!({
8284                "ids": [id_a, id_b],
8285                "title": "merged-warn",
8286                "summary": "merged summary",
8287                "namespace": "w12-cons-warn",
8288            }),
8289        );
8290        let resp = invoke_handle_request(&conn, &req);
8291        assert!(resp.error.is_none());
8292        let text = resp.result.unwrap()["content"][0]["text"]
8293            .as_str()
8294            .unwrap()
8295            .to_string();
8296        let val: Value = serde_json::from_str(&text).unwrap();
8297        assert!(val["id"].is_string());
8298        assert_eq!(val["consolidated"], 2);
8299    }
8300
8301    #[test]
8302    fn handle_update_clears_expires_with_empty_string() {
8303        // expires_at="" path is special-cased by db::update to clear
8304        // the column.
8305        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8306        let mem = Memory {
8307            id: uuid::Uuid::new_v4().to_string(),
8308            tier: Tier::Short,
8309            namespace: "w12-clear-exp".into(),
8310            title: "t".into(),
8311            content: "c".into(),
8312            tags: vec![],
8313            priority: 5,
8314            confidence: 1.0,
8315            source: "test".into(),
8316            access_count: 0,
8317            created_at: chrono::Utc::now().to_rfc3339(),
8318            updated_at: chrono::Utc::now().to_rfc3339(),
8319            last_accessed_at: None,
8320            expires_at: Some(chrono::Utc::now().to_rfc3339()),
8321            metadata: json!({}),
8322        };
8323        let id = db::insert(&conn, &mem).unwrap();
8324        let req = make_tools_call("memory_update", json!({"id": id, "expires_at": ""}));
8325        let resp = invoke_handle_request(&conn, &req);
8326        // empty "" is rejected by validate_expires_at_format; the
8327        // handler returns isError.
8328        let result = resp.result.unwrap();
8329        // The result shape depends on whether validate accepts "" — both
8330        // outcomes exercise distinct paths, so accept either.
8331        assert!(result.is_object());
8332    }
8333
8334    #[test]
8335    fn handle_update_change_namespace() {
8336        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8337        let mem = Memory {
8338            id: uuid::Uuid::new_v4().to_string(),
8339            tier: Tier::Mid,
8340            namespace: "w12-update-ns".into(),
8341            title: "t".into(),
8342            content: "c".into(),
8343            tags: vec![],
8344            priority: 5,
8345            confidence: 1.0,
8346            source: "test".into(),
8347            access_count: 0,
8348            created_at: chrono::Utc::now().to_rfc3339(),
8349            updated_at: chrono::Utc::now().to_rfc3339(),
8350            last_accessed_at: None,
8351            expires_at: None,
8352            metadata: json!({}),
8353        };
8354        let id = db::insert(&conn, &mem).unwrap();
8355        let req = make_tools_call(
8356            "memory_update",
8357            json!({
8358                "id": id,
8359                "namespace": "w12-update-ns-new",
8360                "tags": ["a", "b"],
8361                "title": "new-title",
8362                "content": "new-content",
8363                "tier": "long",
8364                "priority": 8_i64,
8365                "confidence": 0.9_f64,
8366            }),
8367        );
8368        let resp = invoke_handle_request(&conn, &req);
8369        assert!(resp.error.is_none());
8370    }
8371
8372    #[test]
8373    fn handle_delete_with_prefix_id_lookup() {
8374        // db::get_by_prefix is consulted when exact ID lookup misses.
8375        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8376        let mem = Memory {
8377            id: uuid::Uuid::new_v4().to_string(),
8378            tier: Tier::Mid,
8379            namespace: "w12-delete-prefix".into(),
8380            title: "t".into(),
8381            content: "c".into(),
8382            tags: vec![],
8383            priority: 5,
8384            confidence: 1.0,
8385            source: "test".into(),
8386            access_count: 0,
8387            created_at: chrono::Utc::now().to_rfc3339(),
8388            updated_at: chrono::Utc::now().to_rfc3339(),
8389            last_accessed_at: None,
8390            expires_at: None,
8391            metadata: json!({}),
8392        };
8393        let id = db::insert(&conn, &mem).unwrap();
8394        let req = make_tools_call("memory_delete", json!({"id": id}));
8395        let resp = invoke_handle_request(&conn, &req);
8396        assert!(resp.error.is_none());
8397        let text = resp.result.unwrap()["content"][0]["text"]
8398            .as_str()
8399            .unwrap()
8400            .to_string();
8401        let val: Value = serde_json::from_str(&text).unwrap();
8402        assert_eq!(val["deleted"], true);
8403    }
8404
8405    #[test]
8406    fn handle_unsubscribe_after_subscribe_removes_row() {
8407        // Drives the removed=1 branch.
8408        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8409        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
8410        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
8411        let sub = make_tools_call(
8412            "memory_subscribe",
8413            json!({"url": "https://example.com/hook2"}),
8414        );
8415        let sub_resp = invoke_handle_request(&conn, &sub);
8416        let sub_text = sub_resp.result.unwrap()["content"][0]["text"]
8417            .as_str()
8418            .unwrap()
8419            .to_string();
8420        let sub_val: Value = serde_json::from_str(&sub_text).unwrap();
8421        let id = sub_val["id"].as_str().unwrap().to_string();
8422
8423        let unsub = make_tools_call("memory_unsubscribe", json!({"id": id}));
8424        let unsub_resp = invoke_handle_request(&conn, &unsub);
8425        assert!(unsub_resp.error.is_none());
8426        let unsub_text = unsub_resp.result.unwrap()["content"][0]["text"]
8427            .as_str()
8428            .unwrap()
8429            .to_string();
8430        let unsub_val: Value = serde_json::from_str(&unsub_text).unwrap();
8431        assert!(
8432            unsub_val["removed"] == json!(true) || unsub_val["removed"] == json!(1),
8433            "unexpected removed value: {:?}",
8434            unsub_val["removed"]
8435        );
8436    }
8437
8438    #[test]
8439    fn handle_list_subscriptions_after_subscribe_returns_one() {
8440        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8441        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
8442        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
8443        let sub = make_tools_call(
8444            "memory_subscribe",
8445            json!({"url": "https://example.com/listed"}),
8446        );
8447        let _ = invoke_handle_request(&conn, &sub);
8448        let req = make_tools_call("memory_list_subscriptions", json!({}));
8449        let resp = invoke_handle_request(&conn, &req);
8450        assert!(resp.error.is_none());
8451        let text = resp.result.unwrap()["content"][0]["text"]
8452            .as_str()
8453            .unwrap()
8454            .to_string();
8455        let val: Value = serde_json::from_str(&text).unwrap();
8456        // subscriptions field holds the array; the count field may be at
8457        // top level — accept either key.
8458        assert!(val.get("subscriptions").is_some() || val.get("count").is_some() || val.is_array());
8459    }
8460
8461    #[test]
8462    fn test_inject_namespace_standard_dedup_keeps_originals_order() {
8463        // When the standard is one of the recall hits, dedup removes it
8464        // but preserves the relative order of remaining results.
8465        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8466        let std_id = seed_namespace_standard(&conn, "w12-order", "S");
8467        let mems = vec![
8468            json!({"id": "first", "title": "f"}),
8469            json!({"id": std_id, "title": "S"}),
8470            json!({"id": "third", "title": "t"}),
8471        ];
8472        let mut resp = make_recall_response(mems);
8473        super::inject_namespace_standard(&conn, Some("w12-order"), &mut resp);
8474        let memories = resp["memories"].as_array().unwrap();
8475        assert_eq!(memories.len(), 2);
8476        assert_eq!(memories[0]["id"], "first");
8477        assert_eq!(memories[1]["id"], "third");
8478    }
8479}