Skip to main content

ai_memory/mcp/
mod.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
7// #873 — `handle_request` carries the per-tool dispatch match (each arm
8// is a closure-shaped call into a per-tool handler with that handler's
9// specific argument bundle); tracked for split into a registry table
10// as #867. Allowance is module-scope to cover the dispatch helper as
11// well as the legacy `serve_mcp` boot scaffold which is still over-
12// budget while the deferred-registration substrate threads through.
13#![allow(clippy::too_many_lines)]
14
15use crate::models::field_names;
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18use std::io::{self, BufRead, Read, Write};
19use std::path::Path;
20use std::sync::Arc;
21use std::time::Instant;
22
23use crate::config::{AppConfig, FeatureTier, ResolvedModels, TierConfig};
24use crate::db;
25use crate::embeddings::{Embed, Embedder};
26use crate::hnsw::VectorIndex;
27use crate::llm::OllamaClient;
28use crate::reranker::{BatchedReranker, CrossEncoder};
29
30/// Effective-tier banner label for the fully-provisioned tier — the
31/// `config.rs` `FeatureTier` spellings are vendor-carve-out-frozen, so the
32/// banner keeps a file-local spelling (#1558 batch 6).
33const EFFECTIVE_TIER_AUTONOMOUS: &str = "autonomous";
34
35pub(super) mod registry;
36
37// v0.7.x (#1154) — daemon-side Ed25519-signed `serverInfo` block for
38// the MCP initialize handshake. Closes NSA CSI MCP Security concern
39// (j) Tool invocation path confusion. See module docs for the threat
40// model + canonical-bytes discipline.
41pub mod server_identity;
42
43// v0.7.0 Fix #5 closure — SSOT for MCP tool-call parameter field
44// names (canonical snake_case JSON keys). Closes the deferred-item
45// "MCP tool-call param field names (~98 sites)" from the literal
46// sweep: every `.get("X")` / `["X"]` extraction-site literal under
47// `src/mcp/` is allowlist-pinned by `tests/mcp_param_names_invariant.rs`.
48pub mod param_names;
49
50// #1558 batch 3 — JSON-RPC 2.0 wire-layer SSOT: version tag, reserved
51// error codes, method names, MCP protocol revision.
52pub mod jsonrpc;
53
54// v0.7.0 #972 D1.5 (#986) — shared parity-test helpers for the
55// schemars-derived `McpTool` impls vs. the legacy hand-coded
56// `tool_definitions()` catalog. Each `d1_5_986_tests` mod under
57// `src/mcp/tools/<tool>.rs` calls into these helpers so the 4-helper
58// boilerplate isn't duplicated 30+ times across the family migration.
59#[cfg(test)]
60pub(super) mod parity_test_helpers;
61
62// L0.7-3 Tier B chunk-A — shared test-only mutex serialising tests
63// across submodules that mutate the process-wide permission rules
64// registry. The registry is a static RwLock<Vec<PermissionRule>> in
65// `crate::governance`; tests that install rules must hold this mutex
66// for the duration of the call so concurrent tests don't see each
67// other's rules. The wrapping `RulesScope` helper inside each tool's
68// test module clears the registry on drop (even on panic) so any
69// trailing rule never leaks into the next test.
70#[cfg(test)]
71pub(super) static SHARED_PERMISSION_RULES_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
72
73// Re-export registry items at the crate::mcp:: path so external callers
74// (handlers.rs, sizes.rs, main.rs, etc.) continue to resolve them without
75// any call-site changes. Items that were `pub` in the original mcp.rs stay
76// `pub`; items that were `pub(crate)` stay `pub(crate)`.
77pub(crate) use registry::families_overview;
78// #859 — `trim_optional_params` is no longer called from outside the
79// registry module on the production path (the wire-shape composition
80// lives entirely inside `tool_definitions_for_profile`). The in-tree
81// tests still exercise it directly, so the re-export is gated to
82// `#[cfg(test)]`. `strip_docs_from_tools` is fully internal.
83#[cfg(test)]
84pub(crate) use registry::trim_optional_params;
85pub use registry::{
86    handle_capabilities_family, tool_definitions, tool_definitions_for_profile,
87    tool_definitions_for_profile_verbose,
88};
89
90// --- JSON-RPC types ---
91
92#[derive(Deserialize)]
93struct RpcRequest {
94    jsonrpc: String,
95    id: Option<Value>,
96    method: String,
97    #[serde(default)]
98    params: Value,
99}
100
101#[derive(Debug, Serialize)]
102struct RpcResponse {
103    jsonrpc: String,
104    id: Value,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    result: Option<Value>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    error: Option<RpcError>,
109}
110
111#[derive(Debug, Serialize)]
112struct RpcError {
113    code: i64,
114    message: String,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    data: Option<Value>,
117}
118
119fn ok_response(id: Value, result: Value) -> RpcResponse {
120    RpcResponse {
121        jsonrpc: jsonrpc::VERSION.into(),
122        id,
123        result: Some(result),
124        error: None,
125    }
126}
127
128fn err_response(id: Value, code: i64, message: String) -> RpcResponse {
129    RpcResponse {
130        jsonrpc: jsonrpc::VERSION.into(),
131        id,
132        result: None,
133        error: Some(RpcError {
134            code,
135            message,
136            data: None,
137        }),
138    }
139}
140
141/// PR-5 (issue #487): emit an audit event for an MCP `tools/call`
142/// dispatch. Per-handler emissions inside `handle_store` /
143/// `handle_delete` already produce their canonical events; this
144/// helper covers the remaining mutation+recall tool surface so
145/// `audit_emits_at_every_call_site` holds across the matrix.
146fn audit_emit_for_mcp_dispatch(
147    tool_name: &str,
148    arguments: &Value,
149    result: &Result<Value, String>,
150    mcp_client: Option<&str>,
151) {
152    if !crate::audit::is_enabled() {
153        return;
154    }
155    use crate::mcp::registry::tool_names;
156    let action = match tool_name {
157        // Skipped — emitted from inside the handler with full target context.
158        tool_names::MEMORY_STORE | tool_names::MEMORY_DELETE => return,
159        tool_names::MEMORY_RECALL
160        | tool_names::MEMORY_SEARCH
161        | tool_names::MEMORY_GET
162        | tool_names::MEMORY_LIST
163        | tool_names::MEMORY_SESSION_START => crate::audit::AuditAction::Recall,
164        tool_names::MEMORY_UPDATE => crate::audit::AuditAction::Update,
165        tool_names::MEMORY_PROMOTE => crate::audit::AuditAction::Promote,
166        tool_names::MEMORY_FORGET => crate::audit::AuditAction::Forget,
167        tool_names::MEMORY_LINK => crate::audit::AuditAction::Link,
168        tool_names::MEMORY_CONSOLIDATE => crate::audit::AuditAction::Consolidate,
169        tool_names::MEMORY_PENDING_APPROVE => crate::audit::AuditAction::Approve,
170        tool_names::MEMORY_PENDING_REJECT => crate::audit::AuditAction::Reject,
171        // Read-only / metadata tools — no audit event.
172        _ => return,
173    };
174    let agent_id = resolve_mcp_agent_id(arguments, mcp_client);
175    let namespace = arguments
176        .get("namespace")
177        .and_then(Value::as_str)
178        .unwrap_or(crate::DEFAULT_NAMESPACE)
179        .to_string();
180    let memory_id = arguments
181        .get("id")
182        .or_else(|| arguments.get("memory_id"))
183        .and_then(Value::as_str)
184        .unwrap_or("*")
185        .to_string();
186    let mut builder = crate::audit::EventBuilder::new(
187        action,
188        crate::audit::actor(
189            agent_id,
190            mcp_client.map_or(crate::audit::synthesis_sources::HOST_FALLBACK, |_| {
191                crate::audit::synthesis_sources::MCP_CLIENT_INFO
192            }),
193            None,
194        ),
195        crate::audit::AuditTarget {
196            memory_id,
197            namespace,
198            title: None,
199            tier: None,
200            scope: None,
201        },
202    );
203    if let Err(e) = result {
204        builder = builder.error(e.clone());
205    }
206    crate::audit::emit(builder);
207}
208
209/// Resolve the caller's agent_id for an MCP `tools/call` from the
210/// request `arguments` and the handshake-detected `mcp_client` name.
211/// Resolution order: explicit `arguments.agent_id` > `ai:<mcp_client>`
212/// > `"anonymous"`. Shared by the dispatch-level audit emitter and the
213/// L1 capture-nag observer so both key on the same identity.
214fn resolve_mcp_agent_id(arguments: &Value, mcp_client: Option<&str>) -> String {
215    arguments
216        .get("agent_id")
217        .and_then(Value::as_str)
218        .map(str::to_string)
219        .unwrap_or_else(|| {
220            mcp_client
221                .map(|c| format!("ai:{c}"))
222                .unwrap_or_else(|| "anonymous".into())
223        })
224}
225
226/// L1 (#1389 / #1398) — observe one `tools/call` against the
227/// capture-nag watcher and, on a threshold crossing, emit a single
228/// stderr WARN plus a `capture_lag` signed audit event for the session.
229/// Returns the [`NagAction`] so the dispatch loop (and tests) can see
230/// what fired. A `None` watcher (layer disabled / not constructed) is a
231/// no-op returning [`NagAction::None`]. Observation-only: it never
232/// blocks or alters the dispatch path.
233fn observe_capture_nag(
234    nag_watcher: Option<&crate::recover::nag::CaptureNagWatcher>,
235    session_id: &str,
236    tool_name: &str,
237    arguments: &Value,
238    mcp_client: Option<&str>,
239) -> crate::recover::nag::NagAction {
240    use crate::recover::nag::{NagAction, classify_tool};
241    let Some(watcher) = nag_watcher else {
242        return NagAction::None;
243    };
244    let agent_id = resolve_mcp_agent_id(arguments, mcp_client);
245    let action = watcher.observe_tool_call(&agent_id, session_id, classify_tool(tool_name));
246    match action {
247        NagAction::None => {}
248        NagAction::Warn => emit_capture_lag(
249            &agent_id,
250            session_id,
251            watcher.streak_for(&agent_id, session_id),
252            watcher.primary_threshold(),
253            false,
254        ),
255        NagAction::WarnAndEscalate => emit_capture_lag(
256            &agent_id,
257            session_id,
258            watcher.streak_for(&agent_id, session_id),
259            watcher.escalation_threshold(),
260            true,
261        ),
262    }
263    action
264}
265
266/// Emit the L1 `capture_lag` signal: a stderr WARN (MCP convention —
267/// stdout owns JSON-RPC framing, diagnostics go to stderr) plus a
268/// hash-chained `capture_lag` audit event when auditing is enabled. The
269/// streak + threshold ride the audit target's `title` advisory slot;
270/// `memory.content` is never involved, so there is no content-leak risk.
271fn emit_capture_lag(
272    agent_id: &str,
273    session_id: &str,
274    streak: u32,
275    threshold: u32,
276    escalated: bool,
277) {
278    let tier = if escalated { "escalation" } else { "warn" };
279    eprintln!(
280        "ai-memory capture_lag [{tier}]: agent={agent_id} session={session_id} — \
281         {streak} consecutive non-capture tool calls (threshold {threshold}); \
282         call memory_store or memory_capture_turn to record progress"
283    );
284    if !crate::audit::is_enabled() {
285        return;
286    }
287    let title = format!(
288        "capture_lag: {streak} non-capture tool calls (threshold {threshold}{})",
289        if escalated { ", escalation" } else { "" }
290    );
291    let mut builder = crate::audit::EventBuilder::new(
292        crate::audit::AuditAction::CaptureLag,
293        crate::audit::actor(
294            agent_id.to_string(),
295            crate::audit::synthesis_sources::MCP_CLIENT_INFO,
296            None,
297        ),
298        crate::audit::AuditTarget {
299            memory_id: "*".to_string(),
300            namespace: crate::DEFAULT_NAMESPACE.to_string(),
301            title: Some(title),
302            tier: None,
303            scope: None,
304        },
305    );
306    builder.session_id = Some(session_id.to_string());
307    crate::audit::emit(builder);
308}
309
310// --- MCP Prompts ---
311
312/// Return the list of available prompts.
313pub fn prompt_definitions() -> Value {
314    json!({
315        "prompts": [
316            {
317                "name": "recall-first",
318                (field_names::DESCRIPTION): "System prompt for AI clients: proactive memory recall, TOON format, tier strategy.",
319                "arguments": [
320                    {
321                        "name": "namespace",
322                        (field_names::DESCRIPTION): "Optional namespace to scope recall.",
323                        "required": false
324                    }
325                ]
326            },
327            {
328                "name": "memory-workflow",
329                (field_names::DESCRIPTION): "Quick reference card for memory tool usage patterns."
330            }
331        ]
332    })
333}
334
335/// Return the content of a specific prompt.
336fn prompt_content(name: &str, params: &Value) -> Result<Value, String> {
337    match name {
338        "recall-first" => {
339            let ns_hint = params
340                .get("arguments")
341                .and_then(|a| a.get("namespace"))
342                .and_then(|v| v.as_str())
343                .map(|ns| format!(" Scope recall to namespace \"{ns}\" when relevant."))
344                .unwrap_or_default();
345
346            Ok(json!({
347                "messages": [{
348                    "role": "user",
349                    "content": {
350                        "type": "text",
351                        "text": format!(
352            "You have access to a persistent memory system (ai-memory). Follow these rules:\n\
353            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\
354            2. STORE LEARNINGS: When the user corrects you or teaches something, call memory_store with tier:long, priority:9.\n\
355            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\
356            4. TIERS: short=6h ephemeral, mid=7d working knowledge, long=permanent. Mid auto-promotes to long at 5 accesses.\n\
357            5. DEDUP: Storing with an existing title+namespace updates the existing memory, not a duplicate.\n\
358            6. NAMESPACES: Organize by project/topic. Always pass namespace when storing and recalling.\n\
359            7. CAPABILITIES: Call memory_capabilities once per session to discover available features (tier-dependent).\n\
360            8. TAGS: Use tags for cross-cutting concerns. memory_auto_tag can generate them if available.{ns_hint}")
361                    }
362                }]
363            }))
364        }
365        "memory-workflow" => Ok(json!({
366            "messages": [{
367                "role": "user",
368                "content": {
369                    "type": "text",
370                    "text": "\
371        STORE: memory_store(title, content, tier, namespace, tags, priority) — dedup by title+ns\n\
372        RECALL: memory_recall(context, namespace) → ranked results (TOON compact default)\n\
373        SEARCH: memory_search(query, namespace) → exact AND match (TOON compact default)\n\
374        LIST: memory_list(namespace, tier) → browse with filters (TOON compact default)\n\
375        GET: memory_get(id) → single memory with links\n\
376        PROMOTE: memory_promote(id) — mid→long, clears expiry\n\
377        CONSOLIDATE: memory_consolidate(ids, title) — merge N→1, LLM summary if available\n\
378        LINK: memory_link(source_id, target_id, relation) — related_to|supersedes|contradicts|derived_from|reflects_on\n\
379        TAG: memory_auto_tag(id) — LLM generates tags (smart+ tier)\n\
380        EXPAND: memory_expand_query(query) — LLM broadens search terms (smart+ tier)\n\
381        CONTRADICT: memory_detect_contradiction(id_a, id_b) — LLM checks conflict (smart+ tier)"
382                }
383            }]
384        })),
385        _ => Err(format!("unknown prompt: {name}")),
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Submodule declarations — tool handler files
391// ---------------------------------------------------------------------------
392// Each MCP handler (or small cluster of related handlers) lives in its own
393// file under src/mcp/tools/. We reference them with #[path] so they are
394// direct children of the `mcp` module, which gives us clean `pub use`
395// re-exports without a visibility-chain headache.
396//
397// items marked pub(crate) in the orignal stay pub(crate);
398// items marked pub stay pub;
399// private helpers stay private within their file.
400
401// Registry is already declared above (pub(super) mod registry;).
402// tools/ directory: each file = one tool module under mcp.
403
404#[path = "tools/agent.rs"]
405mod agent;
406#[path = "tools/archive.rs"]
407mod archive;
408#[path = "tools/auto_tag.rs"]
409mod auto_tag;
410#[path = "tools/capabilities.rs"]
411mod capabilities;
412// v0.7.0 #1389 L4 — host-volunteered turn capture per RFC-0001
413// (`docs/rfc/RFC-0001-mcp-turn-capture.md`). Substrate-side handler
414// for the protocol-level fix that closes the #1388 substrate failure
415// mode without coupling to host-internal transcript formats.
416#[path = "tools/capture_turn.rs"]
417mod capture_turn;
418#[path = "tools/check_duplicate.rs"]
419mod check_duplicate;
420#[path = "tools/consolidate.rs"]
421mod consolidate;
422// v0.7.0 WT-1-C — curator-pass atomisation tool (memory_atomise).
423#[path = "tools/atomise.rs"]
424mod atomise;
425// v0.7.0 Form 3 (issue #756) — multi-step ingest orchestrator tool.
426// Surfaces the [`crate::multistep_ingest`] subsystem at Family::Power.
427#[path = "tools/delete.rs"]
428mod delete;
429#[path = "tools/detect_contradiction.rs"]
430mod detect_contradiction;
431#[path = "tools/entity_get_by_alias.rs"]
432mod entity_get_by_alias;
433#[path = "tools/entity_register.rs"]
434mod entity_register;
435#[path = "tools/expand_query.rs"]
436mod expand_query;
437#[path = "tools/find_paths.rs"]
438mod find_paths;
439#[path = "tools/forget.rs"]
440mod forget;
441#[path = "tools/get.rs"]
442mod get;
443#[path = "tools/get_taxonomy.rs"]
444mod get_taxonomy;
445#[path = "tools/ingest_multistep.rs"]
446mod ingest_multistep;
447#[path = "tools/kg_invalidate.rs"]
448mod kg_invalidate;
449#[path = "tools/kg_query.rs"]
450mod kg_query;
451#[path = "tools/kg_timeline.rs"]
452mod kg_timeline;
453#[path = "tools/link.rs"]
454mod link;
455#[path = "tools/list.rs"]
456mod list;
457#[path = "tools/load_family.rs"]
458mod load_family;
459#[path = "tools/namespace.rs"]
460mod namespace;
461#[path = "tools/notify.rs"]
462mod notify;
463// v0.7.0 QW-3 follow-up — context-offload substrate primitive
464// (memory_offload + memory_deref). Family::Power registration; handlers
465// live at src/mcp/tools/offload.rs.
466#[path = "tools/offload.rs"]
467mod offload;
468#[path = "tools/pending.rs"]
469mod pending;
470#[path = "tools/promote.rs"]
471mod promote;
472#[path = "tools/quota_status.rs"]
473mod quota_status;
474// v0.7.0 (issue #691) — substrate-level agent-action rules engine.
475#[path = "tools/check_agent_action.rs"]
476mod check_agent_action;
477#[path = "tools/recall.rs"]
478mod recall;
479// v0.7.0 Provenance Gap 3 (#886) — recall-consumption observation tier.
480#[path = "tools/recall_observations.rs"]
481mod recall_observations;
482#[path = "tools/reflect.rs"]
483mod reflect;
484#[path = "tools/reflection_origin.rs"]
485mod reflection_origin;
486// v0.7.0 QW-1 — file-backed reflection chain export.
487#[path = "tools/export_reflection.rs"]
488mod export_reflection;
489// v0.7.0 QW-2 — Persona-as-artifact substrate handlers.
490#[path = "tools/persona.rs"]
491mod persona;
492// v0.7.0 Form 5 (issue #758) — calibration sweep over the shadow-mode
493// observation table. Family::Power operator surface.
494#[path = "tools/calibrate_confidence.rs"]
495mod calibrate_confidence;
496// v0.7.0 L2-3 (issue #668) — Reflection invalidation propagation.
497#[path = "tools/dependents_of_invalidated.rs"]
498mod dependents_of_invalidated;
499#[path = "tools/replay.rs"]
500mod replay;
501#[path = "tools/rule_list.rs"]
502mod rule_list;
503#[path = "tools/search.rs"]
504mod search;
505#[path = "tools/session_start.rs"]
506mod session_start;
507// v0.7.0 issues #224 + #311 — `memory_share` tool (Family::Power).
508// Module declaration restored by D1.6 (#987) so the `McpTool` impl
509// in `tools/share.rs` compiles as part of the crate and
510// [`crate::mcp::registry::registered_tools`] can name it.
511//
512// v0.7.0 #1095 — made `pub` so the HTTP `handlers::share::share_memory`
513// handler (the SR-4 three-surface parity closeout) can call
514// `share::handle_share` against the shared substrate primitive.
515#[path = "tools/share.rs"]
516pub mod share;
517#[path = "tools/store/mod.rs"]
518mod store;
519#[path = "tools/subscribe.rs"]
520mod subscribe;
521#[path = "tools/update.rs"]
522mod update;
523#[path = "tools/verify.rs"]
524mod verify;
525// v0.7.0 L1-5 — Agent Skills ingestion substrate (Pillar 1.5).
526#[path = "tools/skill_export.rs"]
527mod skill_export;
528#[path = "tools/skill_get.rs"]
529mod skill_get;
530#[path = "tools/skill_list.rs"]
531mod skill_list;
532#[path = "tools/skill_register.rs"]
533mod skill_register;
534#[path = "tools/skill_resource.rs"]
535mod skill_resource;
536// v0.7.0 L2-6 (issue #671) — closing the recursive-learning loop:
537// reflections become skills become reusable knowledge.
538#[path = "tools/skill_promote.rs"]
539mod skill_promote;
540// v0.7.0 L2-7 (issue #672) — reflection-skill composition declaration.
541#[path = "tools/skill_compositional_context.rs"]
542mod skill_compositional_context;
543// v0.7.0 #972 D1.4 (#985) — shared test helpers for the per-tool
544// schema-parity tests added under D1.4. Reuses the allowed-diffs
545// catalog documented in d1_2_983_tests.
546#[cfg(test)]
547#[path = "tools/d1_4_985_helpers.rs"]
548pub(crate) mod d1_4_985_helpers;
549
550// ---------------------------------------------------------------------------
551// Re-exports — preserve exact `crate::mcp::*` pub surface (zero new pub items)
552// ---------------------------------------------------------------------------
553// These items were `pub` in the original mcp.rs and are accessed by
554// handlers.rs / sizes.rs / main.rs / integration tests via `crate::mcp::*`.
555pub use capabilities::{
556    CapabilitiesAccept, build_agent_permitted_families, build_capabilities_describe_to_user,
557    build_capabilities_summary, build_capabilities_tools, effective_tier_label,
558    format_rule_summary, handle_capabilities_with_conn, handle_capabilities_with_conn_v3,
559    overlay_tool_payloads,
560};
561pub use find_paths::handle_find_paths;
562// v0.7.0 ARCH-3 / FX-12 — CLI parity exports for handlers previously
563// private to the MCP module (`pub(super)`). The CLI subcommands under
564// `src/cli/commands/kg_query.rs` / `src/cli/commands/check_duplicate.rs`
565// dispatch into the same substrate primitives the MCP tools consume,
566// guaranteeing wire envelope parity across the three surfaces.
567pub use check_duplicate::handle_check_duplicate;
568// v0.7.0 #1443 — CLI parity export for `ai-memory expand`. The
569// subcommand under `src/cli/commands/expand.rs` dispatches into this
570// same substrate primitive the MCP `memory_expand_query` tool consumes,
571// guaranteeing the expanded-terms set is byte-equal across the three
572// surfaces.
573pub use expand_query::handle_expand_query;
574pub use kg_query::handle_kg_query;
575pub use load_family::{handle_load_family, handle_smart_load};
576pub(crate) use namespace::handle_namespace_clear_standard;
577// v0.7.0 G-PHASE-E-2 (#707) — promoted to `pub` so the integration
578// regression at `tests/g_phase_e_2_namespace_set_standard_governance_passthrough.rs`
579// can exercise the merge path directly. The handler is still routed
580// through the MCP dispatch above; the `pub` re-export is purely so
581// external test harnesses can pin the substrate behaviour without
582// going through stdio JSON-RPC.
583//
584// v0.7.0 #1326 — `handle_namespace_get_standard` promoted to `pub`
585// on the same rationale so the get-side governance pass-through
586// regression at `tests/issue_1326_*.rs` can pin the surface without
587// stdio JSON-RPC scaffolding.
588pub use namespace::{handle_namespace_get_standard, handle_namespace_set_standard};
589pub use notify::{handle_inbox, handle_notify};
590pub use pending::{handle_pending_approve, handle_pending_reject};
591// v0.7.0 #1389 L4 — host-volunteered turn capture per RFC-0001.
592// #1416 — `prepare_capture_turn` + the request type are re-exported so
593// the HTTP `POST /api/v1/capture_turn` route reuses the exact same
594// validation + Memory/SignedEvent construction as the MCP tool.
595pub use capture_turn::{MemoryCaptureTurnRequest, handle_capture_turn};
596// `prepare_capture_turn` is `pub(crate)` — re-export at crate visibility so
597// the HTTP handler (`crate::handlers::capture_turn`) can reach it without
598// widening the MCP tool's surface to the public API.
599pub(crate) use capture_turn::prepare_capture_turn;
600pub use quota_status::handle_quota_status;
601// v0.7.0 (issue #691) — substrate-level agent-action rules engine.
602pub use check_agent_action::handle_check_agent_action;
603pub use recall::handle_recall;
604pub use recall::handle_recall_caller;
605pub use recall::handle_recall_with_pre_recall_hook;
606// v0.7.x #1155 / FX-4 PERF-2 (2026-05-26) — batched front-end
607// consumed by the HTTP recall handler when releasing the DB mutex
608// around the per-row `latest_link_attest_level` lookup. One IN(...)
609// SQL emit replaces N round-trips, so the re-acquired lock window
610// on the verbose-provenance branch shrinks from O(N) to O(1) DB
611// round-trips. The legacy per-row `decorate_memory` is still
612// consumed inside the MCP module by `handle_recall_dto`; the HTTP
613// surface routes through `decorate_memory_many` instead.
614//
615// Exposed as `pub` (not `pub(crate)`) so the FX-4 regression suite
616// at `tests/recall_no_lock_across_hnsw.rs` can pin the wire-shape
617// parity between this batched front-end and the legacy per-row
618// `decorate_memory`. The internal `latest_link_attest_level_many`
619// helper stays `pub(crate)` because the test only needs to verify
620// the composed behaviour, not the raw lookup map.
621pub use recall::decorate_memory_many;
622// v0.7.0 Provenance Gap 3 (#886) — recall-consumption observation tier.
623// `handle_recall_observations` lives in `src/mcp/tools/recall_observations.rs`
624// (sibling-agent landing); the function is dispatched via
625// `dispatch_memory_recall_observations` below.
626pub use recall_observations::handle_recall_observations;
627// Consumed only by the postgres SAL HTTP branch in `route_1111` (sal-gated).
628#[cfg(feature = "sal")]
629pub(crate) use recall_observations::{
630    DEFAULT_LIMIT as RECALL_OBS_DEFAULT_LIMIT, MAX_LIMIT as RECALL_OBS_MAX_LIMIT,
631};
632pub use replay::handle_replay;
633pub use rule_list::handle_rule_list;
634pub(crate) use session_start::handle_session_start;
635pub use subscribe::handle_unsubscribe;
636// v0.7.0 ARCH-3 / FX-C3 (#batch2) — CLI parity exports for the
637// subscribe family + entity family + kg admin family + multistep
638// ingest + reflect/dependents/origin/quota observers. Promoted from
639// pub(super)/pub(crate) so the new CLI subcommands under
640// `src/cli/commands/*.rs` can dispatch into the same substrate
641// primitives the MCP tools consume.
642pub use entity_get_by_alias::handle_entity_get_by_alias;
643pub use entity_register::handle_entity_register;
644pub use ingest_multistep::{IngestMultistepHandler, handle_ingest_multistep};
645pub use kg_invalidate::handle_kg_invalidate;
646pub use kg_timeline::handle_kg_timeline;
647pub use subscribe::{handle_list_subscriptions, handle_subscribe};
648pub use verify::handle_verify;
649// v0.7.0 L1-5 / L2-6 — test-and-integration access to the skill
650// substrate handlers. These are public so the L2-6 regression suite
651// (`tests/skill_promote_test.rs`) can drive the full promote → export
652// → re-register round-trip without needing the stdio JSON-RPC layer.
653//
654// v0.7.0 Cluster E API-2 (issue #767) — extended the public re-export
655// set so the new CLI subcommands under `src/cli/commands/skill.rs`
656// and the HTTP routes under `src/handlers/http.rs` can dispatch into
657// the same substrate without re-implementing business logic. CLI/HTTP
658// parity with the seven MCP `memory_skill_*` tools is the contract.
659pub use skill_compositional_context::handle_skill_compositional_context;
660pub use skill_export::handle_skill_export;
661pub use skill_get::handle_skill_get;
662pub use skill_list::handle_skill_list;
663pub use skill_promote::handle_skill_promote_from_reflection;
664pub use skill_register::handle_skill_register;
665pub use skill_resource::handle_skill_resource;
666
667// v0.7.0 #1111 — public re-exports for the HTTP-route closeout. The
668// six MCP handlers below were `pub(super)` so external callers couldn't
669// reach them; the HTTP routes in src/handlers/<name>.rs need a stable
670// path. Wire shape is preserved verbatim — the HTTP handlers are thin
671// wrappers around these. Pre-#1111 the HTTP routes were missing
672// entirely (SR-4 three-surface-parity audit gap).
673pub use calibrate_confidence::handle_calibrate_confidence;
674pub use dependents_of_invalidated::handle_dependents_of_invalidated;
675pub use export_reflection::handle_export_reflection;
676pub use pending::handle_subscription_dlq_list;
677pub use reflect::handle_reflect;
678// Consumed only by the postgres SAL HTTP branch in `route_1111`, which is
679// `#[cfg(feature = "sal")]` — gate the re-export so a non-sal build does
680// not flag it unused.
681#[cfg(feature = "sal")]
682pub(crate) use reflect::{map_reflect_error_to_wire_string, parse_reflect_input};
683pub use reflection_origin::handle_reflection_origin;
684pub use subscribe::handle_subscription_replay;
685
686/// #913 (security-medium / SOC2, 2026-05-19) — test-only dispatcher
687/// into `handle_archive_purge`. The handler is `pub(super)` in the
688/// archive module so external regression tests cannot reach it
689/// directly. Mirrors `dispatch_handle_link_for_test`'s rationale.
690#[doc(hidden)]
691pub fn handle_archive_purge_for_test(
692    conn: &rusqlite::Connection,
693    params: &serde_json::Value,
694) -> Result<serde_json::Value, String> {
695    archive::handle_archive_purge(conn, params)
696}
697
698/// v0.7.0 L2-3 (issue #668) — test-only dispatcher into
699/// `handle_link`. Re-exports the handler at a stable
700/// `ai_memory::mcp::dispatch_handle_link_for_test` path so the
701/// `tests/notification.rs` integration test can drive the supersedes
702/// path end-to-end without re-creating the JSON-RPC wire layer. Not
703/// part of the production wire surface — the production call site
704/// is the JSON-RPC dispatch in `handle_request`.
705#[doc(hidden)]
706pub fn dispatch_handle_link_for_test(
707    conn: &rusqlite::Connection,
708    db_path: &std::path::Path,
709    params: &serde_json::Value,
710    active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
711) -> Result<serde_json::Value, String> {
712    link::handle_link(conn, db_path, params, active_keypair)
713}
714
715/// v0.7.0 L2-3 (issue #668) — test-only dispatcher into
716/// `handle_dependents_of_invalidated`. Mirrors
717/// `dispatch_handle_link_for_test`'s rationale.
718#[doc(hidden)]
719pub fn dispatch_handle_dependents_for_test(
720    conn: &rusqlite::Connection,
721    params: &serde_json::Value,
722) -> Result<serde_json::Value, String> {
723    dependents_of_invalidated::handle_dependents_of_invalidated(conn, params)
724}
725
726/// v0.7.0 (issue #691) — accessor for the stable
727/// `governance.not_available_over_mcp` error string. Consumed by
728/// `tests/governance_immutability.rs` to pin the wire vocabulary
729/// across versions. A future PR that wires the mutation refusal
730/// dispatch can re-use this constant directly rather than copy-
731/// pasting the message.
732#[must_use]
733pub fn tools_check_agent_action_mutation_disabled_error() -> &'static str {
734    check_agent_action::MCP_MUTATION_DISABLED_ERROR
735}
736
737/// v0.7.0 #972 D1.7 (#988) — test-only re-export bundle for the
738/// per-tool `<Tool>Request` request structs that back the schemars-
739/// derived `inputSchema` published in [`registry::tool_definitions`].
740/// Consumed by `tests/mcp_schema_handler_parity.rs` to pin the
741/// compile-time schema↔handler invariant: every property advertised
742/// on the wire MUST round-trip into the `<Tool>Request` struct via
743/// `serde_json::from_value`. The bundle is `#[doc(hidden)]` so it
744/// stays out of the rustdoc surface; production wire paths still
745/// resolve through the `McpTool::input_schema()` trait method.
746#[doc(hidden)]
747pub mod schema_handler_parity_test_exports {
748    pub use super::capabilities::CapabilitiesRequest;
749    pub use super::link::LinkRequest;
750    pub use super::pending::PendingApproveRequest;
751    pub use super::store::StoreRequest;
752    pub use crate::models::recall_request::RecallRequest;
753}
754
755/// v0.7.0 WT-1-C — test-only re-export bundle for the
756/// `memory_atomise` MCP handler. Mirrors
757/// [`dispatch_handle_link_for_test`]'s rationale: the integration
758/// suite at `tests/wt1c_mcp_atomise.rs` drives the handler directly
759/// without spinning up the stdio loop, so the handler symbol and
760/// the handler bundle struct need a stable `ai_memory::mcp::tools::`
761/// path. The production wire path remains the JSON-RPC dispatch in
762/// `handle_request`.
763pub mod tools {
764    pub use super::atomise::{AtomiseToolHandler, handle_atomise};
765
766    /// v0.7.0 multi-agent literal-sweep (scanner B finding F-B3.x) —
767    /// re-export the canonical on-conflict enum so external consumers
768    /// (HTTP handler at `src/handlers/create.rs`) can route through
769    /// `OnConflictMode::parse` as a single SSOT instead of carrying
770    /// their own inline string-allowlist match for the closed set
771    /// `error | merge | version`.
772    pub use super::store::OnConflictMode;
773
774    // v0.7.0 #1325 — re-export the canonical `tool_examples`
775    // catalog so the regression test at
776    // `tests/issue_1325_reflect_caller_depth.rs` and
777    // `tests/issue_1327_skill_register_docstring_example.rs` can
778    // pin the docstring-example invariants without going through
779    // the full `memory_capabilities` envelope.
780    pub mod capabilities {
781        pub use super::super::capabilities::tool_examples;
782    }
783
784    // v0.7.0 #1327 — re-export the canonical SkillRegisterRequest
785    // struct + handler so the regression test at
786    // `tests/issue_1327_skill_register_docstring_example.rs` can
787    // parse the docstring example through the actual parser shape.
788    pub mod skill_register {
789        pub use super::super::skill_register::{SkillRegisterRequest, handle_skill_register};
790    }
791
792    // v0.7.0 Form 3 (issue #756) — multi-step ingest orchestrator
793    // handler + bundle. Integration test at
794    // `tests/form_3_multistep_ingest.rs` drives the handler directly
795    // through this re-export.
796    pub use super::ingest_multistep::{IngestMultistepHandler, handle_ingest_multistep};
797
798    /// v0.7.0 issue #863 — re-export the substrate-shared check-action
799    /// helpers so the CLI subcommand `ai-memory governance check-action`
800    /// (`src/cli/governance_check_action.rs`) can reuse the exact same
801    /// rule-engine path as the MCP tool `memory_check_agent_action`.
802    /// DRY: there is one implementation of "evaluate an agent action
803    /// against the rules table"; both the MCP tool and the CLI verb
804    /// funnel into it.
805    pub mod check_agent_action {
806        pub use super::super::check_agent_action::{
807            DEFAULT_AGENT_ID, build_action, handle_check_agent_action, run_check,
808        };
809    }
810
811    // v0.7.0 COV-8 (Cluster D, issue #767) — re-export the
812    // `memory_kg_invalidate` substrate handler so the K9
813    // governance-gate regression test
814    // (`tests/k9_kg_invalidate_governance_gate.rs`) can drive it
815    // directly. The handler stays read-only from the perspective of
816    // external callers; the surface change is test-visibility only.
817    pub mod kg_invalidate {
818        pub use super::super::kg_invalidate::handle_kg_invalidate;
819    }
820
821    /// Issue #831 — re-export the `memory_promote` substrate handler so
822    /// the lifecycle regression test (`tests/lifecycle_ttl_and_promote.rs`)
823    /// can drive it directly without going through the stdio loop. Pins
824    /// both the default (jump-to-long) and the `target_tier=mid`
825    /// stepwise behaviour of the MCP tool.
826    #[doc(hidden)]
827    pub fn handle_promote_for_tests(
828        conn: &rusqlite::Connection,
829        db_path: &std::path::Path,
830        params: &serde_json::Value,
831        mcp_client: Option<&str>,
832    ) -> Result<serde_json::Value, String> {
833        super::promote::handle_promote(conn, db_path, params, mcp_client)
834    }
835
836    /// v0.7.x Form 1/2 acceptance tests need to drive the `memory_store`
837    /// MCP write path from an integration test crate. Thin pass-through
838    /// to the internal `handle_store` dispatch. Not part of the supported
839    /// public wire API — operators keep using MCP / HTTP / CLI.
840    #[doc(hidden)]
841    #[allow(clippy::too_many_arguments)]
842    pub fn handle_store_for_tests(
843        conn: &rusqlite::Connection,
844        db_path: &std::path::Path,
845        params: &serde_json::Value,
846        embedder: Option<&dyn crate::embeddings::Embed>,
847        llm: Option<&crate::llm::OllamaClient>,
848        vector_index: Option<&crate::hnsw::VectorIndex>,
849        resolved_ttl: &crate::config::ResolvedTtl,
850        autonomous_hooks: bool,
851        mcp_client: Option<&str>,
852        federation_forward_url: Option<&str>,
853    ) -> Result<serde_json::Value, String> {
854        super::store::handle_store(
855            conn,
856            db_path,
857            params,
858            embedder,
859            llm,
860            vector_index,
861            resolved_ttl,
862            autonomous_hooks,
863            mcp_client,
864            federation_forward_url,
865            // Issue #1239 — integration-test entry point: no keypair.
866            // Synthesis Update / Delete supersedes-edge emission falls
867            // back to `attest_level='unsigned'` per
868            // `create_link_signed`'s documented contract when keypair
869            // is None.
870            None,
871        )
872    }
873}
874
875// ---------------------------------------------------------------------------
876// Internal use — functions called from handle_request below.
877// Not part of the external public surface.
878// ---------------------------------------------------------------------------
879use agent::{handle_agent_list, handle_agent_register};
880use archive::{
881    handle_archive_list, handle_archive_purge, handle_archive_restore, handle_archive_stats,
882    handle_gc,
883};
884use auto_tag::handle_auto_tag;
885// v0.7.0 ARCH-3 / FX-12 — `handle_check_duplicate` is `pub use`-exported
886// above; dispatch resolves via the crate path.
887use consolidate::handle_consolidate;
888// v0.7.0 WT-1-C — `memory_atomise` MCP tool wiring.
889use atomise::handle_atomise;
890// v0.7.0 Form 5 (issue #758) — `memory_calibrate_confidence` MCP tool wiring.
891// Note: `handle_calibrate_confidence` is `pub use`-exported above for the
892// #1111 HTTP-route closeout (src/handlers/calibrate_confidence.rs needs
893// a stable path). Avoid the duplicate `use` here; the dispatch in
894// `handle_request` below resolves it through the crate path.
895use delete::handle_delete;
896// v0.7.0 #1111 — `handle_dependents_of_invalidated` is `pub use`-
897// exported above; dispatch resolves via the crate path.
898use detect_contradiction::handle_detect_contradiction;
899// v0.7.0 #1443 — `handle_expand_query` is `pub use`-exported above so
900// the `ai-memory expand` CLI subcommand can dispatch into it; dispatch
901// in this module resolves it via the crate path.
902// v0.7.0 #1111 — `handle_export_reflection` is `pub use`-exported above.
903use forget::{handle_forget, handle_stats};
904use get::handle_get;
905use get_taxonomy::handle_get_taxonomy;
906// v0.7.0 ARCH-3 / FX-C3 (#batch2) — `handle_ingest_multistep`,
907// `handle_kg_invalidate`, `handle_kg_timeline` are `pub use`-exported
908// above. Dispatch resolves via the crate path.
909// v0.7.0 ARCH-3 / FX-12 — `handle_kg_query` is `pub use`-exported above;
910// dispatch resolves via the crate path.
911use link::{handle_get_links, handle_link};
912use list::handle_list;
913use pending::handle_pending_list;
914// v0.7.0 #1111 — `handle_subscription_dlq_list` is `pub use`-exported above.
915use persona::{handle_persona, handle_persona_generate};
916// Issue #809 — re-export handle_persona_generate as a stable pub
917// symbol so the nhi-self-persona regression test
918// (tests/issue_809_nhi_self_persona_any_agent.rs) can drive the
919// persona generator directly without going through an MCP-stdio
920// JSON-RPC envelope. The wrapper name persona_generate_call mirrors
921// the pattern used by other v0.7.x integration tests that need
922// direct handler access.
923pub use persona::handle_persona_generate as persona_generate_call;
924use promote::handle_promote;
925// v0.7.0 #1111 — `handle_reflect` and `handle_reflection_origin` are
926// `pub use`-exported above for the HTTP-route closeout.
927use search::handle_search;
928// handle_skill_compositional_context is re-exported above via
929// `pub use skill_compositional_context::handle_skill_compositional_context`
930// so the dispatch arm in `handle_request` can resolve it through the
931// crate path without an additional `use` here.
932
933/// v0.7.0 L2-7 (issue #672) — integration-test entry point for
934/// `memory_skill_compositional_context`. Hides the internal
935/// `pub(super)` handler symbol from integration tests while still
936/// keeping the production dispatch identical (no second copy of the
937/// routing logic, no jsonrpc envelope construction for callers that
938/// only want the tool's response). Other handlers cross the
939/// integration-test boundary via direct SQL fixtures and the
940/// capabilities harness — composing skills need the handler itself, so
941/// this shim mirrors the dispatch arm used in `handle_request`.
942///
943/// Returns the handler's `Result<Value, String>` as-is so test code can
944/// assert on both success and error shapes without having to peel a
945/// `serde_json::Value` envelope.
946///
947/// # Errors
948///
949/// Forwards the handler's error string verbatim (the only failure mode
950/// is the handler itself returning `Err` — e.g. for an unknown
951/// `skill_id`).
952#[doc(hidden)]
953pub fn skill_compositional_context_for_tests(
954    conn: &rusqlite::Connection,
955    params: &serde_json::Value,
956) -> Result<serde_json::Value, String> {
957    handle_skill_compositional_context(conn, params)
958}
959// handle_skill_export, handle_skill_promote_from_reflection,
960// handle_skill_register, handle_skill_get, handle_skill_list, and
961// handle_skill_resource are all imported via the `pub use` block above
962// (v0.7.0 Cluster E API-2 — issue #767, CLI/HTTP/MCP parity) so the
963// L1-5 / L2-6 regression suites and the CLI/HTTP surfaces can drive
964// them directly without going through the stdio JSON-RPC layer.
965use store::handle_store;
966// v0.7.0 #1111 — `handle_subscription_replay` is `pub use`-exported above.
967// v0.7.0 ARCH-3 / FX-C3 (#batch2) — `handle_subscribe` and
968// `handle_list_subscriptions` are also `pub use`-exported above; the
969// dispatch arms below resolve them via the crate path.
970use update::handle_update;
971
972// ---------------------------------------------------------------------------
973// Test-visible re-exports of private helpers that lived in the original
974// mcp.rs and are referenced by `super::X` in the test module below.
975// These items preserve their original single-module proximity without
976// leaking into the public crate surface.
977// ---------------------------------------------------------------------------
978#[cfg(test)]
979use agent::messages_namespace_for;
980#[cfg(test)]
981use namespace::{auto_register_path_hierarchy, extract_governance};
982#[cfg(test)]
983use replay::REPLAY_VERBOSE_THRESHOLD_BYTES;
984
985// ---------------------------------------------------------------------------
986// Shared helper functions — called from multiple tool modules via super::*.
987// ---------------------------------------------------------------------------
988
989fn build_namespace_chain(conn: &rusqlite::Connection, namespace: &str) -> Vec<String> {
990    db::build_namespace_chain(conn, namespace)
991}
992
993/// Inject namespace standards into a `recall/session_start` response.
994/// N-level rule layering: global ("*") → root → ... → namespace-specific.
995/// Uses [`build_namespace_chain`] to resolve the full ancestor path.
996fn inject_namespace_standard(
997    conn: &rusqlite::Connection,
998    namespace: Option<&str>,
999    response: &mut Value,
1000) {
1001    let mut standards: Vec<Value> = Vec::new();
1002    let mut standard_ids: Vec<String> = Vec::new();
1003
1004    // Helper: add a standard if not already present (dedup by memory ID)
1005    let add_standard = |std: Value, ids: &mut Vec<String>, stds: &mut Vec<Value>| {
1006        let id = std["id"].as_str().unwrap_or_default().to_string();
1007        if !ids.contains(&id) {
1008            ids.push(id);
1009            stds.push(std);
1010        }
1011    };
1012
1013    let chain = if let Some(ns) = namespace {
1014        build_namespace_chain(conn, ns)
1015    } else {
1016        // No namespace context — only the global standard applies.
1017        vec!["*".to_string()]
1018    };
1019
1020    for link in chain {
1021        if let Some(std) = lookup_namespace_standard(conn, &link) {
1022            add_standard(std, &mut standard_ids, &mut standards);
1023        }
1024    }
1025
1026    if standards.is_empty() {
1027        return;
1028    }
1029
1030    // Deduplicate: remove standard memories from results array
1031    if let Some(memories) = response["memories"].as_array_mut() {
1032        memories.retain(|m| {
1033            let mid = m["id"].as_str().unwrap_or_default();
1034            !standard_ids.iter().any(|sid| sid == mid)
1035        });
1036        response["count"] = json!(memories.len());
1037    }
1038
1039    // Return as single object if one standard, array if multiple
1040    if standards.len() == 1 {
1041        response["standard"] = standards.into_iter().next().unwrap();
1042    } else {
1043        response["standards"] = json!(standards);
1044    }
1045}
1046
1047/// G10 — recall hot-path wrapper that fires the
1048/// [`crate::hooks::HookEvent::PreRecallExpand`] chain before
1049/// delegating to [`handle_recall`].
1050///
1051/// The wrapper is the canonical fire site for `pre_recall_expand`:
1052/// the chain runs inside the v0.6.3 50ms recall budget (G6's
1053/// `EventClass::HotPath` deadline) and may rewrite the query /
1054/// namespace / k or short-circuit the recall via `Deny`. On `Deny`
1055/// the wrapper returns an empty `memories` array with a
1056/// `meta.diagnostic.pre_recall_denied` block so callers can see
1057/// *why* the recall was suppressed without parsing logs.
1058///
1059/// `handle_recall` itself stays sync; this wrapper is async only
1060/// because it awaits the daemon-mode chain `fire`. Existing
1061/// callers that don't have a hooks runtime can keep calling
1062/// `handle_recall` directly — this wrapper is opt-in.
1063#[allow(clippy::too_many_arguments)]
1064
1065/// Look up the namespace standard and return it as a serialized Memory, or None.
1066
1067fn lookup_namespace_standard(conn: &rusqlite::Connection, namespace: &str) -> Option<Value> {
1068    let standard_id = db::get_namespace_standard(conn, namespace).ok()??;
1069    let mem = db::get(conn, &standard_id).ok()??;
1070    serde_json::to_value(&mem).ok()
1071}
1072
1073// ---------------------------------------------------------------------------
1074// #867 — `tools/call` dispatch as a registry table.
1075//
1076// The legacy `handle_request` carried a per-tool `match tool_name { ... }`
1077// block that grew linearly with every new MCP tool (each new tool meant
1078// a central-file edit). The dispatch surface is now driven by
1079// [`TOOL_DISPATCH_TABLE`], a `&'static [(&str, DispatchFn)]` registry
1080// keyed by tool name. The legacy match is gone; new tools land by
1081// adding a thin `dispatch_<tool>` wrapper next to their handler module
1082// and registering it through [`register_mcp_tool!`].
1083//
1084// All wrappers share the same shape:
1085//
1086// ```ignore
1087// fn dispatch_foo(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1088//     foo_module::handle_foo(ctx.conn, ctx.arguments, ...)
1089// }
1090// ```
1091//
1092// Behaviour is byte-for-byte identical to the pre-refactor code path:
1093// the same handler functions run in the same order with the same
1094// arguments. The wrappers only un-bundle the `ToolDispatchCtx` back
1095// into the positional arguments each handler expects.
1096//
1097// `O(N)` lookup is fine — N is ~70 and the table is iterated once per
1098// `tools/call` dispatch (every ~50ms recall budget); the `&str`
1099// comparison is a no-alloc memcmp. A `phf_map!` / `HashMap<&'static
1100// str, _>` would be a micro-optimisation and is the obvious next
1101// step if the table grows past several hundred entries.
1102// ---------------------------------------------------------------------------
1103
1104/// Bundle of all per-request inputs every MCP tool dispatch fn might
1105/// need. Centralising these lets every entry in [`TOOL_DISPATCH_TABLE`]
1106/// share the same signature: `fn(&ToolDispatchCtx<'_>) ->
1107/// Result<Value, String>`.
1108///
1109/// Not all wrappers consume every field — `memory_search` only uses
1110/// `conn` + `arguments`; `memory_store` uses most of them. The unused
1111/// references are zero-cost so collapsing the signature into a single
1112/// `&ctx` form is the right trade-off for a registry table.
1113pub(crate) struct ToolDispatchCtx<'a> {
1114    pub conn: &'a rusqlite::Connection,
1115    pub db_path: &'a Path,
1116    pub arguments: &'a Value,
1117    pub embedder: Option<&'a dyn Embed>,
1118    pub llm: Option<&'a OllamaClient>,
1119    pub reranker: Option<&'a BatchedReranker>,
1120    pub tier_config: &'a TierConfig,
1121    /// v0.7.x (issue #1168) — operator-resolved LLM / embeddings /
1122    /// reranker triple. Threaded through `dispatch_memory_capabilities`
1123    /// so `memory_capabilities.models.*` reflects the live operator
1124    /// configuration (matching the boot banner + the actual LLM
1125    /// client the daemon was built from), NOT the compiled tier
1126    /// preset. Built once at MCP serve startup via
1127    /// `AppConfig::resolve_models()` and reused for every dispatch.
1128    pub resolved_models: &'a ResolvedModels,
1129    pub vector_index: Option<&'a VectorIndex>,
1130    pub resolved_ttl: &'a crate::config::ResolvedTtl,
1131    pub resolved_scoring: &'a crate::config::ResolvedScoring,
1132    pub archive_on_gc: bool,
1133    pub autonomous_hooks: bool,
1134    pub mcp_client: Option<&'a str>,
1135    pub profile: &'a crate::profile::Profile,
1136    pub mcp_config: Option<&'a crate::config::McpConfig>,
1137    pub active_keypair: Option<&'a crate::identity::keypair::AgentKeypair>,
1138    pub harness: Option<&'a crate::harness::Harness>,
1139    pub federation_forward_url: Option<&'a str>,
1140    pub recall_scope: Option<&'a crate::config::RecallScope>,
1141    pub atomise_handler: Option<&'a atomise::AtomiseToolHandler>,
1142    pub ingest_multistep_handler: Option<&'a ingest_multistep::IngestMultistepHandler>,
1143}
1144
1145/// Uniform signature for every entry in [`TOOL_DISPATCH_TABLE`]. Each
1146/// tool gets a thin wrapper that un-bundles a [`ToolDispatchCtx`] back
1147/// into the positional arguments its underlying handler expects.
1148pub(crate) type DispatchFn = fn(&ToolDispatchCtx<'_>) -> Result<Value, String>;
1149
1150/// Registry-registration macro. Today this expands to a plain
1151/// `(name, fn)` tuple suitable for the `TOOL_DISPATCH_TABLE` array
1152/// literal, but the indirection lets future refactors swap to
1153/// `inventory::submit!` (cross-module collect) without touching every
1154/// call site.
1155///
1156/// `$name` accepts any `&'static str` expression: a string literal
1157/// OR a `pub const NAME: &str` from
1158/// [`crate::mcp::registry::tool_names`] (issue #1174 PR1 — pm-v3.1
1159/// MCP tool name sweep). Per-tool registrations should reference the
1160/// `tool_names` const so the dispatch table stays in sync with the
1161/// canonical tool-name table.
1162///
1163/// ```text
1164/// register_mcp_tool!(tool_names::MEMORY_SEARCH, dispatch_memory_search)
1165/// ```
1166macro_rules! register_mcp_tool {
1167    ($name:expr, $f:path) => {
1168        ($name, $f as DispatchFn)
1169    };
1170}
1171
1172// --- per-tool dispatch wrappers --------------------------------------------
1173//
1174// Each wrapper is named `dispatch_<tool>` and forwards to the
1175// underlying `handle_<tool>` (or equivalent) with the exact arguments
1176// the pre-refactor match arm passed. Keep the wrappers minimal — any
1177// logic that belongs at dispatch time (agent_id resolution for
1178// `memory_offload`/`memory_deref`, the capabilities `family` branch,
1179// etc.) lives here in this section so the underlying handler stays
1180// free of dispatch-shape concerns.
1181
1182fn dispatch_memory_store(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1183    handle_store(
1184        ctx.conn,
1185        ctx.db_path,
1186        ctx.arguments,
1187        ctx.embedder,
1188        ctx.llm,
1189        ctx.vector_index,
1190        ctx.resolved_ttl,
1191        ctx.autonomous_hooks,
1192        ctx.mcp_client,
1193        ctx.federation_forward_url,
1194        // Issue #1239 — thread the active daemon keypair so the
1195        // synthesis Update / Delete supersedes-edge emission lands as
1196        // `self_signed` (matching the legacy supersede path through
1197        // `update_with_archive_on_supersede`).
1198        ctx.active_keypair,
1199    )
1200}
1201
1202fn dispatch_memory_recall(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1203    // v0.7.0 #1468 — resolve the read-path visibility caller from the
1204    // stable `AI_MEMORY_AGENT_ID` env (or None) so cross-agent
1205    // `scope=private` rows are dropped before serialization.
1206    let caller = crate::identity::resolve_read_visibility_caller();
1207    handle_recall_caller(
1208        ctx.conn,
1209        ctx.arguments,
1210        ctx.embedder,
1211        ctx.vector_index,
1212        ctx.reranker,
1213        ctx.archive_on_gc,
1214        ctx.resolved_ttl,
1215        ctx.resolved_scoring,
1216        ctx.recall_scope,
1217        caller.as_deref(),
1218    )
1219}
1220
1221/// v0.7.0 Gap 3 (#886) — read-side dispatch for the
1222/// `memory_recall_observations` tool.
1223fn dispatch_memory_recall_observations(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1224    handle_recall_observations(ctx.conn, ctx.arguments)
1225}
1226
1227fn dispatch_memory_search(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1228    // v0.7.0 #1468 — see `dispatch_memory_recall`.
1229    let caller = crate::identity::resolve_read_visibility_caller();
1230    handle_search(ctx.conn, ctx.arguments, caller.as_deref())
1231}
1232
1233fn dispatch_memory_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1234    // v0.7.0 #1468 — see `dispatch_memory_recall`.
1235    let caller = crate::identity::resolve_read_visibility_caller();
1236    handle_list(ctx.conn, ctx.arguments, caller.as_deref())
1237}
1238
1239fn dispatch_memory_load_family(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1240    // v0.7.0 #1555 — scope=private visibility gate, parity with recall/list/search.
1241    let caller = crate::identity::resolve_read_visibility_caller();
1242    handle_load_family(ctx.conn, ctx.arguments, caller.as_deref())
1243}
1244
1245fn dispatch_memory_smart_load(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1246    // v0.7.0 #1555 — the always-on intent loader forwards to load_family; gate it too.
1247    let caller = crate::identity::resolve_read_visibility_caller();
1248    handle_smart_load(ctx.conn, ctx.arguments, ctx.embedder, caller.as_deref())
1249}
1250
1251fn dispatch_memory_get_taxonomy(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1252    handle_get_taxonomy(ctx.conn, ctx.arguments)
1253}
1254
1255fn dispatch_memory_check_duplicate(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1256    handle_check_duplicate(ctx.conn, ctx.arguments, ctx.embedder)
1257}
1258
1259fn dispatch_memory_entity_register(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1260    handle_entity_register(ctx.conn, ctx.arguments, ctx.mcp_client)
1261}
1262
1263fn dispatch_memory_entity_get_by_alias(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1264    handle_entity_get_by_alias(ctx.conn, ctx.arguments)
1265}
1266
1267fn dispatch_memory_kg_timeline(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1268    handle_kg_timeline(ctx.conn, ctx.arguments)
1269}
1270
1271fn dispatch_memory_kg_invalidate(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1272    handle_kg_invalidate(ctx.conn, ctx.db_path, ctx.arguments)
1273}
1274
1275fn dispatch_memory_kg_query(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1276    handle_kg_query(ctx.conn, ctx.arguments)
1277}
1278
1279fn dispatch_memory_find_paths(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1280    handle_find_paths(ctx.conn, ctx.arguments)
1281}
1282
1283fn dispatch_memory_delete(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1284    handle_delete(
1285        ctx.conn,
1286        ctx.db_path,
1287        ctx.arguments,
1288        ctx.vector_index,
1289        ctx.mcp_client,
1290    )
1291}
1292
1293fn dispatch_memory_promote(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1294    handle_promote(ctx.conn, ctx.db_path, ctx.arguments, ctx.mcp_client)
1295}
1296
1297fn dispatch_memory_pending_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1298    handle_pending_list(ctx.conn, ctx.arguments)
1299}
1300
1301fn dispatch_memory_pending_approve(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1302    handle_pending_approve(ctx.conn, ctx.arguments, ctx.mcp_client)
1303}
1304
1305fn dispatch_memory_pending_reject(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1306    handle_pending_reject(ctx.conn, ctx.arguments, ctx.mcp_client)
1307}
1308
1309fn dispatch_memory_forget(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1310    handle_forget(ctx.conn, ctx.arguments, ctx.archive_on_gc)
1311}
1312
1313fn dispatch_memory_stats(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1314    handle_stats(ctx.conn, ctx.db_path)
1315}
1316
1317fn dispatch_memory_update(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1318    handle_update(
1319        ctx.conn,
1320        ctx.arguments,
1321        ctx.embedder,
1322        ctx.vector_index,
1323        ctx.mcp_client,
1324    )
1325}
1326
1327fn dispatch_memory_get(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1328    // v0.7.0 #1553 — scope=private visibility gate, parity with recall/list/search.
1329    let caller = crate::identity::resolve_read_visibility_caller();
1330    handle_get(ctx.conn, ctx.arguments, caller.as_deref())
1331}
1332
1333fn dispatch_memory_link(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1334    handle_link(ctx.conn, ctx.db_path, ctx.arguments, ctx.active_keypair)
1335}
1336
1337fn dispatch_memory_get_links(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1338    // v0.7.0 #1553 — visibility gate so neighbor-id enumeration / existence
1339    // oracling of another tenant's scope=private row is blocked.
1340    let caller = crate::identity::resolve_read_visibility_caller();
1341    handle_get_links(ctx.conn, ctx.arguments, caller.as_deref())
1342}
1343
1344fn dispatch_memory_verify(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1345    handle_verify(ctx.conn, ctx.arguments)
1346}
1347
1348fn dispatch_memory_replay(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1349    // v0.7.0 #1571 — bind the replay visibility/permission identity to the
1350    // resolved caller so a spoofed `agent_id` param cannot widen visibility
1351    // (same class as #1553 get/get_links and #1557 inbox).
1352    let caller = crate::identity::resolve_read_visibility_caller();
1353    handle_replay(ctx.conn, ctx.arguments, ctx.mcp_client, caller.as_deref())
1354}
1355
1356fn dispatch_memory_consolidate(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1357    handle_consolidate(
1358        ctx.conn,
1359        ctx.db_path,
1360        ctx.arguments,
1361        ctx.llm,
1362        ctx.embedder,
1363        ctx.vector_index,
1364        ctx.mcp_client,
1365    )
1366}
1367
1368fn dispatch_memory_atomise(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1369    handle_atomise(
1370        ctx.conn,
1371        ctx.arguments,
1372        ctx.atomise_handler,
1373        ctx.tier_config.tier,
1374        ctx.mcp_client,
1375    )
1376}
1377
1378fn dispatch_memory_ingest_multistep(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1379    handle_ingest_multistep(
1380        ctx.arguments,
1381        ctx.ingest_multistep_handler,
1382        ctx.tier_config.tier,
1383    )
1384}
1385
1386fn dispatch_memory_reflect(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1387    handle_reflect(
1388        ctx.conn,
1389        ctx.db_path,
1390        ctx.arguments,
1391        ctx.embedder,
1392        ctx.vector_index,
1393        ctx.mcp_client,
1394        ctx.active_keypair,
1395    )
1396}
1397
1398/// `memory_capabilities` dispatch — branches on the optional `family`
1399/// argument. Pre-refactor this lived inline as ~165 LOC inside the
1400/// match arm; here it is unchanged behaviour, just routed through the
1401/// uniform `ToolDispatchCtx` shape.
1402fn dispatch_memory_capabilities(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1403    let arguments = ctx.arguments;
1404    if let Some(fam_name) = arguments.get("family").and_then(Value::as_str) {
1405        let include_schema = arguments
1406            .get("include_schema")
1407            .and_then(Value::as_bool)
1408            .unwrap_or(false);
1409        let verbose = arguments
1410            .get("verbose")
1411            .and_then(Value::as_bool)
1412            .unwrap_or(false);
1413        let aid = arguments
1414            .get("agent_id")
1415            .and_then(Value::as_str)
1416            .or(ctx.mcp_client);
1417        return handle_capabilities_family(
1418            fam_name,
1419            include_schema,
1420            verbose,
1421            ctx.profile,
1422            ctx.mcp_config,
1423            aid,
1424            Some(ctx.conn),
1425        );
1426    }
1427
1428    let accept = arguments
1429        .get("accept")
1430        .and_then(Value::as_str)
1431        .map_or(CapabilitiesAccept::V3, CapabilitiesAccept::parse);
1432    let top_verbose = arguments
1433        .get("verbose")
1434        .and_then(Value::as_bool)
1435        .unwrap_or(false);
1436    let top_include_schema = arguments
1437        .get("include_schema")
1438        .and_then(Value::as_bool)
1439        .unwrap_or(false);
1440    let v3_aid = arguments
1441        .get("agent_id")
1442        .and_then(Value::as_str)
1443        .or(ctx.mcp_client);
1444    let runtime_tier = effective_tier_label(
1445        ctx.llm.is_some(),
1446        ctx.embedder.is_some(),
1447        ctx.reranker.is_some(),
1448    );
1449    // #1594 / #1598 — `embedder_loaded` reports the LIVE posture: a
1450    // remote embedder whose most recent call failed (dead endpoint,
1451    // auth rejection) is degraded and must report `false` so
1452    // `recall_mode_active` follows truthfully.
1453    let embedder_live = ctx.embedder.is_some_and(|e| !e.is_degraded());
1454    let result = match accept {
1455        CapabilitiesAccept::V3 => handle_capabilities_with_conn_v3(
1456            ctx.tier_config,
1457            ctx.resolved_models,
1458            ctx.reranker,
1459            embedder_live,
1460            Some(ctx.conn),
1461            ctx.profile,
1462            ctx.mcp_config,
1463            v3_aid,
1464            ctx.harness,
1465        ),
1466        _ => handle_capabilities_with_conn(
1467            ctx.tier_config,
1468            ctx.resolved_models,
1469            ctx.reranker,
1470            embedder_live,
1471            Some(ctx.conn),
1472            accept,
1473        ),
1474    };
1475    let profile = ctx.profile;
1476    result.map(|mut value| {
1477        if matches!(accept, CapabilitiesAccept::V2 | CapabilitiesAccept::V3) {
1478            if let Some(obj) = value.as_object_mut() {
1479                obj.insert("families".to_string(), families_overview(profile));
1480            }
1481        }
1482        if matches!(accept, CapabilitiesAccept::V1)
1483            && let Some(obj) = value.as_object_mut()
1484            && !obj.contains_key(field_names::SCHEMA_VERSION)
1485        {
1486            obj.insert(
1487                field_names::SCHEMA_VERSION.to_string(),
1488                Value::String("1".to_string()),
1489            );
1490        }
1491        if let Some(obj) = value.as_object_mut() {
1492            obj.insert("tier".to_string(), Value::String(runtime_tier.to_string()));
1493        }
1494        if (top_include_schema || top_verbose)
1495            && matches!(accept, CapabilitiesAccept::V2 | CapabilitiesAccept::V3)
1496            && let Some(obj) = value.as_object_mut()
1497        {
1498            overlay_tool_payloads(obj, profile, top_include_schema, top_verbose);
1499        }
1500        value
1501    })
1502}
1503
1504fn dispatch_memory_expand_query(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1505    handle_expand_query(ctx.llm, ctx.arguments)
1506}
1507
1508fn dispatch_memory_auto_tag(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1509    handle_auto_tag(ctx.conn, ctx.llm, ctx.arguments)
1510}
1511
1512fn dispatch_memory_detect_contradiction(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1513    handle_detect_contradiction(ctx.conn, ctx.llm, ctx.arguments)
1514}
1515
1516fn dispatch_memory_archive_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1517    handle_archive_list(ctx.conn, ctx.arguments)
1518}
1519
1520fn dispatch_memory_archive_restore(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1521    handle_archive_restore(ctx.conn, ctx.arguments)
1522}
1523
1524fn dispatch_memory_archive_purge(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1525    handle_archive_purge(ctx.conn, ctx.arguments)
1526}
1527
1528fn dispatch_memory_archive_stats(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1529    handle_archive_stats(ctx.conn)
1530}
1531
1532fn dispatch_memory_gc(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1533    handle_gc(ctx.conn, ctx.arguments, ctx.archive_on_gc)
1534}
1535
1536fn dispatch_memory_session_start(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1537    // v0.7.0 #1420 — the post-list visibility filter at
1538    // handle_session_start drops cross-agent `scope=private` rows before
1539    // they reach the wire.
1540    //
1541    // #1469 — the caller MUST be the stable `AI_MEMORY_AGENT_ID` identity
1542    // (or None), NOT the raw handshake `clientInfo.name`. The store path
1543    // stamps `metadata.agent_id` from the env (or a pid-bearing
1544    // synthesized id); a fresh resume process can never reproduce a prior
1545    // process's synthesized owner, so keying the read filter on
1546    // `clientInfo.name` made an agent invisible to its OWN private WIP
1547    // rows on resume. Resolving env > None makes resume see exactly the
1548    // rows the same env identity wrote, and keeps single-tenant trust-all.
1549    let caller = crate::identity::resolve_read_visibility_caller();
1550    handle_session_start(ctx.conn, ctx.arguments, ctx.llm, caller.as_deref())
1551}
1552
1553fn dispatch_memory_namespace_set_standard(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1554    handle_namespace_set_standard(ctx.conn, ctx.arguments)
1555}
1556
1557fn dispatch_memory_namespace_get_standard(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1558    handle_namespace_get_standard(ctx.conn, ctx.arguments)
1559}
1560
1561fn dispatch_memory_namespace_clear_standard(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1562    handle_namespace_clear_standard(ctx.conn, ctx.arguments)
1563}
1564
1565fn dispatch_memory_agent_register(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1566    handle_agent_register(ctx.conn, ctx.arguments)
1567}
1568
1569fn dispatch_memory_agent_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1570    handle_agent_list(ctx.conn)
1571}
1572
1573fn dispatch_memory_notify(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1574    handle_notify(ctx.conn, ctx.arguments, ctx.resolved_ttl, ctx.mcp_client)
1575}
1576
1577/// #1050 (2026-05-21) — `memory_share` was registered in
1578/// [`crate::mcp::registry::registered_tools`] post-#311 (issue #224
1579/// pulled the v0.8 Phase-3 Memory-Sharing-and-Sync RFC forward into
1580/// v0.7.0) but the dispatch wrapper + `register_mcp_tool!` arm were
1581/// never added. `tools/list` advertised the tool, `memory_capabilities`
1582/// v3 reported `callable_now: true` under any profile containing
1583/// `Family::Power`, but `tools/call memory_share` returned
1584/// `-32601 unknown tool: memory_share`. The handler at
1585/// `crate::mcp::share::handle_share` was complete; only the wire
1586/// dispatch was missing.
1587fn dispatch_memory_share(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1588    crate::mcp::share::handle_share(ctx.conn, ctx.arguments)
1589}
1590
1591fn dispatch_memory_inbox(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1592    // v0.7.0 #1557 — bind the inbox owner to the resolved caller so a
1593    // multi-tenant caller cannot read another agent's private inbox.
1594    let caller = crate::identity::resolve_read_visibility_caller();
1595    handle_inbox(ctx.conn, ctx.arguments, ctx.mcp_client, caller.as_deref())
1596}
1597
1598fn dispatch_memory_subscribe(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1599    handle_subscribe(ctx.conn, ctx.arguments, ctx.mcp_client)
1600}
1601
1602fn dispatch_memory_unsubscribe(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1603    handle_unsubscribe(ctx.conn, ctx.arguments, ctx.mcp_client)
1604}
1605
1606fn dispatch_memory_list_subscriptions(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1607    handle_list_subscriptions(ctx.conn, ctx.mcp_client)
1608}
1609
1610fn dispatch_memory_subscription_replay(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1611    handle_subscription_replay(ctx.conn, ctx.arguments, ctx.mcp_client)
1612}
1613
1614fn dispatch_memory_subscription_dlq_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1615    handle_subscription_dlq_list(ctx.conn, ctx.arguments, ctx.mcp_client)
1616}
1617
1618fn dispatch_memory_quota_status(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1619    handle_quota_status(ctx.conn, ctx.arguments)
1620}
1621
1622/// v0.7.0 #1389 L4 — `memory_capture_turn` dispatcher.
1623///
1624/// Threads `ctx.mcp_client` (the host identity captured at
1625/// `initialize.clientInfo.name` per CLAUDE.md §"Agent Identity")
1626/// into the handler so the #1413 agent_id-agreement check + the
1627/// #1415 signed_events row carry the authenticated-via-MCP-
1628/// handshake caller identity rather than a body-claimed one.
1629fn dispatch_memory_capture_turn(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1630    handle_capture_turn(ctx.conn, ctx.arguments, ctx.mcp_client)
1631}
1632
1633fn dispatch_memory_check_agent_action(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1634    handle_check_agent_action(ctx.conn, ctx.arguments)
1635}
1636
1637fn dispatch_memory_rule_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1638    handle_rule_list(ctx.conn, ctx.arguments)
1639}
1640
1641fn dispatch_memory_reflection_origin(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1642    handle_reflection_origin(ctx.conn, ctx.arguments)
1643}
1644
1645fn dispatch_memory_export_reflection(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1646    handle_export_reflection(ctx.conn, ctx.arguments)
1647}
1648
1649fn dispatch_memory_persona(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1650    handle_persona(ctx.conn, ctx.arguments)
1651}
1652
1653fn dispatch_memory_persona_generate(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1654    handle_persona_generate(
1655        ctx.conn,
1656        ctx.arguments,
1657        ctx.llm.map(|c| c as &dyn crate::autonomy::AutonomyLlm),
1658        ctx.tier_config.tier,
1659        ctx.active_keypair,
1660    )
1661}
1662
1663fn dispatch_memory_calibrate_confidence(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1664    handle_calibrate_confidence(ctx.conn, ctx.arguments)
1665}
1666
1667fn dispatch_memory_dependents_of_invalidated(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1668    handle_dependents_of_invalidated(ctx.conn, ctx.arguments)
1669}
1670
1671fn dispatch_memory_skill_register(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1672    handle_skill_register(ctx.conn, ctx.arguments, ctx.active_keypair)
1673}
1674
1675fn dispatch_memory_skill_list(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1676    handle_skill_list(ctx.conn, ctx.arguments)
1677}
1678
1679fn dispatch_memory_skill_get(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1680    handle_skill_get(ctx.conn, ctx.arguments)
1681}
1682
1683fn dispatch_memory_skill_resource(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1684    handle_skill_resource(ctx.conn, ctx.arguments)
1685}
1686
1687fn dispatch_memory_skill_export(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1688    handle_skill_export(ctx.conn, ctx.arguments, ctx.active_keypair)
1689}
1690
1691fn dispatch_memory_skill_promote_from_reflection(
1692    ctx: &ToolDispatchCtx<'_>,
1693) -> Result<Value, String> {
1694    handle_skill_promote_from_reflection(ctx.conn, ctx.arguments, ctx.active_keypair)
1695}
1696
1697fn dispatch_memory_skill_compositional_context(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1698    handle_skill_compositional_context(ctx.conn, ctx.arguments)
1699}
1700
1701/// `memory_offload` dispatch — resolves caller's agent_id through the
1702/// same NHI precedence chain `memory_store` uses
1703/// (explicit > metadata.agent_id > `mcp_client` > host fallback) so
1704/// the substrate row is correctly attributed.
1705fn dispatch_memory_offload(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1706    let explicit_agent_id = ctx
1707        .arguments
1708        .get("agent_id")
1709        .and_then(Value::as_str)
1710        .or_else(|| {
1711            ctx.arguments
1712                .get("metadata")
1713                .and_then(|m| m.get("agent_id"))
1714                .and_then(Value::as_str)
1715        });
1716    match crate::identity::resolve_agent_id(explicit_agent_id, ctx.mcp_client) {
1717        Ok(agent_id) => offload::handle_offload(ctx.conn, ctx.arguments, &agent_id),
1718        Err(e) => Err(e.to_string()),
1719    }
1720}
1721
1722/// `memory_deref` dispatch — SEC-4 (Cluster D, issue #767) resolves
1723/// caller's authenticated agent_id so the deref ownership gate can
1724/// refuse cross-agent leaks (NotFound, leak-resistant). Mirrors the
1725/// `memory_offload` shape.
1726fn dispatch_memory_deref(ctx: &ToolDispatchCtx<'_>) -> Result<Value, String> {
1727    let explicit_agent_id = ctx
1728        .arguments
1729        .get("agent_id")
1730        .and_then(Value::as_str)
1731        .or_else(|| {
1732            ctx.arguments
1733                .get("metadata")
1734                .and_then(|m| m.get("agent_id"))
1735                .and_then(Value::as_str)
1736        });
1737    match crate::identity::resolve_agent_id(explicit_agent_id, ctx.mcp_client) {
1738        Ok(agent_id) => offload::handle_deref(ctx.conn, ctx.arguments, &agent_id),
1739        Err(e) => Err(e.to_string()),
1740    }
1741}
1742
1743/// The canonical `tools/call` dispatch table. Keyed by MCP tool name;
1744/// each entry's `DispatchFn` un-bundles a [`ToolDispatchCtx`] back
1745/// into the positional arguments its handler expects.
1746///
1747/// New tools land by adding a `dispatch_<tool>` wrapper above and an
1748/// entry here via [`register_mcp_tool!`].
1749///
1750/// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): every
1751/// dispatch arm references a const from
1752/// [`crate::mcp::registry::tool_names`]. Renaming a tool is now a
1753/// one-line edit (the const value) rather than a per-call-site sweep.
1754pub(crate) static TOOL_DISPATCH_TABLE: &[(&str, DispatchFn)] = {
1755    use crate::mcp::registry::tool_names;
1756    &[
1757        register_mcp_tool!(tool_names::MEMORY_STORE, dispatch_memory_store),
1758        register_mcp_tool!(tool_names::MEMORY_RECALL, dispatch_memory_recall),
1759        register_mcp_tool!(
1760            tool_names::MEMORY_RECALL_OBSERVATIONS,
1761            dispatch_memory_recall_observations
1762        ),
1763        register_mcp_tool!(tool_names::MEMORY_SEARCH, dispatch_memory_search),
1764        register_mcp_tool!(tool_names::MEMORY_LIST, dispatch_memory_list),
1765        register_mcp_tool!(tool_names::MEMORY_LOAD_FAMILY, dispatch_memory_load_family),
1766        register_mcp_tool!(tool_names::MEMORY_SMART_LOAD, dispatch_memory_smart_load),
1767        register_mcp_tool!(
1768            tool_names::MEMORY_GET_TAXONOMY,
1769            dispatch_memory_get_taxonomy
1770        ),
1771        register_mcp_tool!(
1772            tool_names::MEMORY_CHECK_DUPLICATE,
1773            dispatch_memory_check_duplicate
1774        ),
1775        register_mcp_tool!(
1776            tool_names::MEMORY_ENTITY_REGISTER,
1777            dispatch_memory_entity_register
1778        ),
1779        register_mcp_tool!(
1780            tool_names::MEMORY_ENTITY_GET_BY_ALIAS,
1781            dispatch_memory_entity_get_by_alias
1782        ),
1783        register_mcp_tool!(tool_names::MEMORY_KG_TIMELINE, dispatch_memory_kg_timeline),
1784        register_mcp_tool!(
1785            tool_names::MEMORY_KG_INVALIDATE,
1786            dispatch_memory_kg_invalidate
1787        ),
1788        register_mcp_tool!(tool_names::MEMORY_KG_QUERY, dispatch_memory_kg_query),
1789        register_mcp_tool!(tool_names::MEMORY_FIND_PATHS, dispatch_memory_find_paths),
1790        register_mcp_tool!(tool_names::MEMORY_DELETE, dispatch_memory_delete),
1791        register_mcp_tool!(tool_names::MEMORY_PROMOTE, dispatch_memory_promote),
1792        register_mcp_tool!(
1793            tool_names::MEMORY_PENDING_LIST,
1794            dispatch_memory_pending_list
1795        ),
1796        register_mcp_tool!(
1797            tool_names::MEMORY_PENDING_APPROVE,
1798            dispatch_memory_pending_approve
1799        ),
1800        register_mcp_tool!(
1801            tool_names::MEMORY_PENDING_REJECT,
1802            dispatch_memory_pending_reject
1803        ),
1804        register_mcp_tool!(tool_names::MEMORY_FORGET, dispatch_memory_forget),
1805        register_mcp_tool!(tool_names::MEMORY_STATS, dispatch_memory_stats),
1806        register_mcp_tool!(tool_names::MEMORY_UPDATE, dispatch_memory_update),
1807        register_mcp_tool!(tool_names::MEMORY_GET, dispatch_memory_get),
1808        register_mcp_tool!(tool_names::MEMORY_LINK, dispatch_memory_link),
1809        register_mcp_tool!(tool_names::MEMORY_GET_LINKS, dispatch_memory_get_links),
1810        register_mcp_tool!(tool_names::MEMORY_VERIFY, dispatch_memory_verify),
1811        register_mcp_tool!(tool_names::MEMORY_REPLAY, dispatch_memory_replay),
1812        register_mcp_tool!(tool_names::MEMORY_CONSOLIDATE, dispatch_memory_consolidate),
1813        register_mcp_tool!(tool_names::MEMORY_ATOMISE, dispatch_memory_atomise),
1814        register_mcp_tool!(
1815            tool_names::MEMORY_INGEST_MULTISTEP,
1816            dispatch_memory_ingest_multistep
1817        ),
1818        register_mcp_tool!(tool_names::MEMORY_REFLECT, dispatch_memory_reflect),
1819        register_mcp_tool!(
1820            tool_names::MEMORY_CAPABILITIES,
1821            dispatch_memory_capabilities
1822        ),
1823        register_mcp_tool!(
1824            tool_names::MEMORY_EXPAND_QUERY,
1825            dispatch_memory_expand_query
1826        ),
1827        register_mcp_tool!(tool_names::MEMORY_AUTO_TAG, dispatch_memory_auto_tag),
1828        register_mcp_tool!(
1829            tool_names::MEMORY_DETECT_CONTRADICTION,
1830            dispatch_memory_detect_contradiction
1831        ),
1832        register_mcp_tool!(
1833            tool_names::MEMORY_ARCHIVE_LIST,
1834            dispatch_memory_archive_list
1835        ),
1836        register_mcp_tool!(
1837            tool_names::MEMORY_ARCHIVE_RESTORE,
1838            dispatch_memory_archive_restore
1839        ),
1840        register_mcp_tool!(
1841            tool_names::MEMORY_ARCHIVE_PURGE,
1842            dispatch_memory_archive_purge
1843        ),
1844        register_mcp_tool!(
1845            tool_names::MEMORY_ARCHIVE_STATS,
1846            dispatch_memory_archive_stats
1847        ),
1848        register_mcp_tool!(tool_names::MEMORY_GC, dispatch_memory_gc),
1849        register_mcp_tool!(
1850            tool_names::MEMORY_SESSION_START,
1851            dispatch_memory_session_start
1852        ),
1853        register_mcp_tool!(
1854            tool_names::MEMORY_NAMESPACE_SET_STANDARD,
1855            dispatch_memory_namespace_set_standard
1856        ),
1857        register_mcp_tool!(
1858            tool_names::MEMORY_NAMESPACE_GET_STANDARD,
1859            dispatch_memory_namespace_get_standard
1860        ),
1861        register_mcp_tool!(
1862            tool_names::MEMORY_NAMESPACE_CLEAR_STANDARD,
1863            dispatch_memory_namespace_clear_standard
1864        ),
1865        register_mcp_tool!(
1866            tool_names::MEMORY_AGENT_REGISTER,
1867            dispatch_memory_agent_register
1868        ),
1869        register_mcp_tool!(tool_names::MEMORY_AGENT_LIST, dispatch_memory_agent_list),
1870        register_mcp_tool!(tool_names::MEMORY_NOTIFY, dispatch_memory_notify),
1871        register_mcp_tool!(tool_names::MEMORY_SHARE, dispatch_memory_share),
1872        register_mcp_tool!(tool_names::MEMORY_INBOX, dispatch_memory_inbox),
1873        register_mcp_tool!(tool_names::MEMORY_SUBSCRIBE, dispatch_memory_subscribe),
1874        register_mcp_tool!(tool_names::MEMORY_UNSUBSCRIBE, dispatch_memory_unsubscribe),
1875        register_mcp_tool!(
1876            tool_names::MEMORY_LIST_SUBSCRIPTIONS,
1877            dispatch_memory_list_subscriptions
1878        ),
1879        register_mcp_tool!(
1880            tool_names::MEMORY_SUBSCRIPTION_REPLAY,
1881            dispatch_memory_subscription_replay
1882        ),
1883        register_mcp_tool!(
1884            tool_names::MEMORY_SUBSCRIPTION_DLQ_LIST,
1885            dispatch_memory_subscription_dlq_list
1886        ),
1887        register_mcp_tool!(
1888            tool_names::MEMORY_QUOTA_STATUS,
1889            dispatch_memory_quota_status
1890        ),
1891        // v0.7.0 #1389 L4 — host-volunteered turn capture per RFC-0001.
1892        register_mcp_tool!(
1893            tool_names::MEMORY_CAPTURE_TURN,
1894            dispatch_memory_capture_turn
1895        ),
1896        register_mcp_tool!(
1897            tool_names::MEMORY_CHECK_AGENT_ACTION,
1898            dispatch_memory_check_agent_action
1899        ),
1900        register_mcp_tool!(tool_names::MEMORY_RULE_LIST, dispatch_memory_rule_list),
1901        register_mcp_tool!(
1902            tool_names::MEMORY_REFLECTION_ORIGIN,
1903            dispatch_memory_reflection_origin
1904        ),
1905        register_mcp_tool!(
1906            tool_names::MEMORY_EXPORT_REFLECTION,
1907            dispatch_memory_export_reflection
1908        ),
1909        register_mcp_tool!(tool_names::MEMORY_PERSONA, dispatch_memory_persona),
1910        register_mcp_tool!(
1911            tool_names::MEMORY_PERSONA_GENERATE,
1912            dispatch_memory_persona_generate
1913        ),
1914        register_mcp_tool!(
1915            tool_names::MEMORY_CALIBRATE_CONFIDENCE,
1916            dispatch_memory_calibrate_confidence
1917        ),
1918        register_mcp_tool!(
1919            tool_names::MEMORY_DEPENDENTS_OF_INVALIDATED,
1920            dispatch_memory_dependents_of_invalidated
1921        ),
1922        register_mcp_tool!(
1923            tool_names::MEMORY_SKILL_REGISTER,
1924            dispatch_memory_skill_register
1925        ),
1926        register_mcp_tool!(tool_names::MEMORY_SKILL_LIST, dispatch_memory_skill_list),
1927        register_mcp_tool!(tool_names::MEMORY_SKILL_GET, dispatch_memory_skill_get),
1928        register_mcp_tool!(
1929            tool_names::MEMORY_SKILL_RESOURCE,
1930            dispatch_memory_skill_resource
1931        ),
1932        register_mcp_tool!(
1933            tool_names::MEMORY_SKILL_EXPORT,
1934            dispatch_memory_skill_export
1935        ),
1936        register_mcp_tool!(
1937            tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
1938            dispatch_memory_skill_promote_from_reflection
1939        ),
1940        register_mcp_tool!(
1941            tool_names::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
1942            dispatch_memory_skill_compositional_context
1943        ),
1944        register_mcp_tool!(tool_names::MEMORY_OFFLOAD, dispatch_memory_offload),
1945        register_mcp_tool!(tool_names::MEMORY_DEREF, dispatch_memory_deref),
1946    ]
1947};
1948
1949/// v0.7.0 #1105 — O(1) HashMap-based lookup against
1950/// [`TOOL_DISPATCH_TABLE`]. Pre-#1105 this was a linear scan over the
1951/// dispatch-table slice; the MCP stdio transport is single-threaded by
1952/// protocol design so the per-call overhead is paid serially and
1953/// contributes directly to dispatch latency for tools at the
1954/// alphabetical end of the table.
1955///
1956/// Returns the matching `DispatchFn` or `None` if the tool is
1957/// unknown. The caller maps `None` to the JSON-RPC `-32601` "method
1958/// not found" envelope.
1959pub(crate) fn lookup_dispatch(tool_name: &str) -> Option<DispatchFn> {
1960    static MAP: std::sync::OnceLock<std::collections::HashMap<&'static str, DispatchFn>> =
1961        std::sync::OnceLock::new();
1962    let map = MAP.get_or_init(|| {
1963        let mut m = std::collections::HashMap::with_capacity(TOOL_DISPATCH_TABLE.len());
1964        for (name, f) in TOOL_DISPATCH_TABLE {
1965            m.insert(*name, *f);
1966        }
1967        m
1968    });
1969    map.get(tool_name).copied()
1970}
1971
1972#[allow(clippy::too_many_arguments)]
1973#[allow(clippy::too_many_lines)]
1974fn handle_request(
1975    conn: &rusqlite::Connection,
1976    db_path: &Path,
1977    req: &RpcRequest,
1978    embedder: Option<&dyn Embed>,
1979    llm: Option<&OllamaClient>,
1980    reranker: Option<&BatchedReranker>,
1981    tier_config: &TierConfig,
1982    // v0.7.x (issue #1168) — operator-resolved models triple. See
1983    // `ToolDispatchCtx::resolved_models` for rationale. Threaded from
1984    // `run_mcp_server` (and tests) through to
1985    // `dispatch_memory_capabilities`.
1986    resolved_models: &ResolvedModels,
1987    vector_index: Option<&VectorIndex>,
1988    resolved_ttl: &crate::config::ResolvedTtl,
1989    resolved_scoring: &crate::config::ResolvedScoring,
1990    archive_on_gc: bool,
1991    autonomous_hooks: bool,
1992    mcp_client: Option<&str>,
1993    profile: &crate::profile::Profile,
1994    mcp_config: Option<&crate::config::McpConfig>,
1995    // v0.7 Track H — H2 outbound link signing. When `Some`, every
1996    // `memory_link` call signs the link with this keypair. When `None`
1997    // (operator hasn't generated one), links go in unsigned, preserving
1998    // v0.6.4 behaviour.
1999    active_keypair: Option<&crate::identity::keypair::AgentKeypair>,
2000    // v0.7 Track B (B4) — harness detected from `clientInfo.name` at
2001    // MCP `initialize` handshake time. Threaded into the
2002    // capabilities-v3 dispatch so the response can carry
2003    // `your_harness_supports_deferred_registration` (presence + value
2004    // both signal). `None` when no `initialize` has been observed yet
2005    // — the field is omitted from the wire on that fall-through.
2006    harness: Option<&crate::harness::Harness>,
2007    // v0.7.0 (#318) — when `Some`, all MCP write tools forward to the
2008    // local HTTP daemon at this base URL so its federation fanout
2009    // coordinator runs. `None` keeps the legacy direct-SQLite path
2010    // (single-node MCP deployments without a sibling `serve` daemon).
2011    federation_forward_url: Option<&str>,
2012    // v0.7.0 (issue #518) — `[agents.defaults.recall_scope]`
2013    // resolved from the running daemon's `AppConfig`. `Some` enables
2014    // `session_default=true` callers to splice these defaults into
2015    // their `memory_recall` request before the storage call. `None`
2016    // (single-tenant default) preserves v0.6.x recall semantics.
2017    recall_scope: Option<&crate::config::RecallScope>,
2018    // v0.7.0 WT-1-C — `memory_atomise` MCP tool handler bundle. `Some`
2019    // when an LLM is wired (smart/autonomous tier); `None` collapses
2020    // the dispatch path to a tier-locked advisory envelope.
2021    atomise_handler: Option<&atomise::AtomiseToolHandler>,
2022    // v0.7.0 Form 3 (issue #756) — `memory_ingest_multistep` handler
2023    // bundle. `Some` when an LLM is wired; `None` collapses to the
2024    // tier-locked advisory.
2025    ingest_multistep_handler: Option<&ingest_multistep::IngestMultistepHandler>,
2026    // v0.7.0 #1389 / #1398 L1 — capture-nag watcher singleton, owned by
2027    // `run_mcp_server` for the lifetime of the stdio session. `Some`
2028    // wires the dispatch loop to observe non-capture tool-call streaks
2029    // and emit `capture_lag` WARN + signed events past the configured
2030    // threshold; `None` disables the layer (tests that don't exercise
2031    // L1, and any future transport that opts out).
2032    nag_watcher: Option<&crate::recover::nag::CaptureNagWatcher>,
2033    // Per-session key for the nag watcher's `(agent_id, session_id)`
2034    // counter. In the stdio path this is a process-lifetime id minted
2035    // once in `run_mcp_server`.
2036    nag_session_id: &str,
2037) -> RpcResponse {
2038    let id = req.id.clone().unwrap_or(Value::Null);
2039
2040    // Validate JSON-RPC 2.0 version
2041    if req.jsonrpc != jsonrpc::VERSION {
2042        return err_response(
2043            id,
2044            jsonrpc::INVALID_REQUEST,
2045            format!(
2046                "invalid JSON-RPC version (must be \"{}\")",
2047                jsonrpc::VERSION
2048            ),
2049        );
2050    }
2051
2052    match req.method.as_str() {
2053        jsonrpc::METHOD_INITIALIZE => {
2054            // v0.7.x (#1154) — daemon serverInfo Ed25519 signing.
2055            // Closes NSA CSI MCP Security concern (j) Tool invocation
2056            // path confusion at the substrate boundary. When the
2057            // daemon has an Ed25519 keypair on disk (loaded by
2058            // `crate::governance::audit::load_daemon_signing_key`
2059            // at startup and threaded as `active_keypair`), the
2060            // `serverInfo` block carries a signed `ai_memory_identity`
2061            // sub-block clients can pin via Trust On First Use. When
2062            // no keypair is present, the sub-block is OMITTED — same
2063            // wire shape as v0.7.0, preserving the "continuing
2064            // unsigned" posture documented at `src/main.rs:96-98`.
2065            // Per MCP / JSON-RPC convention, clients ignore unknown
2066            // response fields, so this is zero-risk on wire compat.
2067            let mut server_info = json!({
2068                "name": "ai-memory",
2069                "version": crate::PKG_VERSION,
2070            });
2071            let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2072            if let Ok(Some(identity_block)) =
2073                server_identity::build_signed_identity(active_keypair, &now_rfc3339)
2074                && let Some(obj) = server_info.as_object_mut()
2075            {
2076                obj.insert("ai_memory_identity".to_string(), identity_block);
2077            }
2078            ok_response(
2079                id,
2080                json!({
2081                    "protocolVersion": jsonrpc::PROTOCOL_REVISION,
2082                    (field_names::CAPABILITIES): { "tools": {}, "prompts": {} },
2083                    "serverInfo": server_info,
2084                }),
2085            )
2086        }
2087        jsonrpc::METHOD_NOTIFICATIONS_INITIALIZED | jsonrpc::METHOD_PING => {
2088            ok_response(id, json!({}))
2089        }
2090        jsonrpc::METHOD_TOOLS_LIST => ok_response(id, tool_definitions_for_profile(profile)),
2091        jsonrpc::METHOD_PROMPTS_LIST => ok_response(id, prompt_definitions()),
2092        jsonrpc::METHOD_PROMPTS_GET => {
2093            let prompt_name = match req.params["name"].as_str() {
2094                Some(name) if !name.is_empty() => name,
2095                _ => {
2096                    return err_response(
2097                        id,
2098                        jsonrpc::INVALID_PARAMS,
2099                        "missing or empty prompt name".into(),
2100                    );
2101                }
2102            };
2103            match prompt_content(prompt_name, &req.params) {
2104                Ok(val) => ok_response(id, val),
2105                Err(e) => err_response(id, jsonrpc::INVALID_PARAMS, e),
2106            }
2107        }
2108        jsonrpc::METHOD_TOOLS_CALL => {
2109            let tool_name = match req.params["name"].as_str() {
2110                Some(name) if !name.is_empty() => name,
2111                _ => {
2112                    return err_response(
2113                        id,
2114                        jsonrpc::INVALID_PARAMS,
2115                        "missing or empty tool name".into(),
2116                    );
2117                }
2118            };
2119
2120            // v0.6.4-002 (RFC S28) — reject calls to tools that are not
2121            // loaded under the active profile. The error message names
2122            // the profile that would load the tool, so a confused agent
2123            // can self-correct via `--profile <hint>` or use
2124            // `memory_capabilities --include-schema family=<f>` to opt in
2125            // at runtime (Track C, v0.6.4-006).
2126            //
2127            // #1254 (MED, 2026-05-25) — the helpful "tool exists in
2128            // family X, use --profile Y to load it" hint leaks the
2129            // higher-profile tool name + family membership to a
2130            // lower-profile client. Multi-tenant operators MUST be able
2131            // to opt out so a probing client sees a uniform
2132            // "unknown tool" response regardless of whether the name
2133            // exists in another family. The escape hatch is the
2134            // `profile_hint_in_errors` McpConfig field (default
2135            // `false`); operators flip it on for single-tenant dev
2136            // posture where every caller sees the full surface anyway.
2137            if !profile.loads(tool_name) {
2138                let hint_enabled = mcp_config.is_some_and(|c| c.profile_hint_in_errors);
2139                let hint = if hint_enabled {
2140                    let owning_family = crate::profile::Family::for_tool(tool_name);
2141                    match owning_family {
2142                        Some(f) => format!(
2143                            "tool '{tool_name}' is in family '{}' which is not loaded under \
2144                             the active profile. Restart with `--profile <name>` or \
2145                             `--profile core,{}` to load it, or call `memory_capabilities \
2146                             --include-schema family={}` to expand at runtime.",
2147                            f.name(),
2148                            f.name(),
2149                            f.name()
2150                        ),
2151                        None => format!(
2152                            "tool '{tool_name}' is not registered in this build. Call \
2153                             `memory_capabilities` to see available tools."
2154                        ),
2155                    }
2156                } else {
2157                    // Default (production-secure): uniform error
2158                    // regardless of whether the tool exists in another
2159                    // family. Pairs with a debug-level tracing line so
2160                    // operators with `RUST_LOG=ai_memory::mcp=debug`
2161                    // can still see the precise refusal cause.
2162                    tracing::debug!(
2163                        target: "ai_memory::mcp",
2164                        tool = tool_name,
2165                        owning_family = ?crate::profile::Family::for_tool(tool_name).map(crate::profile::Family::name),
2166                        "tools/call refused: tool not loaded under active profile (\
2167                         #1254 — set mcp.profile_hint_in_errors=true to surface family hint)",
2168                    );
2169                    format!("unknown tool: {tool_name}")
2170                };
2171                return err_response(id, jsonrpc::METHOD_NOT_FOUND, hint);
2172            }
2173
2174            // Pillar 3 / Stream E — emit a structured tracing span around
2175            // every MCP tool dispatch so production observability can
2176            // attribute latency per tool. The span carries the tool name
2177            // and JSON-RPC id; outcome and elapsed wall time are emitted
2178            // as a child event after dispatch returns.
2179            let span = tracing::info_span!(
2180                "mcp_tool_call",
2181                tool = tool_name,
2182                rpc_id = ?id,
2183            );
2184            let _enter = span.enter();
2185            let started = Instant::now();
2186
2187            let empty_obj = json!({});
2188            let arguments = if req.params["arguments"].is_object() {
2189                &req.params["arguments"]
2190            } else {
2191                &empty_obj
2192            };
2193
2194            // v0.7.0 #1389 / #1398 L1 — observe this call against the
2195            // capture-nag watcher BEFORE dispatch. Emits a `capture_lag`
2196            // WARN + signed event when the agent crosses the
2197            // consecutive-non-capture-tool-call threshold. Strictly
2198            // observation-only: the returned action does not gate or
2199            // alter the dispatch below.
2200            observe_capture_nag(
2201                nag_watcher,
2202                nag_session_id,
2203                tool_name,
2204                arguments,
2205                mcp_client,
2206            );
2207
2208            // #867 — registry-driven dispatch. The legacy per-tool match
2209            // is gone; every tool now resolves through
2210            // [`TOOL_DISPATCH_TABLE`] which keys on `tool_name` and
2211            // returns a `DispatchFn` that un-bundles the
2212            // `ToolDispatchCtx` back into the underlying handler's
2213            // positional arguments. New tools register via
2214            // `register_mcp_tool!` next to their module instead of
2215            // editing this central dispatcher.
2216            let ctx = ToolDispatchCtx {
2217                conn,
2218                db_path,
2219                arguments,
2220                embedder,
2221                llm,
2222                reranker,
2223                tier_config,
2224                resolved_models,
2225                vector_index,
2226                resolved_ttl,
2227                resolved_scoring,
2228                archive_on_gc,
2229                autonomous_hooks,
2230                mcp_client,
2231                profile,
2232                mcp_config,
2233                active_keypair,
2234                harness,
2235                federation_forward_url,
2236                recall_scope,
2237                atomise_handler,
2238                ingest_multistep_handler,
2239            };
2240            let Some(dispatch) = lookup_dispatch(tool_name) else {
2241                // Ultrareview #349: unknown tool is a JSON-RPC 2.0
2242                // "method not found" condition — return -32601, not
2243                // an ok_response with `isError: true`. Clients that
2244                // switch on error code can then misroute / retry
2245                // correctly. We surface the tool name in `data` so
2246                // clients can log it without parsing the message.
2247                return err_response(
2248                    id,
2249                    jsonrpc::METHOD_NOT_FOUND,
2250                    format!("unknown tool: {tool_name}"),
2251                );
2252            };
2253            let result = dispatch(&ctx);
2254
2255            // Outcome + elapsed reported under the `mcp_tool_call` span so
2256            // exporters can chart per-tool p95/p99 against PERFORMANCE.md
2257            // budgets without needing per-handler instrumentation.
2258            let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
2259            match &result {
2260                Ok(_) => tracing::info!(elapsed_ms, "ok"),
2261                Err(err) => tracing::warn!(elapsed_ms, error = %err, "err"),
2262            }
2263
2264            // PR-5 (issue #487): MCP-dispatch-level audit emission for
2265            // mutation/recall tools that the per-handler instrumentation
2266            // doesn't already cover. `memory_store` and `memory_delete`
2267            // each emit their own canonical event from inside the
2268            // handler so we skip them here to avoid double-counting.
2269            audit_emit_for_mcp_dispatch(tool_name, arguments, &result, mcp_client);
2270
2271            match result {
2272                Ok(val) => {
2273                    // Check if TOON format requested for recall/search/list
2274                    let format_str = arguments
2275                        .get("format")
2276                        .and_then(|v| v.as_str())
2277                        .unwrap_or(crate::toon::FORMAT_TOON_COMPACT);
2278                    use crate::mcp::registry::tool_names as tn;
2279                    let text = match format_str {
2280                        "toon"
2281                            if matches!(
2282                                tool_name,
2283                                tn::MEMORY_RECALL | tn::MEMORY_LIST | tn::MEMORY_SESSION_START
2284                            ) =>
2285                        {
2286                            crate::toon::memories_to_toon(&val, false)
2287                        }
2288                        crate::toon::FORMAT_TOON_COMPACT
2289                            if matches!(
2290                                tool_name,
2291                                tn::MEMORY_RECALL | tn::MEMORY_LIST | tn::MEMORY_SESSION_START
2292                            ) =>
2293                        {
2294                            crate::toon::memories_to_toon(&val, true)
2295                        }
2296                        "toon" if tool_name == tn::MEMORY_SEARCH => {
2297                            crate::toon::search_to_toon(&val, false)
2298                        }
2299                        crate::toon::FORMAT_TOON_COMPACT if tool_name == tn::MEMORY_SEARCH => {
2300                            crate::toon::search_to_toon(&val, true)
2301                        }
2302                        _ => serde_json::to_string_pretty(&val).unwrap_or_default(),
2303                    };
2304                    ok_response(
2305                        id,
2306                        json!({
2307                            "content": [{
2308                                "type": "text",
2309                                "text": text
2310                            }]
2311                        }),
2312                    )
2313                }
2314                // B4 (R2-LOW) — MCP-spec error envelope.
2315                //
2316                // Per MCP 2025-03-26 §"Tool result", handler-level errors
2317                // are returned to the client as a successful JSON-RPC
2318                // `result` carrying `isError: true` and a `content`
2319                // array of text blocks — NOT as a JSON-RPC `error`
2320                // object (`code` / `message` / `data`). The JSON-RPC
2321                // error channel is reserved for protocol-layer
2322                // failures (parse error, method-not-found,
2323                // invalid-params at the framing layer) so tool
2324                // semantics ride a uniform wire shape regardless of
2325                // which tool ran.
2326                //
2327                // This means the typed `MemoryError` variants in
2328                // `crate::errors` necessarily collapse to a plain
2329                // string here. The richer typing surfaces through the
2330                // HTTP transport (which DOES preserve error code +
2331                // structured `data` per `errors::ApiError`); MCP
2332                // clients that want the typed shape should use
2333                // `memory_capabilities` to discover the HTTP endpoint
2334                // and call it directly.
2335                //
2336                // This collapse is intentional and load-bearing for
2337                // MCP-spec compliance — do not "fix" it by routing
2338                // handler errors to `err_response`.
2339                Err(e) => ok_response(
2340                    id,
2341                    json!({
2342                        "content": [{"type": "text", "text": e}],
2343                        "isError": true
2344                    }),
2345                ),
2346            }
2347        }
2348        _ => err_response(
2349            id,
2350            jsonrpc::METHOD_NOT_FOUND,
2351            format!("method not found: {}", req.method),
2352        ),
2353    }
2354}
2355
2356/// v0.7 Track H — H2: best-effort load of the active Ed25519 keypair
2357/// for the MCP daemon. Logs to stderr (the MCP convention — stdout owns
2358/// JSON-RPC). Missing keypair returns `None` and link writes go in
2359/// unsigned; operator opts in by running `ai-memory identity generate`.
2360///
2361/// # Resolution order
2362///
2363/// 1. The keypair file matching the *resolved* `agent_id` for this
2364///    process (lets an operator who explicitly enrolled a per-NHI key
2365///    via `ai-memory identity generate <agent-id>` get that key picked
2366///    up automatically).
2367/// 2. Fallback to the substrate-managed `daemon` keypair (auto-generated
2368///    on first `serve`/`mcp` start, persisted under `<keys>/daemon.priv`).
2369///    This mirrors `daemon_runtime::ensure_and_load_daemon_keypair` so
2370///    the HTTP and MCP transports converge on the same signing key when
2371///    no NHI-specific key has been enrolled — closing the v0.7.0 #811
2372///    regression where MCP saw `host:FROSTYi.local:pid-XXX` from
2373///    `resolve_agent_id(None, None)`, failed the agent-keyed lookup, and
2374///    silently fell back to unsigned writes despite the daemon key
2375///    sitting on disk.
2376fn load_active_keypair_for_mcp() -> Option<crate::identity::keypair::AgentKeypair> {
2377    let dir = crate::identity::keypair::default_key_dir().ok()?;
2378    if !dir.exists() {
2379        return None;
2380    }
2381    let agent_id = crate::identity::resolve_agent_id(None, None).ok();
2382    load_active_keypair_for_mcp_in(&dir, agent_id.as_deref())
2383}
2384
2385/// Inner resolution used by [`load_active_keypair_for_mcp`]; split out so
2386/// it can be unit-tested without touching the host's real keys dir.
2387fn load_active_keypair_for_mcp_in(
2388    dir: &std::path::Path,
2389    agent_id: Option<&str>,
2390) -> Option<crate::identity::keypair::AgentKeypair> {
2391    if let Some(agent_id) = agent_id {
2392        match crate::identity::keypair::load(agent_id, dir) {
2393            Ok(kp) if kp.can_sign() => return Some(kp),
2394            Ok(_) => {}
2395            Err(e) => {
2396                let msg = format!("{e:#}");
2397                if !(msg.contains("No such file") || msg.contains("not found")) {
2398                    eprintln!("ai-memory: keypair load failed for {agent_id}: {msg}");
2399                }
2400            }
2401        }
2402    }
2403    // Fallback: substrate-managed daemon keypair (created by the
2404    // serve/mcp boot path; see daemon_runtime::ensure_and_load_daemon_keypair).
2405    match crate::identity::keypair::load(crate::identity::keypair::DAEMON_KEYPAIR_LABEL, dir) {
2406        Ok(kp) if kp.can_sign() => Some(kp),
2407        Ok(_) => None,
2408        Err(e) => {
2409            let msg = format!("{e:#}");
2410            if !(msg.contains("No such file") || msg.contains("not found")) {
2411                eprintln!("ai-memory: daemon keypair load failed: {msg}");
2412            }
2413            None
2414        }
2415    }
2416}
2417
2418/// v0.7.0 Wave-2 A5 (issue #853) — default batch size for the boot
2419/// embedding-backfill loop. Tuned to balance two effects:
2420///
2421/// * Embedder forward-pass amortisation — bigger batches let the
2422///   embedder's native batch path (when it lands) push more tokens
2423///   through one model call.
2424/// * SQLite transaction grouping — one commit per chunk, so the
2425///   per-row UPDATE round-trips collapse.
2426///
2427/// 64 is the empirical sweet spot for the pre-vectorised loop body:
2428/// large enough to amortise commit cost across the typical
2429/// 500-1000 unembedded-row boot scenario, small enough that an
2430/// embedder fault aborts at most one chunk of work. Override with
2431/// `AI_MEMORY_EMBED_BACKFILL_BATCH` for ops experimentation.
2432pub const DEFAULT_EMBED_BACKFILL_BATCH_SIZE: usize = 64;
2433
2434/// v0.7.0 Wave-2 A5 (issue #853) — chunked boot embedding backfill.
2435///
2436/// Replaces the original per-row `emb.embed()` + `db::set_embedding()`
2437/// loop that issued one autocommit `UPDATE` per memory. The new path:
2438///
2439/// 1. Fetches unembedded rows in bounded keyset passes of
2440///    `batch_size` (#1579 B6 / F5.6 + #1595 —
2441///    [`db::get_unembedded_ids_batch_after`]; the pre-fix path
2442///    materialised the WHOLE backlog in one Vec via
2443///    [`db::get_unembedded_ids`]) and re-fetches past the cursor
2444///    until drained.
2445/// 2. Each pass embeds one `batch_size` chunk. Callers
2446///    typically resolve this via
2447///    [`crate::config::AppConfig::resolve_embeddings`] which
2448///    applies the #1146 universal precedence ladder
2449///    (CLI > env > config > legacy > compiled default). The
2450///    [`run_embedding_backfill`] entry-point preserves the legacy
2451///    env-only resolution for back-compat callers; the
2452///    [`run_embedding_backfill_with_batch_size`] sibling takes an
2453///    explicit value so production daemons can honour
2454///    `[embeddings].backfill_batch` from `config.toml` even when
2455///    the env var is unset (issue #1260).
2456/// 3. Per chunk: calls [`Embed::embed_batch`] (the default impl
2457///    loops internally; a vectorised backend implementation is the
2458///    follow-up sub-issue), then a single
2459///    [`db::set_embeddings_batch`] call that wraps every UPDATE in
2460///    one transaction.
2461///
2462/// **Idempotence:** if the first bounded fetch returns an empty vec
2463/// (the "fully embedded" steady state), the function returns
2464/// `Ok(0)` without further work — re-running the backfill on a
2465/// fully-embedded DB is a true no-op.
2466///
2467/// **Failure isolation (#1595):** a chunk-level `embed_batch` fault
2468/// falls back to per-row [`Embed::embed`]; rows that still fail (or
2469/// whose `title + content` exceeds the client-side
2470/// [`crate::embeddings::EMBED_MAX_BYTES`] cap) are SKIPPED with a
2471/// per-row WARN naming the id + reason, and the sweep CONTINUES past
2472/// them via a keyset cursor ([`db::get_unembedded_ids_batch_after`]).
2473/// The pre-#1595 loop stopped the whole sweep on the first failing
2474/// chunk — one over-context-length row meant 0 rows ever backfilled.
2475/// Skipped rows stay unembedded and are re-attempted on the next sweep
2476/// invocation. The final stderr summary reports both totals:
2477/// `backfilled {ok}/{scanned} (skipped {skipped})`.
2478///
2479/// # Errors
2480///
2481/// Only propagates errors from [`db::get_unembedded_ids_batch_after`]
2482/// (the bounded scans). Per-chunk embedder + writer faults are logged
2483/// and counted (NOT propagated), matching the original loop's
2484/// semantics so a transient embedder fault doesn't block MCP
2485/// readiness.
2486pub fn run_embedding_backfill(
2487    conn: &mut rusqlite::Connection,
2488    emb: &dyn Embed,
2489) -> anyhow::Result<usize> {
2490    // Back-compat entry-point — resolves the batch size from the
2491    // env-var-only path so existing callers (the embedding-backfill
2492    // integration test, ops scripts that drive this function
2493    // directly) keep working when no `AppConfig` is in scope.
2494    // Production daemons should call
2495    // `run_embedding_backfill_with_batch_size` with the value from
2496    // `AppConfig::resolve_embeddings().backfill_batch` so
2497    // `[embeddings].backfill_batch` in `config.toml` is honoured
2498    // even when the env var is unset (issue #1260).
2499    let batch_size = std::env::var(crate::config::ENV_EMBED_BACKFILL_BATCH)
2500        .ok()
2501        .and_then(|s| s.parse::<usize>().ok())
2502        .filter(|&n| n > 0)
2503        .unwrap_or(DEFAULT_EMBED_BACKFILL_BATCH_SIZE);
2504    run_embedding_backfill_with_batch_size(conn, emb, batch_size)
2505}
2506
2507/// v0.7.0 issue #1260 — explicit-batch-size variant of
2508/// [`run_embedding_backfill`]. Honors the canonical #1146 precedence
2509/// ladder by accepting a pre-resolved batch size from
2510/// [`crate::config::AppConfig::resolve_embeddings`].
2511///
2512/// `batch_size` is the post-resolver value — pass
2513/// `AppConfig::resolve_embeddings().backfill_batch as usize`. Zero or
2514/// out-of-band values are coerced up to
2515/// [`DEFAULT_EMBED_BACKFILL_BATCH_SIZE`] defensively (a zero batch
2516/// would make the bounded fetch a no-op-forever).
2517///
2518/// # Errors
2519///
2520/// Same contract as [`run_embedding_backfill`] — only the bounded
2521/// `get_unembedded_ids_batch` scans can propagate.
2522pub fn run_embedding_backfill_with_batch_size(
2523    conn: &mut rusqlite::Connection,
2524    emb: &dyn Embed,
2525    batch_size: usize,
2526) -> anyhow::Result<usize> {
2527    // Defensive: a zero batch would make the bounded fetch below a
2528    // no-op-forever. The resolver clamps to 1..=10000 by construction
2529    // (`AppConfig::resolve_embeddings`), but if a future caller passes
2530    // an unvalidated value we coerce up to the compiled default rather
2531    // than blowing up.
2532    let batch_size = if batch_size == 0 {
2533        DEFAULT_EMBED_BACKFILL_BATCH_SIZE
2534    } else {
2535        batch_size
2536    };
2537
2538    // #1579 B6 (F5.6) + #1595 — bounded keyset drain loop. Each pass
2539    // fetches at most `batch_size` rows strictly after the cursor
2540    // (`db::get_unembedded_ids_batch_after`), so materialisation stays
2541    // bounded AND the cursor advances past skipped (persistently
2542    // failing / oversize) rows instead of re-fetching the same head
2543    // forever. The pre-#1595 head-scan + no-progress break meant one
2544    // poison row stopped the whole sweep with 0 rows backfilled.
2545    // Termination is structural: every non-empty fetch strictly
2546    // advances the cursor.
2547    let mut ok = 0usize;
2548    let mut skipped = 0usize;
2549    let mut scanned = 0usize;
2550    let mut announced = false;
2551    let mut cursor: Option<String> = None;
2552    loop {
2553        let chunk = db::get_unembedded_ids_batch_after(conn, cursor.as_deref(), batch_size)?;
2554        if chunk.is_empty() {
2555            // Idempotence: zero rows scanned ⇒ zero work; on the
2556            // first pass no log line is emitted so re-runs on a
2557            // steady-state DB stay silent.
2558            break;
2559        }
2560        if !announced {
2561            eprintln!("ai-memory: backfilling unembedded memories (batch_size={batch_size})...");
2562            announced = true;
2563        }
2564        scanned += chunk.len();
2565        cursor = chunk.last().map(|(id, _, _)| id.clone());
2566
2567        let embedded = embed_rows_with_fallback(emb, &chunk);
2568        for (id, reason) in &embedded.skipped {
2569            eprintln!("ai-memory: backfill skipped row {id}: {reason} (#1595)");
2570        }
2571        skipped += embedded.skipped.len();
2572        if embedded.entries.is_empty() {
2573            continue;
2574        }
2575
2576        match db::set_embeddings_batch(conn, &embedded.entries) {
2577            Ok(n) => ok += n,
2578            Err(e) => {
2579                // #1595 — a chunk-level write fault (e.g. one row
2580                // tripping the G4 namespace-dim invariant) falls back
2581                // to per-row writes so the rest of the chunk still
2582                // lands; rows that still fail are skipped with a WARN.
2583                eprintln!(
2584                    "ai-memory: set_embeddings_batch failed for chunk of {} rows: {e} \
2585                     — falling back to per-row writes (#1595)",
2586                    embedded.entries.len()
2587                );
2588                for (id, v) in &embedded.entries {
2589                    match db::set_embedding(conn, id, v) {
2590                        Ok(()) => ok += 1,
2591                        Err(e) => {
2592                            eprintln!("ai-memory: backfill skipped row {id}: {e} (#1595)");
2593                            skipped += 1;
2594                        }
2595                    }
2596                }
2597            }
2598        }
2599    }
2600
2601    if scanned > 0 {
2602        eprintln!("ai-memory: backfilled {ok}/{scanned} (skipped {skipped})");
2603    }
2604    Ok(ok)
2605}
2606
2607/// #1595 — vectors-plus-skips outcome of embedding one fetched chunk.
2608/// Shared by the boot backfill sweep and the `ai-memory reembed` CLI
2609/// so their resilience semantics cannot drift.
2610pub(crate) struct EmbeddedRows {
2611    /// `(id, vector)` pairs ready for a batched write.
2612    pub(crate) entries: Vec<(String, Vec<f32>)>,
2613    /// `(id, reason)` pairs for rows that could not be embedded this
2614    /// pass (oversize input or per-row embedder failure).
2615    pub(crate) skipped: Vec<(String, String)>,
2616}
2617
2618/// #1595 — embed a chunk of `(id, title, content)` rows with per-row
2619/// failure isolation:
2620///
2621/// 1. Rows whose canonical document text
2622///    ([`crate::embeddings::embedding_document`]) exceeds
2623///    [`crate::embeddings::EMBED_MAX_BYTES`] are skipped CLIENT-SIDE
2624///    (same guard + reason text as `embed_with_status` on the store
2625///    path) — they are never sent to the backend at all.
2626/// 2. The remaining rows go through one [`Embed::embed_batch`] call.
2627/// 3. On a chunk-level fault (error OR a vector-count misalignment),
2628///    fall back to per-row [`Embed::embed`]; rows that still fail are
2629///    reported in `skipped` with the embedder's error as the reason.
2630///
2631/// Pure with respect to the DB — callers own the write (checked
2632/// `set_embeddings_batch` for backfill, replace-semantics
2633/// `set_embeddings_batch_reembed` for the #1598 migration sweep) and
2634/// the WARN emission, so the helper stays unit-testable without I/O.
2635pub(crate) fn embed_rows_with_fallback(
2636    emb: &dyn Embed,
2637    rows: &[(String, String, String)],
2638) -> EmbeddedRows {
2639    let mut skipped: Vec<(String, String)> = Vec::new();
2640    // (id, document text) pairs that pass the client-side size guard.
2641    let mut candidates: Vec<(&str, String)> = Vec::with_capacity(rows.len());
2642    for (id, title, content) in rows {
2643        let text = crate::embeddings::embedding_document(title, content);
2644        if let Some(reason) = crate::embeddings::oversize_embed_reason(text.len()) {
2645            skipped.push((id.clone(), reason));
2646        } else {
2647            candidates.push((id.as_str(), text));
2648        }
2649    }
2650
2651    let text_refs: Vec<&str> = candidates.iter().map(|(_, t)| t.as_str()).collect();
2652    let mut entries: Vec<(String, Vec<f32>)> = Vec::with_capacity(candidates.len());
2653    let batch = if text_refs.is_empty() {
2654        Ok(Vec::new())
2655    } else {
2656        emb.embed_batch(&text_refs)
2657    };
2658    match batch {
2659        // The happy path requires the embedder contract (one vector
2660        // per input) to hold; a misaligned result would pair ids with
2661        // the wrong vectors and silently corrupt semantic recall.
2662        Ok(vectors) if vectors.len() == candidates.len() => {
2663            entries.extend(
2664                candidates
2665                    .iter()
2666                    .zip(vectors)
2667                    .map(|((id, _), v)| ((*id).to_string(), v)),
2668            );
2669        }
2670        batch_fault => {
2671            match &batch_fault {
2672                Ok(vectors) => eprintln!(
2673                    "ai-memory: embed_batch returned {} vectors for {} inputs — \
2674                     falling back to per-row embeds for this chunk (#1595)",
2675                    vectors.len(),
2676                    candidates.len()
2677                ),
2678                Err(e) => eprintln!(
2679                    "ai-memory: embed_batch failed for chunk of {} rows: {e} — \
2680                     falling back to per-row embeds (#1595)",
2681                    candidates.len()
2682                ),
2683            }
2684            for (id, text) in &candidates {
2685                match emb.embed(text) {
2686                    Ok(v) => entries.push(((*id).to_string(), v)),
2687                    Err(e) => skipped.push(((*id).to_string(), format!("{e:#}"))),
2688                }
2689            }
2690        }
2691    }
2692
2693    EmbeddedRows { entries, skipped }
2694}
2695
2696/// Run the MCP server over stdio. Blocks until stdin closes.
2697/// Initializes components based on the requested feature tier.
2698///
2699/// `profile` (v0.6.4-001) selects the tool surface advertised through
2700/// `tools/list`. Today the parameter is plumbed through and recorded in
2701/// the boot manifest; the family-scoped registration filter that
2702/// actually gates which tools land in `tools/list` is wired in
2703/// v0.6.4-002 (#522). Until that lands, every profile shows the full
2704/// 43-tool surface — the resolution step still runs so the parse error
2705/// path is exercised (and asserted in the integration tests).
2706/// #1249 — per-line byte cap on the MCP stdio JSON-RPC parser. 16 MiB
2707/// is well above the largest realistic tool-input payload (the wire
2708/// trimmer keeps `tools/list` under ~11k cl100k tokens, ~50 KB of JSON;
2709/// the heaviest individual store/recall calls are under 1 MB) while
2710/// still bounded enough that an OOM-vector peer cannot exhaust the
2711/// daemon's address space. On overrun the loop emits a `-32700` parse
2712/// error, drains the rest of the offending line, and continues serving.
2713pub const MCP_MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
2714
2715/// #1249 — hard ceiling on the post-overrun drain so a peer that
2716/// streams a never-ending sequence of non-newline bytes cannot keep
2717/// the daemon's drain loop spinning forever. When this ceiling fires
2718/// the daemon emits a final `-32700` and exits cleanly so an operator
2719/// process supervisor can restart it.
2720pub const MCP_MAX_DRAIN_BYTES: usize = 64 * 1024 * 1024;
2721
2722#[allow(clippy::too_many_lines)]
2723#[allow(deprecated)] // DOC-6: legacy AppConfig.llm_model / embedding_model fallback
2724pub fn run_mcp_server(
2725    db_path: &Path,
2726    tier: FeatureTier,
2727    app_config: &AppConfig,
2728    profile: &crate::profile::Profile,
2729) -> anyhow::Result<()> {
2730    // Pillar 3 / Stream E — wire `tracing` for the MCP entrypoint so the
2731    // per-tool spans added in `handle_request` actually surface. The
2732    // writer is pinned to stderr because stdio JSON-RPC owns stdout;
2733    // emitting trace lines there would corrupt the protocol. `try_init`
2734    // is a no-op if a subscriber was already installed by another
2735    // command in the same process.
2736    let _ = tracing_subscriber::fmt()
2737        .with_env_filter(
2738            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
2739                tracing_subscriber::EnvFilter::new(crate::logging::DEFAULT_LOG_DIRECTIVE)
2740            }),
2741        )
2742        .with_writer(std::io::stderr)
2743        .try_init();
2744
2745    let mut conn = db::open(db_path)?;
2746
2747    // #1583 (SEC, MED) — install the substrate `GOVERNANCE_PRE_WRITE`
2748    // agent-action gate on the MCP write surface. Pre-#1583 the hook
2749    // was installed ONLY by the HTTP daemon (`bootstrap_serve`), so an
2750    // operator's `memory_write` agent-action rules were silently
2751    // bypassed for every MCP-driven `memory_store` / `memory_consolidate`
2752    // / `memory_reflect` — the primary NHI agent interface. (Namespace
2753    // CorePolicy standards were always enforced via `db::enforce_governance`
2754    // on the store path; this closes the SEPARATE agent-action layer.)
2755    //
2756    // The hook fires synchronously from inside `storage::insert`, which
2757    // borrows `conn`, so it needs its OWN consultation connection. When
2758    // that open fails the hook fails CLOSED (#1455). The deferred-audit
2759    // drainer chain-logs refusals; its supervisor thread is detached for
2760    // the lifetime of the MCP process.
2761    let (mcp_governance_queue, _mcp_governance_supervisor) =
2762        crate::governance::deferred_audit::install_deferred_audit_drainer(db_path);
2763    let mcp_rule_cache = std::sync::Arc::new(crate::governance::rule_cache::RuleCache::new());
2764    let mcp_hook_conn: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>> =
2765        match db::open(db_path) {
2766            Ok(c) => Some(std::sync::Arc::new(std::sync::Mutex::new(c))),
2767            Err(e) => {
2768                eprintln!(
2769                    "ai-memory: #1583 — failed to open governance consultation connection \
2770                     ({e}); the pre-write gate will fail CLOSED on every MCP write"
2771                );
2772                None
2773            }
2774        };
2775    crate::daemon_runtime::install_governance_pre_write_hook(
2776        db_path,
2777        &mcp_governance_queue,
2778        &mcp_rule_cache,
2779        mcp_hook_conn.clone(),
2780    );
2781    // #1685 — also install the wire-action egress gate on the MCP surface.
2782    // Previously ONLY `serve` (HTTP) installed GOVERNANCE_PRE_ACTION, so the
2783    // skill_export (FilesystemWrite) + LLM (NetworkRequest) egress sinks
2784    // fail-OPEN under `ai-memory mcp` — the primary NHI interface. Same shared
2785    // installer + the same long-lived consultation connection.
2786    crate::daemon_runtime::install_governance_pre_action_hook(
2787        db_path,
2788        &mcp_governance_queue,
2789        &mcp_rule_cache,
2790        mcp_hook_conn,
2791    );
2792
2793    let stdin = io::stdin();
2794    let mut stdout = io::stdout();
2795
2796    let mut tier_config = tier.config();
2797    eprintln!("ai-memory: requested tier = {}", tier.as_str());
2798    // v0.6.4-001 — log resolved profile so an operator inspecting MCP
2799    // boot stderr can immediately see which tool surface is active.
2800    // Family-scoped filtering of tools/list arrives in v0.6.4-002.
2801    let family_names: Vec<&'static str> = profile.families().iter().map(|f| f.name()).collect();
2802    eprintln!(
2803        "ai-memory: profile = {} families ({}); expected tool count = {}",
2804        profile.families().len(),
2805        family_names.join(", "),
2806        profile.expected_tool_count()
2807    );
2808
2809    // Apply config.toml overrides — tiers gate features, models are independently configurable.
2810    // Only override if the tier actually uses an LLM (smart/autonomous). The legacy flat-field
2811    // `llm_model` is provider/model-agnostic (#1067 / #1146 / #1490): ANY model string is honored
2812    // here and the concrete backend/model is resolved through `resolve_llm` below (which folds
2813    // this same flat field through its Legacy arm). This is only a tier-gate carrier — no vendor
2814    // or model name is hardcoded, and no override is silently dropped.
2815    if tier_config.llm_model.is_some()
2816        && let Some(ref llm_override) = app_config.llm_model
2817    {
2818        let trimmed = llm_override.trim();
2819        if trimmed.is_empty() {
2820            eprintln!("ai-memory: empty llm_model override ignored, using tier default");
2821        } else {
2822            tier_config.llm_model = Some(trimmed.to_string());
2823            eprintln!("ai-memory: llm_model override from config: {trimmed}");
2824        }
2825    }
2826
2827    // Apply embedding model override from config.toml
2828    if tier_config.embedding_model.is_some()
2829        && let Some(ref emb_override) = app_config.embedding_model
2830    {
2831        match emb_override.as_str() {
2832            "mini_lm_l6_v2" => {
2833                tier_config.embedding_model = Some(crate::config::EmbeddingModel::MiniLmL6V2);
2834                eprintln!("ai-memory: embedding_model override from config: mini_lm_l6_v2 (local)");
2835            }
2836            "nomic_embed_v15" => {
2837                tier_config.embedding_model = Some(crate::config::EmbeddingModel::NomicEmbedV15);
2838                eprintln!(
2839                    "ai-memory: embedding_model override from config: nomic_embed_v15 (Ollama)"
2840                );
2841            }
2842            other => {
2843                eprintln!("ai-memory: unknown embedding_model '{other}', using tier default");
2844            }
2845        }
2846    }
2847
2848    // --- Initialize LLM (smart tier and above) — before embedder so Ollama
2849    //     client can be shared with nomic embedder ---
2850    //
2851    // #1142 ported the env-aware resolver from
2852    // `src/daemon_runtime.rs:1746-1798` into MCP stdio. #1143 collapsed
2853    // the inline env-vs-legacy match into the shared
2854    // `OllamaClient::build_for_init` helper so every synchronous LLM
2855    // init site (MCP stdio LLM, MCP embed fallback, CLI `atomise`,
2856    // CLI `curator`) shares one resolution rule. Behavioural parity
2857    // with the daemon's async wrapper is pinned by tests in
2858    // `src/llm.rs::tests::build_for_init_*`.
2859    // v0.7.x (#1146) — single canonical entry through the resolver.
2860    // The resolver folds CLI flags (none at MCP boot), AI_MEMORY_LLM_*
2861    // env vars, the [llm] config section, the legacy
2862    // `llm_model`/`ollama_url` flat fields, and the compiled tier
2863    // preset. The provenance fields on `ResolvedLlm` (`source`,
2864    // `api_key_source`) surface in the startup banner so the operator
2865    // can see WHICH layer won.
2866    let resolved_llm = app_config.resolve_llm(None, None, None);
2867    let llm: Option<Arc<OllamaClient>> = if tier_config.llm_model.is_none()
2868        && resolved_llm.source == crate::config::ConfigSource::CompiledDefault
2869    {
2870        // Keyword tier with no operator config: LLM intentionally disabled.
2871        None
2872    } else {
2873        match OllamaClient::build_from_resolved(&resolved_llm) {
2874            Ok(Some(client)) => {
2875                eprintln!(
2876                    "ai-memory: LLM ready (backend={}, model={}, source={}, key_source={})",
2877                    resolved_llm.backend,
2878                    resolved_llm.model,
2879                    resolved_llm.source.as_str(),
2880                    resolved_llm.api_key_source.as_str(),
2881                );
2882                // For ollama backends, exercise the legacy ensure_model
2883                // pull step so first-run UX (model not on disk) still
2884                // emits the pull-progress hint. Backend identifier comes
2885                // from `crate::llm::BACKEND_OLLAMA` per the PR10 vendor-
2886                // monoculture lint gate (issue #1174).
2887                if resolved_llm.backend == crate::llm::BACKEND_OLLAMA {
2888                    if let Err(e) = client.ensure_model() {
2889                        eprintln!("ai-memory: model pull failed: {e} (LLM features disabled)");
2890                        None
2891                    } else {
2892                        Some(Arc::new(client))
2893                    }
2894                } else {
2895                    Some(Arc::new(client))
2896                }
2897            }
2898            Ok(None) => {
2899                eprintln!(
2900                    "ai-memory: LLM disabled — resolver returned no client \
2901                     (backend={}, source={})",
2902                    resolved_llm.backend,
2903                    resolved_llm.source.as_str(),
2904                );
2905                None
2906            }
2907            Err(e) => {
2908                eprintln!(
2909                    "ai-memory: LLM init failed (backend={}, source={}): {e} \
2910                     (LLM features disabled)",
2911                    resolved_llm.backend,
2912                    resolved_llm.source.as_str(),
2913                );
2914                None
2915            }
2916        }
2917    };
2918
2919    // --- Initialize embedder (semantic tier and above) ---
2920    //
2921    // #1598 — single shared boot entry: `Embedder::from_resolved`
2922    // consumes the canonical `AppConfig::resolve_embeddings()` ladder
2923    // (AI_MEMORY_EMBED_* env > [embeddings] section > legacy flat >
2924    // compiled default) and builds either an OpenAI-compatible remote
2925    // embed client (API backends: OpenRouter, HF TEI, vLLM, …) or the
2926    // historical dedicated-Ollama embed client. This supersedes the
2927    // #1143 clone-the-LLM-client heuristic at this site: the embed
2928    // client is ALWAYS its own client now, never a clone of the chat
2929    // LLM client.
2930    //
2931    // #1593 FAIL-CLOSED: when embedder construction fails, the daemon
2932    // degrades to keyword recall (embedder = None) with a loud stderr
2933    // breadcrumb — it NEVER routes embedding requests through the
2934    // chat LLM client (the pre-#1143 silent-wrong-wire-shape trap).
2935    let resolved_embeddings = app_config.resolve_embeddings();
2936    let embedder = match Embedder::from_resolved(&resolved_embeddings, tier_config.embedding_model)
2937    {
2938        Ok(Some(emb)) => {
2939            eprintln!("ai-memory: embedder loaded ({})", emb.model_description());
2940            // Backfill embeddings for memories that don't have them.
2941            // v0.7.0 Wave-2 A5 (issue #853): scan all unembedded rows
2942            // in a single query, then chunk into fixed-size batches
2943            // and call `embed_batch` + `set_embeddings_batch` per
2944            // chunk. This collapses N per-row UPDATE round-trips into
2945            // ceil(N/B) transaction commits and creates the surface
2946            // for a vectorised embedder backend to land later.
2947            //
2948            // v0.7.0 issue #1260 — batch size from the canonical #1146
2949            // precedence ladder (AI_MEMORY_EMBED_BACKFILL_BATCH env >
2950            // [embeddings].backfill_batch config > compiled default
2951            // 100), via the same resolver output the embedder was
2952            // built from.
2953            let embed_batch_size = resolved_embeddings.backfill_batch as usize;
2954            if let Err(e) =
2955                run_embedding_backfill_with_batch_size(&mut conn, &emb, embed_batch_size)
2956            {
2957                eprintln!("ai-memory: backfill failed: {e}");
2958            }
2959            Some(emb)
2960        }
2961        Ok(None) => None,
2962        Err(e) => {
2963            eprintln!(
2964                "ai-memory: embedder init failed (backend={}, model={}, url={}, \
2965                 source={}): {e:#} — semantic recall DEGRADED to keyword \
2966                 (#1143, #1593, #1598)",
2967                resolved_embeddings.backend,
2968                resolved_embeddings.model,
2969                resolved_embeddings.url,
2970                resolved_embeddings.source.as_str(),
2971            );
2972            None
2973        }
2974    };
2975
2976    // --- Build HNSW vector index (semantic tier and above) ---
2977    //
2978    // #1579 B3 — async boot. Pre-#1579 this site ran
2979    // `get_all_embeddings` + `VectorIndex::build` SYNCHRONOUSLY before
2980    // the stdio loop started (P1 audit: 40 s to `initialize` at 10k
2981    // vectors, >28 min at 100k). The server now answers immediately
2982    // with an EMPTY index while a background thread loads the stored
2983    // embeddings over its own connection and warms the graph through
2984    // the #968 double-buffer rebuild (`VectorIndex::warm_boot`).
2985    // Until the swap lands, semantic recall serves the keyword/FTS
2986    // blend and the #519 proactive conflict check uses its
2987    // bounded-scan fallback (`is_fully_searchable` gating). The
2988    // `Arc` exists solely so the warm thread can share the index with
2989    // the single-threaded stdio loop; handlers still receive
2990    // `Option<&VectorIndex>` via `as_deref()`.
2991    let vector_index: Option<std::sync::Arc<VectorIndex>> = if embedder.is_some() {
2992        let idx = std::sync::Arc::new(VectorIndex::empty());
2993        eprintln!(
2994            "ai-memory: HNSW index warming in background; semantic recall \
2995             serves keyword/FTS results until the swap lands (#1579 B3)"
2996        );
2997        let warm_idx = std::sync::Arc::clone(&idx);
2998        let warm_db_path = db_path.to_path_buf();
2999        std::thread::spawn(move || {
3000            let started = std::time::Instant::now();
3001            let Some(entries) = crate::daemon_runtime::load_boot_index_entries(&warm_db_path)
3002            else {
3003                return;
3004            };
3005            if entries.is_empty() {
3006                eprintln!("ai-memory: no embeddings for HNSW index, using linear scan");
3007                return;
3008            }
3009            let total = entries.len();
3010            warm_idx.warm_boot(entries);
3011            eprintln!(
3012                "ai-memory: HNSW index ready ({total} entries, warmed in {:.1}s)",
3013                started.elapsed().as_secs_f64()
3014            );
3015        });
3016        Some(idx)
3017    } else {
3018        None
3019    };
3020
3021    // --- Initialize cross-encoder reranker (autonomous tier) ---
3022    //
3023    // v0.7 G9 — wrap the encoder in a `BatchedReranker` so concurrent
3024    // recall requests coalesce into a single tokenize+forward pass on
3025    // the BERT model, instead of serializing through the per-candidate
3026    // `Arc<Mutex<BertModel>>`.
3027    let reranker = if tier_config.cross_encoder {
3028        eprintln!("ai-memory: loading neural cross-encoder (ms-marco-MiniLM-L-6-v2)...");
3029        let ce = CrossEncoder::new_neural();
3030        if ce.is_neural() {
3031            eprintln!("ai-memory: neural cross-encoder ready (batched)");
3032        } else {
3033            eprintln!("ai-memory: using lexical cross-encoder fallback");
3034        }
3035        // #1691/n14 — apply the operator-configured score floor
3036        // (env > [reranker].score_floor > Off) instead of the hardcoded
3037        // Off the bare `new` constructor used; this is what makes the
3038        // with_score_floor capability reachable on the MCP recall path.
3039        Some(BatchedReranker::with_score_floor(
3040            ce,
3041            app_config.resolve_reranker_score_floor(),
3042        ))
3043    } else {
3044        None
3045    };
3046
3047    // Report effective tier
3048    let effective_tier = if llm.is_some() && embedder.is_some() && reranker.is_some() {
3049        EFFECTIVE_TIER_AUTONOMOUS
3050    } else if llm.is_some() && embedder.is_some() {
3051        "smart"
3052    } else if embedder.is_some() {
3053        "semantic"
3054    } else {
3055        "keyword"
3056    };
3057    eprintln!("ai-memory MCP server started (stdio, tier={effective_tier})");
3058
3059    // v0.7 Track H — H2 outbound link signing. Best-effort load of the
3060    // active agent's Ed25519 keypair from the default key dir. Missing
3061    // keypair = unsigned link writes (preserves v0.6.4 behaviour);
3062    // operator opts in by running `ai-memory identity generate`.
3063    let active_keypair: Option<crate::identity::keypair::AgentKeypair> =
3064        load_active_keypair_for_mcp();
3065    if active_keypair.is_some() {
3066        eprintln!("ai-memory: link signing enabled (Ed25519)");
3067    }
3068
3069    // v0.7.0 WT-1-C — `memory_atomise` MCP tool wiring. The atomiser
3070    // is built ONLY when an LLM is available (curator-pass tools
3071    // require the smart/autonomous tier). On the keyword and semantic
3072    // tiers (no LLM), the handler is wired as `None` and the dispatch
3073    // path returns the tier-locked advisory envelope.
3074    let atomise_handler: Option<std::sync::Arc<atomise::AtomiseToolHandler>> =
3075        if let Some(ref llm_client) = llm {
3076            let curator: Box<dyn crate::atomisation::curator::Curator> = Box::new(
3077                crate::atomisation::curator::LlmCurator::new(llm_client.clone()),
3078            );
3079            let keypair_arc = active_keypair
3080                .as_ref()
3081                .map(|kp| std::sync::Arc::new(kp.clone()));
3082            // v0.7.0 (#1244) — thread the resolved LLM model name into
3083            // the atomiser so the `atomisation_complete` signed-event
3084            // payload's `curator_model` field reflects what actually
3085            // ran on this deployment, not the pre-#1244 hardcoded
3086            // `"gemma4"`.
3087            let curator_model = llm_client.model_name().to_string();
3088            let atomiser = std::sync::Arc::new(
3089                crate::atomisation::Atomiser::new(
3090                    curator,
3091                    keypair_arc,
3092                    crate::atomisation::AtomiserConfig::default(),
3093                    tier_config.tier,
3094                )
3095                .with_curator_model(curator_model),
3096            );
3097            eprintln!("ai-memory: atomisation engine ready (curator=LlmCurator)");
3098            Some(std::sync::Arc::new(atomise::AtomiseToolHandler::new(
3099                atomiser,
3100                tier_config.tier,
3101            )))
3102        } else {
3103            None
3104        };
3105
3106    // v0.7.0 Form 3 (issue #756) — `memory_ingest_multistep` MCP tool
3107    // wiring. The handler is built only when an LLM is available
3108    // (Form 3 LLM stages require the smart/autonomous tier). On
3109    // keyword/semantic tiers the handler is wired as `None` and the
3110    // dispatch returns the tier-locked advisory envelope.
3111    let ingest_multistep_handler: Option<std::sync::Arc<ingest_multistep::IngestMultistepHandler>> =
3112        if let Some(ref llm_client) = llm {
3113            let dispatch: std::sync::Arc<dyn crate::multistep_ingest::LlmDispatch> =
3114                std::sync::Arc::new(crate::multistep_ingest::executor::OllamaDispatch::new(
3115                    llm_client.clone(),
3116                ));
3117            eprintln!("ai-memory: multi-step ingest orchestrator ready (Form 3)");
3118            Some(std::sync::Arc::new(
3119                ingest_multistep::IngestMultistepHandler::new(dispatch, tier_config.tier),
3120            ))
3121        } else {
3122            None
3123        };
3124
3125    // v0.7.x (issue #1168) — resolve the operator-configured LLM /
3126    // embeddings / reranker triple once. The triple is process-stable
3127    // (config is loaded at boot and never mutated) so we compute it
3128    // outside the per-request stdio loop and thread an immutable
3129    // borrow to every `handle_request` call. Threaded into the MCP
3130    // `dispatch_memory_capabilities` so `memory_capabilities.models.*`
3131    // reports the same backend / model the boot banner emits and the
3132    // live LLM client was built from.
3133    let resolved_models = app_config.resolve_models();
3134
3135    // Captured from the MCP `initialize` handshake's `clientInfo.name`.
3136    // Used by `crate::identity` to synthesize an `ai:<client>@<host>:pid-<pid>`
3137    // agent_id when the caller doesn't supply one explicitly.
3138    let mut mcp_client_name: Option<String> = None;
3139
3140    // v0.7.0 B4 — Harness detected from `clientInfo.name` at handshake
3141    // time. Stays `None` until we observe an `initialize` so the
3142    // capabilities-v3 response omits
3143    // `your_harness_supports_deferred_registration` (presence is
3144    // itself meaningful — absence means "we don't know").
3145    let mut detected_harness: Option<crate::harness::Harness> = None;
3146
3147    // v0.7.0 #1389 / #1398 L1 — capture-nag watcher for this stdio
3148    // session. One stdio connection == one `run_mcp_server` invocation,
3149    // so we mint a stable session id once and key the per-(agent_id,
3150    // session_id) streak counter on it for the process lifetime. The
3151    // watcher is dropped when this function returns (process exit), so
3152    // no explicit `drop_session` is needed on the stdio path. Thresholds
3153    // come from `AI_MEMORY_CAPTURE_NAG_THRESHOLD` /
3154    // `AI_MEMORY_CAPTURE_NAG_ESCALATE_THRESHOLD` (set either to 0 to
3155    // disable that tier).
3156    let nag_watcher = crate::recover::nag::CaptureNagWatcher::new_from_env();
3157    let nag_session_id = format!("mcp-{}", uuid::Uuid::new_v4());
3158
3159    // #1249 — DoS guard on the stdio JSON-RPC parser. The pre-#1249 loop
3160    // (`for line in stdin.lock().lines()`) had no per-line cap; a peer
3161    // that streamed an unbounded sequence of non-newline bytes drove
3162    // `BufRead::Lines` to grow its internal `String` allocation without
3163    // limit, OOM-killing the daemon. The replacement is a manual
3164    // `read_until('\n', &mut buf)` loop that enforces
3165    // [`MCP_MAX_LINE_BYTES`]: on overrun we emit a `-32700` parse error,
3166    // drain the rest of the offending line so the next request lines up
3167    // on a fresh boundary, and continue the loop.
3168    let mut stdin_locked = stdin.lock();
3169    let mut line_buf: Vec<u8> = Vec::with_capacity(8192);
3170    loop {
3171        line_buf.clear();
3172        // Take a per-line slice of stdin sized to MCP_MAX_LINE_BYTES + 1
3173        // so we can detect overrun (read_until returns the byte count
3174        // including the delimiter; when the cap is hit BEFORE we see a
3175        // newline `read_until` returns the cap bytes and we know to
3176        // drain the rest).
3177        let n = (&mut stdin_locked)
3178            .take((MCP_MAX_LINE_BYTES as u64) + 1)
3179            .read_until(b'\n', &mut line_buf)?;
3180        if n == 0 {
3181            // Clean EOF.
3182            break;
3183        }
3184        let overrun = line_buf.last() != Some(&b'\n') && n > MCP_MAX_LINE_BYTES;
3185        if overrun {
3186            // Drain the rest of this line so the next iteration starts
3187            // on a clean boundary. We discard the bytes; this also caps
3188            // the drain so a never-ending stream of non-newline bytes
3189            // doesn't spin forever in the drain loop.
3190            let mut scratch = [0u8; 8192];
3191            let mut drained: usize = 0;
3192            loop {
3193                if drained >= MCP_MAX_DRAIN_BYTES {
3194                    // Hard ceiling on drain — close the loop rather than
3195                    // serve an infinitely-streaming peer.
3196                    let resp = err_response(
3197                        Value::Null,
3198                        jsonrpc::PARSE_ERROR,
3199                        format!(
3200                            "parse error: line exceeded {MCP_MAX_LINE_BYTES} bytes \
3201                             and drain ceiling {MCP_MAX_DRAIN_BYTES} hit; closing stream"
3202                        ),
3203                    );
3204                    let out = serde_json::to_string(&resp)?;
3205                    writeln!(stdout, "{out}")?;
3206                    stdout.flush()?;
3207                    let _ = db::checkpoint(&conn);
3208                    eprintln!("ai-memory MCP server stopped (drain ceiling exceeded)");
3209                    return Ok(());
3210                }
3211                let m = stdin_locked.read(&mut scratch)?;
3212                if m == 0 {
3213                    break;
3214                }
3215                drained = drained.saturating_add(m);
3216                if scratch[..m].contains(&b'\n') {
3217                    break;
3218                }
3219            }
3220            let resp = err_response(
3221                Value::Null,
3222                jsonrpc::PARSE_ERROR,
3223                format!("parse error: line exceeded {MCP_MAX_LINE_BYTES} bytes"),
3224            );
3225            let out = serde_json::to_string(&resp)?;
3226            writeln!(stdout, "{out}")?;
3227            stdout.flush()?;
3228            continue;
3229        }
3230        // Trim trailing newline (and optional \r) before decoding.
3231        if line_buf.last() == Some(&b'\n') {
3232            line_buf.pop();
3233        }
3234        if line_buf.last() == Some(&b'\r') {
3235            line_buf.pop();
3236        }
3237        let line = match std::str::from_utf8(&line_buf) {
3238            Ok(s) => s,
3239            Err(e) => {
3240                let resp = err_response(
3241                    Value::Null,
3242                    jsonrpc::PARSE_ERROR,
3243                    format!("parse error: invalid UTF-8: {e}"),
3244                );
3245                let out = serde_json::to_string(&resp)?;
3246                writeln!(stdout, "{out}")?;
3247                stdout.flush()?;
3248                continue;
3249            }
3250        };
3251        if line.trim().is_empty() {
3252            continue;
3253        }
3254
3255        let req: RpcRequest = match serde_json::from_str(line) {
3256            Ok(r) => r,
3257            Err(e) => {
3258                let resp = err_response(
3259                    Value::Null,
3260                    jsonrpc::PARSE_ERROR,
3261                    format!("parse error: {e}"),
3262                );
3263                let out = serde_json::to_string(&resp)?;
3264                writeln!(stdout, "{out}")?;
3265                stdout.flush()?;
3266                continue;
3267            }
3268        };
3269
3270        // Capture clientInfo.name on initialize (even if id is Null / notification-style).
3271        if req.method == jsonrpc::METHOD_INITIALIZE
3272            && let Some(name) = req.params["clientInfo"]["name"].as_str()
3273            && !name.is_empty()
3274        {
3275            mcp_client_name = Some(name.to_string());
3276            // v0.7.0 B4 — detect the harness so capabilities-v3 +
3277            // future B1/B2 loaders can shape responses based on
3278            // whether the harness supports deferred-tool registration.
3279            detected_harness = Some(crate::harness::Harness::detect(name));
3280        }
3281
3282        // Notifications have no id — no response expected per JSON-RPC spec
3283        if req.id.is_none() || req.id == Some(Value::Null) {
3284            continue;
3285        }
3286
3287        let resolved_ttl = app_config.effective_ttl();
3288        let resolved_scoring = app_config.effective_scoring();
3289        let archive_on_gc = app_config.effective_archive_on_gc();
3290        let autonomous_hooks = app_config.effective_autonomous_hooks();
3291        let resolved_recall_scope = app_config.effective_recall_scope();
3292        let resp = handle_request(
3293            &conn,
3294            db_path,
3295            &req,
3296            embedder.as_ref().map(|e| e as &dyn Embed),
3297            llm.as_deref(),
3298            reranker.as_ref(),
3299            &tier_config,
3300            &resolved_models,
3301            vector_index.as_deref(),
3302            &resolved_ttl,
3303            &resolved_scoring,
3304            archive_on_gc,
3305            autonomous_hooks,
3306            mcp_client_name.as_deref(),
3307            profile,
3308            app_config.mcp.as_ref(),
3309            active_keypair.as_ref(),
3310            detected_harness.as_ref(),
3311            app_config.mcp_federation_forward_url.as_deref(),
3312            resolved_recall_scope,
3313            atomise_handler.as_deref(),
3314            ingest_multistep_handler.as_deref(),
3315            Some(&nag_watcher),
3316            &nag_session_id,
3317        );
3318        let out = serde_json::to_string(&resp)?;
3319        writeln!(stdout, "{out}")?;
3320        stdout.flush()?;
3321    }
3322
3323    let _ = db::checkpoint(&conn);
3324    eprintln!("ai-memory MCP server stopped");
3325    Ok(())
3326}
3327
3328#[cfg(test)]
3329mod tests {
3330    use super::*;
3331    use crate::models::{Memory, Tier};
3332    use serde_json::json;
3333
3334    // ----- issue #965 audit: MCP dispatch has NO Arc<Mutex<Connection>> -----
3335    //
3336    // Wave-2 Tier-B5 (#965) was filed under the premise that "MCP stdio
3337    // path holds a single Arc<Mutex<Connection>> that serialises every
3338    // tool dispatch." Audit (sub-agent H, 2026-05-21) found this premise
3339    // verifiably false:
3340    //
3341    //   1. `run_mcp_server` opens a plain `rusqlite::Connection` via
3342    //      `db::open` (no Arc, no Mutex) — `src/mcp/mod.rs:2013`.
3343    //   2. The stdio loop is a manual `read_until('\n')` loop (post-#1249
3344    //      DoS hardening; pre-#1249 it was `for line in stdin.lock().lines()`)
3345    //      — synchronous and single-threaded by JSON-RPC stdio protocol
3346    //      design.
3347    //   3. `handle_request` and `ToolDispatchCtx` carry a plain
3348    //      `&rusqlite::Connection` reference — no shared-state wrapper —
3349    //      `src/mcp/mod.rs:1519,846`.
3350    //   4. There is NO lock contention because there is NO concurrent
3351    //      access. Adding r2d2 to a single-threaded loop adds dependency
3352    //      + per-acquire latency for ZERO throughput benefit.
3353    //
3354    // The HTTP daemon path (`src/handlers/transport.rs:22`) IS the
3355    // `Arc<Mutex<(Connection, ...)>>` site, but that is a separate
3356    // refactor (HTTP is more contention-tolerant because Axum's task
3357    // pool already gates concurrent dispatch) and out of scope for
3358    // #965 per the Wave-2 Tier-B5 framing.
3359    //
3360    // These tests pin the invariant at compile time + at runtime so any
3361    // future refactor that re-introduces an Arc<Mutex<Connection>> in
3362    // the MCP layer surfaces as a test failure rather than a silent
3363    // performance regression.
3364
3365    /// Compile-time assertion: `ToolDispatchCtx::conn` must be a plain
3366    /// `&Connection` reference, NOT `&Arc<Mutex<Connection>>` or any
3367    /// other shared-state wrapper. This is enforced by the type
3368    /// signature itself; the test serves as a load-bearing comment for
3369    /// future maintainers.
3370    #[test]
3371    fn issue_965_audit_tool_dispatch_ctx_holds_plain_connection_ref() {
3372        // If the field type ever changes from `&'a rusqlite::Connection`
3373        // to anything else, this assertion fails to compile. The
3374        // helper closure captures the type via a no-op assignment.
3375        fn _type_check<'a>(ctx: &ToolDispatchCtx<'a>) -> &'a rusqlite::Connection {
3376            ctx.conn
3377        }
3378        // Runtime smoke: assertion must reach this line — proves the
3379        // module compiles with the expected ctx shape.
3380        let _ = _type_check;
3381    }
3382
3383    /// Compile-time assertion: `handle_request` must take a plain
3384    /// `&rusqlite::Connection`, NOT `&Arc<Mutex<Connection>>`. If a
3385    /// future "concurrency refactor" pushes a Mutex back in here, this
3386    /// fails to compile.
3387    #[test]
3388    fn issue_965_audit_handle_request_takes_plain_connection_ref() {
3389        // The function pointer type tells the audit story: first arg is
3390        // `&rusqlite::Connection`. Coercing the symbol into this
3391        // pointer type at compile time pins the invariant.
3392        let _: fn(
3393            &rusqlite::Connection,
3394            &Path,
3395            &RpcRequest,
3396            Option<&dyn Embed>,
3397            Option<&OllamaClient>,
3398            Option<&BatchedReranker>,
3399            &TierConfig,
3400            &crate::config::ResolvedModels,
3401            Option<&VectorIndex>,
3402            &crate::config::ResolvedTtl,
3403            &crate::config::ResolvedScoring,
3404            bool,
3405            bool,
3406            Option<&str>,
3407            &crate::profile::Profile,
3408            Option<&crate::config::McpConfig>,
3409            Option<&crate::identity::keypair::AgentKeypair>,
3410            Option<&crate::harness::Harness>,
3411            Option<&str>,
3412            Option<&crate::config::RecallScope>,
3413            Option<&atomise::AtomiseToolHandler>,
3414            Option<&ingest_multistep::IngestMultistepHandler>,
3415            Option<&crate::recover::nag::CaptureNagWatcher>,
3416            &str,
3417        ) -> RpcResponse = handle_request;
3418    }
3419
3420    /// Stress probe: serially dispatch 50 MCP tool calls through the
3421    /// real `handle_request` path against a single `Connection` and
3422    /// confirm every response is `error: None` and that the underlying
3423    /// row store reflects the writes. This is the closest analogue to
3424    /// the prompt's "50 concurrent tool dispatch" stress test that the
3425    /// single-threaded MCP stdio architecture admits — concurrent
3426    /// dispatch is impossible at the stdio JSON-RPC layer (one line in,
3427    /// one line out), so the meaningful stress shape is sustained
3428    /// serial dispatch, which this test exercises.
3429    #[test]
3430    fn issue_965_audit_serial_dispatch_50_calls_through_single_connection() {
3431        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
3432        let conn = db::open(tmp.path()).expect("open db");
3433        let tier_config = crate::config::FeatureTier::Keyword.config();
3434        let resolved_ttl = crate::config::ResolvedTtl::default();
3435        let resolved_scoring = crate::config::ResolvedScoring::default();
3436        let profile = crate::profile::Profile::full();
3437
3438        for i in 0..50 {
3439            let req = RpcRequest {
3440                jsonrpc: "2.0".into(),
3441                id: Some(json!(i)),
3442                method: "tools/call".into(),
3443                params: json!({
3444                    "name": "memory_store",
3445                    "arguments": {
3446                        "title": format!("issue-965-stress-{i}"),
3447                        "content": format!("audit stress probe iteration {i}"),
3448                        "tier": Tier::Short.as_str(),
3449                        "namespace": "issue-965-audit",
3450                    },
3451                }),
3452            };
3453            let resp = handle_request(
3454                &conn,
3455                tmp.path(),
3456                &req,
3457                None, // embedder
3458                None, // llm
3459                None, // reranker
3460                &tier_config,
3461                &crate::config::ResolvedModels::from_tier_preset(&tier_config),
3462                None, // vector_index
3463                &resolved_ttl,
3464                &resolved_scoring,
3465                true,  // archive_on_gc
3466                false, // autonomous_hooks
3467                None,  // mcp_client
3468                &profile,
3469                None,           // mcp_config
3470                None,           // active_keypair
3471                None,           // harness
3472                None,           // federation_forward_url
3473                None,           // recall_scope
3474                None,           // atomise_handler
3475                None,           // ingest_multistep_handler
3476                None,           // nag_watcher (#1389/#1398 L1)
3477                "test-session", // nag_session_id (#1389/#1398 L1)
3478            );
3479            assert!(
3480                resp.error.is_none(),
3481                "iter {i} surfaced error: {:?}",
3482                resp.error
3483            );
3484        }
3485
3486        // All 50 writes landed in the same Connection. Count via direct
3487        // SQL — proves the serial path is durable + correct without
3488        // needing a connection pool to mediate.
3489        let n: i64 = conn
3490            .query_row(
3491                "SELECT COUNT(*) FROM memories WHERE namespace = 'issue-965-audit'",
3492                [],
3493                |r| r.get(0),
3494            )
3495            .expect("count query");
3496        assert_eq!(n, 50, "expected 50 audit-stress rows, found {n}");
3497    }
3498
3499    // ----- issue #811 verification: load_active_keypair_for_mcp fallback -----
3500
3501    #[test]
3502    fn issue_811_load_active_keypair_for_mcp_picks_agent_specific_when_present() {
3503        let dir = tempfile::TempDir::new().unwrap();
3504        let kp = crate::identity::keypair::generate("ai:alice").unwrap();
3505        crate::identity::keypair::save(&kp, dir.path()).unwrap();
3506        let loaded = super::load_active_keypair_for_mcp_in(dir.path(), Some("ai:alice"))
3507            .expect("agent-specific keypair should resolve when on disk");
3508        assert!(
3509            loaded.can_sign(),
3510            "loaded agent-specific keypair must carry a private half"
3511        );
3512        assert_eq!(loaded.agent_id, "ai:alice");
3513    }
3514
3515    #[test]
3516    fn issue_811_load_active_keypair_for_mcp_falls_back_to_daemon_when_agent_specific_missing() {
3517        // The regression: the live MCP resolved agent_id to a host-id
3518        // (e.g. `host:host.local:pid-XYZ`) for which no keypair file
3519        // ever exists; the substrate-managed `daemon` key sat on disk
3520        // unused. This asserts the fallback so the persona pipeline
3521        // signs end-to-end even without a per-NHI keypair enrolled.
3522        let dir = tempfile::TempDir::new().unwrap();
3523        let daemon_kp = crate::identity::keypair::generate("daemon").unwrap();
3524        crate::identity::keypair::save(&daemon_kp, dir.path()).unwrap();
3525        let loaded =
3526            super::load_active_keypair_for_mcp_in(dir.path(), Some("host:host.local:pid-12345"))
3527                .expect("daemon fallback must engage when agent-specific key absent");
3528        assert!(
3529            loaded.can_sign(),
3530            "daemon fallback keypair must carry a private half"
3531        );
3532        assert_eq!(loaded.agent_id, "daemon");
3533    }
3534
3535    #[test]
3536    fn issue_811_load_active_keypair_for_mcp_returns_none_when_neither_present() {
3537        let dir = tempfile::TempDir::new().unwrap();
3538        let loaded = super::load_active_keypair_for_mcp_in(dir.path(), Some("ai:none"));
3539        assert!(
3540            loaded.is_none(),
3541            "no key files → None (preserves v0.6.4 unsigned behaviour)"
3542        );
3543    }
3544
3545    #[test]
3546    fn issue_811_load_active_keypair_for_mcp_falls_back_when_agent_id_unresolvable() {
3547        // `agent_id = None` simulates `resolve_agent_id` failing entirely;
3548        // daemon fallback must still engage.
3549        let dir = tempfile::TempDir::new().unwrap();
3550        let daemon_kp = crate::identity::keypair::generate("daemon").unwrap();
3551        crate::identity::keypair::save(&daemon_kp, dir.path()).unwrap();
3552        let loaded = super::load_active_keypair_for_mcp_in(dir.path(), None)
3553            .expect("daemon fallback must engage when agent_id resolution fails");
3554        assert_eq!(loaded.agent_id, "daemon");
3555    }
3556
3557    #[test]
3558    fn tool_definitions_returns_full_profile_count() {
3559        // The live `tool_definitions()` surface must advertise exactly
3560        // the full-profile tool set. Anchored on the tool-count SSOT
3561        // (`Profile::full().expected_tool_count()`, derived from the
3562        // per-Family `tool_names` slices) so adding a tool touches ONE
3563        // site (the family slice), never a hardcoded literal here.
3564        let defs = tool_definitions();
3565        let tools = defs["tools"].as_array().unwrap();
3566        assert_eq!(
3567            tools.len(),
3568            crate::profile::Profile::full().expected_tool_count()
3569        );
3570    }
3571
3572    /// v0.6.4-002 acceptance gate (RFC §S25/S26): `--profile core`
3573    /// registers exactly 7 family tools (5 baseline + v0.7 B1
3574    /// memory_load_family + v0.7 B2 memory_smart_load) + 1 always-on
3575    /// bootstrap (memory_capabilities) = 8 visible tools. `--profile
3576    /// full` registers the whole SSOT set.
3577    #[test]
3578    fn tool_definitions_for_profile_core_registers_core_family_plus_always_on() {
3579        use crate::profile::{ALWAYS_ON_TOOLS, Family};
3580        let defs = tool_definitions_for_profile(&crate::profile::Profile::core());
3581        let tools = defs["tools"].as_array().unwrap();
3582        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
3583        // Exactly the Core family tools + the always-on bootstrap set
3584        // (memory_capabilities). Counts derived from the SSOT slices.
3585        assert_eq!(
3586            tools.len(),
3587            Family::Core.tool_names().len() + ALWAYS_ON_TOOLS.len(),
3588            "core profile should register the Core family + always-on bootstrap; got {names:?}"
3589        );
3590        for required in [
3591            "memory_store",
3592            "memory_recall",
3593            "memory_list",
3594            "memory_get",
3595            "memory_search",
3596            "memory_load_family",
3597            "memory_smart_load",
3598            "memory_capabilities",
3599        ] {
3600            assert!(
3601                names.contains(&required),
3602                "core profile missing {required}; got {names:?}"
3603            );
3604        }
3605        // None of the non-core tools should leak through.
3606        for excluded in [
3607            "memory_kg_query",
3608            "memory_consolidate",
3609            "memory_archive_list",
3610            "memory_subscribe",
3611            "memory_promote",
3612        ] {
3613            assert!(
3614                !names.contains(&excluded),
3615                "core profile leaked {excluded}; got {names:?}"
3616            );
3617        }
3618    }
3619
3620    #[test]
3621    fn tool_definitions_for_profile_full_registers_expected_count() {
3622        let defs = tool_definitions_for_profile(&crate::profile::Profile::full());
3623        let tools = defs["tools"].as_array().unwrap();
3624        assert_eq!(
3625            tools.len(),
3626            crate::profile::Profile::full().expected_tool_count(),
3627            "full profile registration count must match \
3628             `Profile::full().expected_tool_count()` — the SSOT derived \
3629             from the per-Family `tool_names` slices; no literal is \
3630             restated here so surface additions (e.g. #1389 L4 \
3631             memory_capture_turn under Family::Lifecycle) flow through \
3632             automatically"
3633        );
3634    }
3635
3636    #[test]
3637    fn tool_definitions_for_profile_graph_registers_core_graph_plus_always_on() {
3638        use crate::profile::{ALWAYS_ON_TOOLS, Family};
3639        let defs = tool_definitions_for_profile(&crate::profile::Profile::graph());
3640        let tools = defs["tools"].as_array().unwrap();
3641        // Core + Graph families + always-on bootstrap; all derived from
3642        // the SSOT slices.
3643        assert_eq!(
3644            tools.len(),
3645            Family::Core.tool_names().len()
3646                + Family::Graph.tool_names().len()
3647                + ALWAYS_ON_TOOLS.len(),
3648            "graph profile = Core + Graph families + always-on bootstrap"
3649        );
3650    }
3651
3652    /// RFC §S30: custom comma-list `core,graph` registers union.
3653    #[test]
3654    fn tool_definitions_for_profile_custom_core_comma_graph_registers_union() {
3655        use crate::profile::{ALWAYS_ON_TOOLS, Family};
3656        let p = crate::profile::Profile::parse("core,graph").unwrap();
3657        let defs = tool_definitions_for_profile(&p);
3658        let tools = defs["tools"].as_array().unwrap();
3659        assert_eq!(
3660            tools.len(),
3661            Family::Core.tool_names().len()
3662                + Family::Graph.tool_names().len()
3663                + ALWAYS_ON_TOOLS.len(),
3664            "core,graph = Core + Graph families + always-on bootstrap"
3665        );
3666    }
3667
3668    // ---- v0.6.4-006 — capabilities family enum + include_schema ----
3669
3670    #[test]
3671    fn families_overview_lists_all_eight_with_correct_loaded_flags() {
3672        let p = crate::profile::Profile::core();
3673        let v = families_overview(&p);
3674        let families = v["families"].as_array().unwrap();
3675        assert_eq!(families.len(), 8, "all eight families must appear");
3676
3677        let core_row = families.iter().find(|r| r["name"] == "core").unwrap();
3678        assert_eq!(core_row["loaded"], true);
3679        // v0.7 B1 + B2 — Core now ships 7 tools (5 baseline +
3680        // memory_load_family + memory_smart_load).
3681        assert_eq!(core_row["tool_count"], 7);
3682        let graph_row = families.iter().find(|r| r["name"] == "graph").unwrap();
3683        assert_eq!(graph_row["loaded"], false);
3684        // v0.7 J7 — graph now ships 11 tools (8 baseline + memory_replay
3685        // [I4] + memory_verify [H4] + memory_find_paths [J7]).
3686        assert_eq!(graph_row["tool_count"], 11);
3687
3688        let always_on = v["always_on"].as_array().unwrap();
3689        assert_eq!(always_on.len(), 1);
3690        assert_eq!(always_on[0], "memory_capabilities");
3691    }
3692
3693    #[test]
3694    fn handle_capabilities_family_lists_tool_names() {
3695        let p = crate::profile::Profile::core();
3696        let v = handle_capabilities_family("graph", false, false, &p, None, None, None).unwrap();
3697        assert_eq!(v["family"], "graph");
3698        assert_eq!(v["loaded_under_active_profile"], false);
3699        let tools = v["tools"].as_array().unwrap();
3700        // v0.7 J7 — graph now lists 11 tools (8 baseline + memory_replay
3701        // [I4] + memory_verify [H4] + memory_find_paths [J7]).
3702        assert_eq!(tools.len(), 11);
3703        // Spot-check known graph tool present.
3704        assert!(tools.iter().any(|t| t == "memory_kg_query"));
3705        assert!(tools.iter().any(|t| t == "memory_replay"));
3706        assert!(tools.iter().any(|t| t == "memory_verify"));
3707        assert!(tools.iter().any(|t| t == "memory_find_paths"));
3708    }
3709
3710    #[test]
3711    fn handle_capabilities_family_include_schema_returns_full_definitions() {
3712        let p = crate::profile::Profile::core();
3713        // v0.7 C2 + C4 — verbose=true preserves BOTH the long-form `docs`
3714        // field (C2) AND every optional `inputSchema.properties` entry (C4).
3715        // The legacy assertion (full definition shape present) still holds,
3716        // and additionally `docs` is now expected on every tool that defines
3717        // one.
3718        let v = handle_capabilities_family("graph", true, true, &p, None, None, None).unwrap();
3719        assert_eq!(v["family"], "graph");
3720        assert_eq!(v["verbose"], true);
3721        let tools = v["tools"].as_array().unwrap();
3722        // v0.7 J7 — graph now ships 11 schemas (8 baseline + memory_replay
3723        // [I4] + memory_verify [H4] + memory_find_paths [J7]).
3724        assert_eq!(tools.len(), 11);
3725        // Each row must carry the full MCP tool definition shape.
3726        for tool in tools {
3727            assert!(tool.get("name").is_some(), "missing name");
3728            assert!(tool.get("description").is_some(), "missing description");
3729            assert!(tool.get("inputSchema").is_some(), "missing inputSchema");
3730        }
3731    }
3732
3733    #[test]
3734    fn handle_capabilities_family_verbose_preserves_docs_field() {
3735        // v0.7 C2 — verbose=true with include_schema=true must restore the
3736        // long-form `docs` payload on every tool entry that defines one.
3737        let p = crate::profile::Profile::core();
3738        let v = handle_capabilities_family("core", true, true, &p, None, None, None).unwrap();
3739        let tools = v["tools"].as_array().unwrap();
3740        assert!(!tools.is_empty());
3741        let with_docs = tools
3742            .iter()
3743            .filter(|t| t.get("docs").and_then(Value::as_str).is_some())
3744            .count();
3745        assert!(
3746            with_docs >= 1,
3747            "verbose=true must surface at least one docs string in family=core; got 0"
3748        );
3749    }
3750
3751    #[test]
3752    fn handle_capabilities_family_unknown_returns_diagnostic_err() {
3753        let p = crate::profile::Profile::core();
3754        let err =
3755            handle_capabilities_family("xyz", false, false, &p, None, None, None).unwrap_err();
3756        assert!(err.contains("xyz"));
3757        assert!(err.contains("Valid families"));
3758        assert!(err.contains("core"));
3759        assert!(err.contains("graph"));
3760    }
3761
3762    #[test]
3763    fn handle_capabilities_family_empty_name_errors() {
3764        let p = crate::profile::Profile::core();
3765        let err = handle_capabilities_family("", false, false, &p, None, None, None).unwrap_err();
3766        assert!(err.contains("must not be empty"));
3767    }
3768
3769    // ---- v0.7 C4 — wire-form schema invariants (post-#859 update) ----
3770
3771    /// `tools/list` payload (the default `tool_definitions_for_profile`
3772    /// path) must EXPOSE every optional param so NHI agents can
3773    /// discover the call surface. Per-property `description` prose
3774    /// is stripped on the wire (the budget concession that lets
3775    /// discovery fit the token ceiling); the structural metadata
3776    /// (`type`, `enum`, `minimum`, `maximum`, `default`) survives so
3777    /// clients can construct valid argument objects.
3778    ///
3779    /// **Pre-#859 (historical):** this test asserted the opposite —
3780    /// that `confidence`, `priority`, `tier`, … were STRIPPED from
3781    /// the wire. That trim broke NHI runtime discovery and was
3782    /// reverted by #859. The test is renamed + inverted to lock the
3783    /// new wire shape.
3784    #[test]
3785    fn tool_definitions_for_profile_preserves_optional_params_post_859() {
3786        let p = crate::profile::Profile::full();
3787        let defs = tool_definitions_for_profile(&p);
3788        let store = defs["tools"]
3789            .as_array()
3790            .unwrap()
3791            .iter()
3792            .find(|t| t["name"] == "memory_store")
3793            .expect("memory_store must be present in full profile");
3794        let props = store["inputSchema"]["properties"].as_object().unwrap();
3795        // Required AND optional now both survive on the wire.
3796        for kept in [
3797            "title",
3798            "content",
3799            "namespace",
3800            "confidence",
3801            "priority",
3802            "tier",
3803            "metadata",
3804            "agent_id",
3805            "source",
3806            "scope",
3807            "tags",
3808            "on_conflict",
3809            "kind",
3810        ] {
3811            assert!(
3812                props.contains_key(kept),
3813                "#859: wire schema must preserve property `{kept}` for client-side discovery"
3814            );
3815        }
3816        // But the prose stays stripped.
3817        let confidence = props
3818            .get("confidence")
3819            .and_then(serde_json::Value::as_object)
3820            .expect("confidence property must be an object");
3821        assert!(
3822            !confidence.contains_key("description"),
3823            "#859: per-property `description` prose must be stripped on the wire"
3824        );
3825        // Structural metadata stays. Post-D1.6 (#987) the schemars-
3826        // derived schema emits `type: ["number","null"]` for
3827        // `Option<f64>` fields (one of the documented allowed-diffs);
3828        // accept either the legacy `"number"` form or the post-D1.6
3829        // nullable variant. Either way, the `number` discriminator
3830        // must be present so clients can validate.
3831        let confidence_type = confidence
3832            .get("type")
3833            .expect("confidence must declare a type discriminator");
3834        let confidence_is_number = match confidence_type {
3835            serde_json::Value::String(s) => s == "number",
3836            serde_json::Value::Array(items) => items.iter().any(|v| v.as_str() == Some("number")),
3837            _ => false,
3838        };
3839        assert!(
3840            confidence_is_number,
3841            "confidence.type must contain `number`; got {confidence_type:?}"
3842        );
3843        // Pre-D1.6 the legacy macro pinned `minimum: 0.0, maximum: 1.0`
3844        // on `confidence`. The schemars derive on `Option<f64>` does
3845        // NOT emit those bounds (no `#[schemars(range)]` on the
3846        // request struct), so they're absent from the post-D1.6 wire
3847        // shape. The handler still validates 0.0..=1.0 server-side
3848        // (see `crate::validate::validate_confidence`). Pin only the
3849        // post-D1.6 reality here; if D1.7 (#988) adds bounds back via
3850        // a schemars attribute, tighten this assertion then.
3851        let _ = confidence; // silence the lint if the assertion goes away.
3852        // The historical `assert!(confidence.contains_key("minimum"));`
3853        // / `assert!(confidence.contains_key("maximum"));` pair landed
3854        // pre-D1.6 and is documented as an allowed-diff post-D1.6
3855        // per the D1.6 (#987) catalog (range-constraint loss is not
3856        // in the disallowed-diff list: property-add/remove,
3857        // description-rename, required-change, type-widening).
3858    }
3859
3860    /// `tool_definitions_for_profile_verbose` keeps every optional —
3861    /// this is the opt-in path callers reach via
3862    /// `memory_capabilities { verbose=true, family=…, include_schema=true }`.
3863    #[test]
3864    fn tool_definitions_for_profile_verbose_keeps_every_optional() {
3865        let p = crate::profile::Profile::full();
3866        let defs = tool_definitions_for_profile_verbose(&p);
3867        let store = defs["tools"]
3868            .as_array()
3869            .unwrap()
3870            .iter()
3871            .find(|t| t["name"] == "memory_store")
3872            .expect("memory_store must be present");
3873        let props = store["inputSchema"]["properties"].as_object().unwrap();
3874        for kept in [
3875            "title",
3876            "content",
3877            "namespace",
3878            "confidence",
3879            "priority",
3880            "tier",
3881            "metadata",
3882            "agent_id",
3883            "source",
3884            "scope",
3885            "tags",
3886            "on_conflict",
3887        ] {
3888            assert!(
3889                props.contains_key(kept),
3890                "verbose path should preserve `{kept}`"
3891            );
3892        }
3893    }
3894
3895    /// The `verbose=true` family path must round-trip every optional;
3896    /// `verbose=false` (the default for `include_schema=true`) is the
3897    /// wire-form schema. Per issue #859 (rev v0.7.0), the wire form
3898    /// PRESERVES every property entry so MCP clients can discover the
3899    /// long-tail optionals (`confidence`, `priority`, …) — it only
3900    /// strips the per-property `description` prose and the top-level
3901    /// `docs` field. Anchors the wire-shape contract documented on
3902    /// [`handle_capabilities_family`].
3903    #[test]
3904    fn handle_capabilities_family_verbose_toggles_optional_params() {
3905        let p = crate::profile::Profile::full();
3906        // verbose=false → trimmed wire schema: properties preserved,
3907        // per-property `description` prose stripped.
3908        let trimmed =
3909            handle_capabilities_family("core", true, false, &p, None, None, None).unwrap();
3910        assert_eq!(trimmed["verbose"], false);
3911        let store_trimmed = trimmed["tools"]
3912            .as_array()
3913            .unwrap()
3914            .iter()
3915            .find(|t| t["name"] == "memory_store")
3916            .expect("memory_store in core family");
3917        let props_trimmed = store_trimmed["inputSchema"]["properties"]
3918            .as_object()
3919            .unwrap();
3920        // #859 — every optional must remain so NHI agents can discover
3921        // the surface from `tools/list` directly.
3922        for kept in [
3923            "title",
3924            "content",
3925            "namespace",
3926            "confidence",
3927            "priority",
3928            "tier",
3929            "metadata",
3930            "agent_id",
3931            "source",
3932            "scope",
3933            "tags",
3934            "on_conflict",
3935            "kind",
3936        ] {
3937            assert!(
3938                props_trimmed.contains_key(kept),
3939                "wire schema (verbose=false) must preserve property `{kept}` (#859)"
3940            );
3941        }
3942        // But the per-property prose is dropped on the wire path.
3943        let confidence_prop = props_trimmed
3944            .get("confidence")
3945            .and_then(serde_json::Value::as_object)
3946            .expect("confidence property must be an object");
3947        assert!(
3948            !confidence_prop.contains_key("description"),
3949            "wire schema must drop per-property `description` prose (#859)"
3950        );
3951        // Structural metadata stays — clients need it to construct
3952        // valid args. Post-D1.6 (#987) the schemars-derived schema
3953        // emits `type: ["number","null"]` for `Option<f64>`; accept
3954        // either form. Range constraints (`minimum`/`maximum`) are
3955        // a documented allowed-diff post-D1.6 (no `#[schemars(range)]`
3956        // attribute on the request struct yet).
3957        let confidence_type = confidence_prop
3958            .get("type")
3959            .expect("confidence must declare a type discriminator");
3960        let confidence_is_number = match confidence_type {
3961            serde_json::Value::String(s) => s == "number",
3962            serde_json::Value::Array(items) => items.iter().any(|v| v.as_str() == Some("number")),
3963            _ => false,
3964        };
3965        assert!(
3966            confidence_is_number,
3967            "confidence.type must contain `number`; got {confidence_type:?}"
3968        );
3969
3970        // verbose=true → full schema (prose preserved).
3971        let verbose = handle_capabilities_family("core", true, true, &p, None, None, None).unwrap();
3972        assert_eq!(verbose["verbose"], true);
3973        let store_verbose = verbose["tools"]
3974            .as_array()
3975            .unwrap()
3976            .iter()
3977            .find(|t| t["name"] == "memory_store")
3978            .expect("memory_store in core family");
3979        let props_verbose = store_verbose["inputSchema"]["properties"]
3980            .as_object()
3981            .unwrap();
3982        assert!(props_verbose.contains_key("confidence"));
3983        assert!(props_verbose.contains_key("priority"));
3984        assert!(props_verbose.contains_key("metadata"));
3985        assert!(props_verbose.contains_key("agent_id"));
3986    }
3987
3988    /// #859 (rev) — `trim_optional_params` strips per-property
3989    /// `description` text from every property entry (not the property
3990    /// entry itself). Reports a positive count and is idempotent — a
3991    /// second pass on an already-stripped schema is a no-op.
3992    #[test]
3993    fn trim_optional_params_is_idempotent() {
3994        let mut defs = tool_definitions();
3995        let stripped_first = trim_optional_params(&mut defs);
3996        assert!(
3997            stripped_first > 0,
3998            "first trim should strip a positive number of per-property descriptions"
3999        );
4000        let stripped_second = trim_optional_params(&mut defs);
4001        assert_eq!(
4002            stripped_second, 0,
4003            "re-trim of an already-trimmed schema must be a no-op"
4004        );
4005    }
4006
4007    /// `tools/list` (full profile, trimmed wire) must be materially
4008    /// smaller than the verbose payload. Pre-#859 the trim dropped
4009    /// entire optional property entries which gave a ~30% byte saving;
4010    /// post-#859 the trim preserves properties (keeping discovery) and
4011    /// only drops per-property `description` prose + the top-level
4012    /// `docs` field, which still saves a substantive fraction because
4013    /// the prose dominates the per-property byte cost.
4014    #[test]
4015    fn c4_trim_shrinks_full_profile_payload_meaningfully() {
4016        let p = crate::profile::Profile::full();
4017        let trimmed = tool_definitions_for_profile(&p);
4018        let verbose = tool_definitions_for_profile_verbose(&p);
4019        let trimmed_bytes = serde_json::to_string(&trimmed).unwrap().len();
4020        let verbose_bytes = serde_json::to_string(&verbose).unwrap().len();
4021        assert!(
4022            trimmed_bytes < verbose_bytes,
4023            "trimmed ({trimmed_bytes}B) must be smaller than verbose ({verbose_bytes}B)"
4024        );
4025        let saved_pct = (verbose_bytes - trimmed_bytes) as f64 / verbose_bytes as f64 * 100.0;
4026        // Post-#859 floor — `tool_definitions_for_profile_verbose`
4027        // already strips `docs` (via `strip_docs_from_tools`) and the
4028        // recursive per-property description walker, so the only
4029        // additional savings the trimmed path delivers is
4030        // `wire_compact_descriptions` truncating each tool's
4031        // top-level `description` to the first sentence (typically
4032        // ~28 chars). That's ~5-10% of the verbose total; the 5%
4033        // gate flags a regression in the wire compactor itself. The
4034        // absolute token-budget ceiling is pinned separately by
4035        // `tests/c2_tool_docs_field.rs`.
4036        assert!(
4037            saved_pct >= 5.0,
4038            "trim should save >=5% of full-profile bytes via top-level description \
4039             compaction; got {saved_pct:.1}% (verbose={verbose_bytes}B, \
4040             trimmed={trimmed_bytes}B) — `wire_compact_descriptions` may be broken"
4041        );
4042    }
4043
4044    #[test]
4045    fn tool_definitions_include_check_duplicate() {
4046        let defs = tool_definitions();
4047        let tools = defs["tools"].as_array().unwrap();
4048        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4049        assert!(names.contains(&"memory_check_duplicate"));
4050    }
4051
4052    #[test]
4053    fn tool_definitions_include_entity_registry_tools() {
4054        let defs = tool_definitions();
4055        let tools = defs["tools"].as_array().unwrap();
4056        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4057        assert!(names.contains(&"memory_entity_register"));
4058        assert!(names.contains(&"memory_entity_get_by_alias"));
4059    }
4060
4061    #[test]
4062    fn tool_definitions_include_kg_timeline() {
4063        let defs = tool_definitions();
4064        let tools = defs["tools"].as_array().unwrap();
4065        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4066        assert!(names.contains(&"memory_kg_timeline"));
4067    }
4068
4069    #[test]
4070    fn tool_definitions_include_kg_invalidate() {
4071        let defs = tool_definitions();
4072        let tools = defs["tools"].as_array().unwrap();
4073        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4074        assert!(names.contains(&"memory_kg_invalidate"));
4075    }
4076
4077    #[test]
4078    fn tool_definitions_include_kg_query() {
4079        let defs = tool_definitions();
4080        let tools = defs["tools"].as_array().unwrap();
4081        let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
4082        assert!(names.contains(&"memory_kg_query"));
4083    }
4084
4085    #[test]
4086    fn tool_definitions_include_agent_register_and_list() {
4087        let defs = tool_definitions();
4088        let names: Vec<&str> = defs["tools"]
4089            .as_array()
4090            .unwrap()
4091            .iter()
4092            .filter_map(|t| t["name"].as_str())
4093            .collect();
4094        assert!(names.contains(&"memory_agent_register"));
4095        assert!(names.contains(&"memory_agent_list"));
4096    }
4097
4098    #[test]
4099    fn tool_definitions_include_notify_and_inbox() {
4100        // v0.6.0.0 agent-to-agent messaging primitive.
4101        let defs = tool_definitions();
4102        let names: Vec<&str> = defs["tools"]
4103            .as_array()
4104            .unwrap()
4105            .iter()
4106            .filter_map(|t| t["name"].as_str())
4107            .collect();
4108        assert!(names.contains(&"memory_notify"));
4109        assert!(names.contains(&"memory_inbox"));
4110    }
4111
4112    #[test]
4113    fn messages_namespace_is_prefixed() {
4114        assert_eq!(super::messages_namespace_for("alice"), "_messages/alice");
4115        assert_eq!(
4116            super::messages_namespace_for("ai:claude-opus-4.7"),
4117            "_messages/ai:claude-opus-4.7"
4118        );
4119    }
4120
4121    #[test]
4122    fn tool_definitions_all_have_names() {
4123        let defs = tool_definitions();
4124        let tools = defs["tools"].as_array().unwrap();
4125        for tool in tools {
4126            assert!(tool["name"].as_str().unwrap().starts_with("memory_"));
4127        }
4128    }
4129
4130    #[test]
4131    fn tool_definitions_recall_has_toon_default() {
4132        // Post-D1.6 (#987) — the `format` field is `Option<String>` on
4133        // the schemars-derived `RecallRequest`, so `default` is `null`
4134        // (one of the documented allowed-diffs). The functional default
4135        // ("toon_compact" when the caller omits `format`) is enforced
4136        // at the handler boundary, not at the wire schema. We assert
4137        // the property still ships on the wire (clients discover it)
4138        // and that an enum constraint is present.
4139        let defs = tool_definitions();
4140        let tools = defs["tools"].as_array().unwrap();
4141        let recall = tools.iter().find(|t| t["name"] == "memory_recall").unwrap();
4142        let format_schema = &recall["inputSchema"]["properties"]["format"];
4143        assert!(format_schema.is_object(), "format schema must be present");
4144        // Either legacy ("toon_compact") or post-D1.6 (null) default
4145        // is acceptable per the D1.6 allowed-diffs catalog.
4146        let default = &format_schema["default"];
4147        assert!(
4148            default.is_null() || default.as_str() == Some("toon_compact"),
4149            "format default must be null (post-D1.6 Option<T>) or \"toon_compact\" (legacy); \
4150             got {default:?}"
4151        );
4152    }
4153
4154    /// v0.7.0 (issue #518) — pin the `memory_recall` tool schema for
4155    /// the new `session_default` boolean. Post-D1.6 (#987) the
4156    /// schemars-derived schema emits `type: ["boolean","null"]` +
4157    /// `default: null` for the `Option<bool>` request field
4158    /// (allowed-diff per the D1.6 catalog). The handler-side
4159    /// functional default remains `false`; the description must still
4160    /// mention `[agents.defaults.recall_scope]` so clients discover
4161    /// the splice contract through `tools/list`.
4162    #[test]
4163    fn tool_definitions_recall_advertises_session_default_issue_518() {
4164        let defs = tool_definitions();
4165        let tools = defs["tools"].as_array().unwrap();
4166        let recall = tools
4167            .iter()
4168            .find(|t| t["name"] == "memory_recall")
4169            .expect("memory_recall tool must be defined");
4170        let props = &recall["inputSchema"]["properties"];
4171        let session_default = &props["session_default"];
4172        // Accept either the legacy `"boolean"` form or the post-D1.6
4173        // nullable `["boolean","null"]` variant; both surface the
4174        // `boolean` discriminator.
4175        let session_default_type = &session_default["type"];
4176        let is_boolean = match session_default_type {
4177            serde_json::Value::String(s) => s == "boolean",
4178            serde_json::Value::Array(items) => items.iter().any(|v| v.as_str() == Some("boolean")),
4179            _ => false,
4180        };
4181        assert!(
4182            is_boolean,
4183            "session_default.type must contain `boolean`; got {session_default_type:?}"
4184        );
4185        // Default is `false` (legacy) or `null` (post-D1.6 Option<T>).
4186        let default = &session_default["default"];
4187        assert!(
4188            default.is_null() || default.as_bool() == Some(false),
4189            "session_default default must be null (post-D1.6) or false (legacy); got {default:?}"
4190        );
4191        assert!(
4192            session_default["description"]
4193                .as_str()
4194                .is_some_and(|d| d.contains("agents.defaults.recall_scope")),
4195            "session_default description must mention [agents.defaults.recall_scope] — got {session_default:?}"
4196        );
4197    }
4198
4199    #[test]
4200    fn prompt_definitions_returns_2() {
4201        let defs = prompt_definitions();
4202        let prompts = defs["prompts"].as_array().unwrap();
4203        assert_eq!(prompts.len(), 2);
4204        assert_eq!(prompts[0]["name"], "recall-first");
4205        assert_eq!(prompts[1]["name"], "memory-workflow");
4206    }
4207
4208    #[test]
4209    fn prompt_definitions_recall_first_has_arguments() {
4210        let defs = prompt_definitions();
4211        let prompts = defs["prompts"].as_array().unwrap();
4212        let recall_first = &prompts[0];
4213        let args = recall_first["arguments"].as_array().unwrap();
4214        assert_eq!(args.len(), 1);
4215        assert_eq!(args[0]["name"], "namespace");
4216    }
4217
4218    #[test]
4219    fn prompt_content_recall_first() {
4220        let params = json!({});
4221        let result = prompt_content("recall-first", &params).unwrap();
4222        let msgs = result["messages"].as_array().unwrap();
4223        assert_eq!(msgs.len(), 1);
4224        let text = msgs[0]["content"]["text"].as_str().unwrap();
4225        assert!(text.contains("RECALL FIRST"));
4226        assert!(text.contains("TOON"));
4227        assert!(text.contains("memory_recall"));
4228        assert!(text.contains("memory_store"));
4229        assert!(text.contains("DEDUP"));
4230    }
4231
4232    #[test]
4233    fn prompt_content_recall_first_with_namespace() {
4234        let params = json!({"arguments": {"namespace": "my-project"}});
4235        let result = prompt_content("recall-first", &params).unwrap();
4236        let text = result["messages"][0]["content"]["text"].as_str().unwrap();
4237        assert!(text.contains("my-project"));
4238    }
4239
4240    #[test]
4241    fn prompt_content_memory_workflow() {
4242        let params = json!({});
4243        let result = prompt_content("memory-workflow", &params).unwrap();
4244        let text = result["messages"][0]["content"]["text"].as_str().unwrap();
4245        assert!(text.contains("STORE"));
4246        assert!(text.contains("RECALL"));
4247        assert!(text.contains("SEARCH"));
4248        assert!(text.contains("CONSOLIDATE"));
4249        assert!(text.contains("TOON"));
4250    }
4251
4252    #[test]
4253    fn prompt_content_unknown() {
4254        let params = json!({});
4255        let result = prompt_content("nonexistent", &params);
4256        assert!(result.is_err());
4257        assert!(result.unwrap_err().contains("unknown prompt"));
4258    }
4259
4260    #[test]
4261    fn prompt_content_role_is_user() {
4262        let params = json!({});
4263        let result = prompt_content("recall-first", &params).unwrap();
4264        assert_eq!(result["messages"][0]["role"], "user");
4265    }
4266
4267    #[test]
4268    fn ok_response_structure() {
4269        let resp = ok_response(json!(1), json!({"test": true}));
4270        assert_eq!(resp.jsonrpc, "2.0");
4271        assert_eq!(resp.id, json!(1));
4272        assert!(resp.result.is_some());
4273        assert!(resp.error.is_none());
4274    }
4275
4276    #[test]
4277    fn err_response_structure() {
4278        let resp = err_response(json!(1), -32600, "test error".to_string());
4279        assert_eq!(resp.jsonrpc, "2.0");
4280        assert!(resp.error.is_some());
4281        let err = resp.error.unwrap();
4282        assert_eq!(err.code, -32600);
4283        assert_eq!(err.message, "test error");
4284    }
4285
4286    /// Buffer-backed `MakeWriter` so `tracing` output can be asserted on
4287    /// without polluting test stdout/stderr or installing a global
4288    /// subscriber. Used by the Stream E span coverage tests below.
4289    #[derive(Clone)]
4290    struct VecWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
4291
4292    impl std::io::Write for VecWriter {
4293        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4294            self.0.lock().unwrap().extend_from_slice(buf);
4295            Ok(buf.len())
4296        }
4297        fn flush(&mut self) -> std::io::Result<()> {
4298            Ok(())
4299        }
4300    }
4301
4302    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for VecWriter {
4303        type Writer = VecWriter;
4304        fn make_writer(&'a self) -> Self::Writer {
4305            self.clone()
4306        }
4307    }
4308
4309    fn run_with_capture<F: FnOnce()>(f: F) -> String {
4310        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
4311        let writer = VecWriter(buf.clone());
4312        let subscriber = tracing_subscriber::fmt()
4313            .with_writer(writer)
4314            .with_max_level(tracing::Level::INFO)
4315            .with_ansi(false)
4316            .finish();
4317        tracing::subscriber::with_default(subscriber, f);
4318        String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default()
4319    }
4320
4321    fn make_tools_call(tool: &str, args: Value) -> RpcRequest {
4322        RpcRequest {
4323            jsonrpc: "2.0".into(),
4324            id: Some(json!(1)),
4325            method: "tools/call".into(),
4326            params: json!({ "name": tool, "arguments": args }),
4327        }
4328    }
4329
4330    /// Pillar 3 / Stream E coverage — every successful `tools/call` must
4331    /// emit a `mcp_tool_call` span carrying the tool name plus an `ok`
4332    /// event with `elapsed_ms`. This is the single point of latency
4333    /// instrumentation production exporters key off.
4334    #[test]
4335    fn tools_call_emits_span_with_tool_name_and_elapsed_ms() {
4336        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4337        let tier_config = FeatureTier::Keyword.config();
4338        let resolved_ttl = crate::config::ResolvedTtl::default();
4339        let resolved_scoring = crate::config::ResolvedScoring::default();
4340        let req = make_tools_call("memory_list", json!({"limit": 1}));
4341
4342        let captured = run_with_capture(|| {
4343            let resp = handle_request(
4344                &conn,
4345                std::path::Path::new(":memory:"),
4346                &req,
4347                None,
4348                None,
4349                None,
4350                &tier_config,
4351                &crate::config::ResolvedModels::from_tier_preset(&tier_config),
4352                None,
4353                &resolved_ttl,
4354                &resolved_scoring,
4355                true,
4356                false,
4357                None,
4358                &crate::profile::Profile::full(),
4359                None,
4360                None,
4361                None,
4362                None,           // federation_forward_url (#318)
4363                None,           // recall_scope (#518)
4364                None,           // atomise_handler (WT-1-C)
4365                None,           // ingest_multistep_handler (Form 3 / #756)
4366                None,           // nag_watcher (#1389/#1398 L1)
4367                "test-session", // nag_session_id (#1389/#1398 L1)
4368            );
4369            assert!(resp.error.is_none(), "expected ok rpc response");
4370        });
4371
4372        assert!(
4373            captured.contains("mcp_tool_call"),
4374            "missing span name in: {captured}"
4375        );
4376        assert!(
4377            captured.contains("memory_list"),
4378            "missing tool field in: {captured}"
4379        );
4380        assert!(
4381            captured.contains("elapsed_ms"),
4382            "missing elapsed_ms field in: {captured}"
4383        );
4384        assert!(
4385            captured.contains(" ok"),
4386            "missing ok outcome event in: {captured}"
4387        );
4388    }
4389
4390    /// Failure path — when the underlying handler returns an `Err`, the
4391    /// span emits a `warn` level event with the error message so on-call
4392    /// dashboards can alert on per-tool error rate.
4393    #[test]
4394    fn tools_call_emits_warn_event_on_handler_error() {
4395        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4396        let tier_config = FeatureTier::Keyword.config();
4397        let resolved_ttl = crate::config::ResolvedTtl::default();
4398        let resolved_scoring = crate::config::ResolvedScoring::default();
4399        // memory_get with a missing/invalid id is a deterministic Err
4400        // path: validate_id rejects empty strings.
4401        let req = make_tools_call("memory_get", json!({"id": ""}));
4402
4403        let captured = run_with_capture(|| {
4404            let resp = handle_request(
4405                &conn,
4406                std::path::Path::new(":memory:"),
4407                &req,
4408                None,
4409                None,
4410                None,
4411                &tier_config,
4412                &crate::config::ResolvedModels::from_tier_preset(&tier_config),
4413                None,
4414                &resolved_ttl,
4415                &resolved_scoring,
4416                true,
4417                false,
4418                None,
4419                &crate::profile::Profile::full(),
4420                None,
4421                None,
4422                None,
4423                None,           // federation_forward_url (#318)
4424                None,           // recall_scope (#518)
4425                None,           // atomise_handler (WT-1-C)
4426                None,           // ingest_multistep_handler (Form 3 / #756)
4427                None,           // nag_watcher (#1389/#1398 L1)
4428                "test-session", // nag_session_id (#1389/#1398 L1)
4429            );
4430            // Handler errs are returned as ok_response with isError=true,
4431            // not RpcError, by design (the JSON-RPC layer is reserved for
4432            // protocol-level failures).
4433            assert!(resp.error.is_none());
4434        });
4435
4436        assert!(
4437            captured.contains("mcp_tool_call"),
4438            "missing span in err path: {captured}"
4439        );
4440        assert!(
4441            captured.contains("memory_get"),
4442            "missing tool field in err path: {captured}"
4443        );
4444        assert!(
4445            captured.contains("WARN"),
4446            "missing WARN level on err path: {captured}"
4447        );
4448        assert!(
4449            captured.contains("err"),
4450            "missing err outcome in: {captured}"
4451        );
4452    }
4453    /// Parametrized smoke matrix over the MCP tool dispatch pathway
4454    /// (Justice of MCP). Each `ToolCase` exercises one tool end to end:
4455    /// Tier 1: happy path with canonical valid args.
4456    /// Tier 2: required arg validation (missing required arg → error).
4457    #[test]
4458    #[allow(clippy::too_many_lines)]
4459    fn mcp_tools_smoke_matrix() {
4460        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4461        let tier_config = FeatureTier::Keyword.config();
4462        let resolved_ttl = crate::config::ResolvedTtl::default();
4463        let resolved_scoring = crate::config::ResolvedScoring::default();
4464
4465        struct ToolCase {
4466            name: &'static str,
4467            valid_args: Value,
4468            required_arg: Option<&'static str>, // first required arg name for error test
4469        }
4470
4471        let cases: &[ToolCase] = &[
4472            ToolCase {
4473                name: "memory_store",
4474                valid_args: json!({"title": "test", "content": "test content"}),
4475                required_arg: Some("title"),
4476            },
4477            ToolCase {
4478                name: "memory_recall",
4479                valid_args: json!({"context": "test"}),
4480                required_arg: Some("context"),
4481            },
4482            ToolCase {
4483                name: "memory_search",
4484                valid_args: json!({"query": "test"}),
4485                required_arg: Some("query"),
4486            },
4487            ToolCase {
4488                name: "memory_list",
4489                valid_args: json!({}),
4490                required_arg: None,
4491            },
4492            ToolCase {
4493                name: "memory_load_family",
4494                valid_args: json!({"family": "core"}),
4495                required_arg: Some("family"),
4496            },
4497            // v0.7 B2 — memory_smart_load: free-text intent picks a
4498            // family using cached embeddings (or deterministic keyword
4499            // fallback when the embedder is offline). Empty intent
4500            // surfaces as a missing-required-arg error.
4501            ToolCase {
4502                name: "memory_smart_load",
4503                valid_args: json!({"intent": "load core memories"}),
4504                required_arg: Some("intent"),
4505            },
4506            ToolCase {
4507                name: "memory_get_taxonomy",
4508                valid_args: json!({}),
4509                required_arg: None,
4510            },
4511            ToolCase {
4512                name: "memory_check_duplicate",
4513                valid_args: json!({"title": "test", "content": "test content"}),
4514                required_arg: Some("title"),
4515            },
4516            ToolCase {
4517                name: "memory_entity_register",
4518                valid_args: json!({"canonical_name": "Entity", "namespace": "test"}),
4519                required_arg: Some("canonical_name"),
4520            },
4521            ToolCase {
4522                name: "memory_entity_get_by_alias",
4523                valid_args: json!({"alias": "test"}),
4524                required_arg: Some("alias"),
4525            },
4526            ToolCase {
4527                name: "memory_kg_timeline",
4528                valid_args: json!({"source_id": "fake-id-for-test"}),
4529                required_arg: Some("source_id"),
4530            },
4531            ToolCase {
4532                name: "memory_kg_invalidate",
4533                valid_args: json!({"source_id": "s", "target_id": "t", "relation": "related_to"}),
4534                required_arg: Some("source_id"),
4535            },
4536            ToolCase {
4537                name: "memory_kg_query",
4538                valid_args: json!({"source_id": "fake-id-for-test"}),
4539                required_arg: Some("source_id"),
4540            },
4541            // v0.7 J7 — memory_find_paths: an unknown source/target
4542            // returns an empty `paths` list, not an error, so the happy
4543            // path works without pre-seeding the DB.
4544            ToolCase {
4545                name: "memory_find_paths",
4546                valid_args: json!({
4547                    "source_id": "fake-src-for-test",
4548                    "target_id": "fake-dst-for-test",
4549                }),
4550                required_arg: Some("source_id"),
4551            },
4552            ToolCase {
4553                name: "memory_delete",
4554                valid_args: json!({"id": "fake-id-for-test"}),
4555                required_arg: Some("id"),
4556            },
4557            ToolCase {
4558                name: "memory_promote",
4559                valid_args: json!({"id": "fake-id-for-test"}),
4560                required_arg: Some("id"),
4561            },
4562            ToolCase {
4563                name: "memory_forget",
4564                valid_args: json!({}),
4565                required_arg: None,
4566            },
4567            ToolCase {
4568                name: "memory_stats",
4569                valid_args: json!({}),
4570                required_arg: None,
4571            },
4572            ToolCase {
4573                name: "memory_update",
4574                valid_args: json!({"id": "fake-id-for-test"}),
4575                required_arg: Some("id"),
4576            },
4577            ToolCase {
4578                name: "memory_get",
4579                valid_args: json!({"id": "fake-id-for-test"}),
4580                required_arg: Some("id"),
4581            },
4582            ToolCase {
4583                name: "memory_link",
4584                valid_args: json!({"source_id": "s", "target_id": "t"}),
4585                required_arg: Some("source_id"),
4586            },
4587            ToolCase {
4588                name: "memory_get_links",
4589                valid_args: json!({"id": "fake-id-for-test"}),
4590                required_arg: Some("id"),
4591            },
4592            ToolCase {
4593                name: "memory_verify",
4594                // Happy-path arg shape — the link won't be found in the
4595                // empty in-memory DB but the dispatcher path is what the
4596                // smoke matrix covers; the "not found" branch is still
4597                // an Err result, which the matrix tolerates because it
4598                // already classifies dispatch outcomes.
4599                valid_args: json!({
4600                    "source_id": "fake-src-id",
4601                    "target_id": "fake-dst-id",
4602                    "relation": "related_to"
4603                }),
4604                // No "single required arg" — the tool is reachable via
4605                // either link_id OR (source_id+target_id), so the
4606                // smoke matrix's required-arg branch is not applicable.
4607                required_arg: None,
4608            },
4609            // v0.7.0 I4 — memory_replay walks the I2 join table; an
4610            // unknown memory_id yields an empty `transcripts` list
4611            // rather than an error, so the happy path works without
4612            // pre-seeding the DB.
4613            ToolCase {
4614                name: "memory_replay",
4615                valid_args: json!({"memory_id": "fake-id-for-test"}),
4616                required_arg: Some("memory_id"),
4617            },
4618            ToolCase {
4619                name: "memory_consolidate",
4620                valid_args: json!({"ids": ["id1", "id2"], "title": "consolidated"}),
4621                required_arg: Some("ids"),
4622            },
4623            ToolCase {
4624                name: "memory_capabilities",
4625                valid_args: json!({}),
4626                required_arg: None,
4627            },
4628            ToolCase {
4629                name: "memory_expand_query",
4630                valid_args: json!({"query": "test"}),
4631                required_arg: Some("query"),
4632            },
4633            ToolCase {
4634                name: "memory_auto_tag",
4635                valid_args: json!({"id": "fake-id-for-test"}),
4636                required_arg: Some("id"),
4637            },
4638            ToolCase {
4639                name: "memory_detect_contradiction",
4640                valid_args: json!({"id_a": "a", "id_b": "b"}),
4641                required_arg: Some("id_a"),
4642            },
4643            ToolCase {
4644                name: "memory_archive_list",
4645                valid_args: json!({}),
4646                required_arg: None,
4647            },
4648            ToolCase {
4649                name: "memory_archive_restore",
4650                valid_args: json!({"id": "fake-id-for-test"}),
4651                required_arg: Some("id"),
4652            },
4653            ToolCase {
4654                name: "memory_archive_purge",
4655                valid_args: json!({}),
4656                required_arg: None,
4657            },
4658            ToolCase {
4659                name: "memory_archive_stats",
4660                valid_args: json!({}),
4661                required_arg: None,
4662            },
4663            ToolCase {
4664                name: "memory_gc",
4665                valid_args: json!({}),
4666                required_arg: None,
4667            },
4668            ToolCase {
4669                name: "memory_session_start",
4670                valid_args: json!({}),
4671                required_arg: None,
4672            },
4673            ToolCase {
4674                name: "memory_namespace_set_standard",
4675                valid_args: json!({"namespace": "test", "id": "fake-id-for-test"}),
4676                required_arg: Some("namespace"),
4677            },
4678            ToolCase {
4679                name: "memory_namespace_get_standard",
4680                valid_args: json!({"namespace": "test"}),
4681                required_arg: Some("namespace"),
4682            },
4683            ToolCase {
4684                name: "memory_namespace_clear_standard",
4685                valid_args: json!({"namespace": "test"}),
4686                required_arg: Some("namespace"),
4687            },
4688            ToolCase {
4689                name: "memory_pending_list",
4690                valid_args: json!({}),
4691                required_arg: None,
4692            },
4693            ToolCase {
4694                name: "memory_pending_approve",
4695                valid_args: json!({"id": "fake-id-for-test"}),
4696                required_arg: Some("id"),
4697            },
4698            ToolCase {
4699                name: "memory_pending_reject",
4700                valid_args: json!({"id": "fake-id-for-test"}),
4701                required_arg: Some("id"),
4702            },
4703            ToolCase {
4704                name: "memory_agent_register",
4705                valid_args: json!({"agent_id": "test-agent", "agent_type": "human"}),
4706                required_arg: Some("agent_id"),
4707            },
4708            ToolCase {
4709                name: "memory_agent_list",
4710                valid_args: json!({}),
4711                required_arg: None,
4712            },
4713            ToolCase {
4714                name: "memory_notify",
4715                valid_args: json!({"target_agent_id": "agent", "title": "msg", "payload": "body"}),
4716                required_arg: Some("target_agent_id"),
4717            },
4718            ToolCase {
4719                name: "memory_inbox",
4720                valid_args: json!({}),
4721                required_arg: None,
4722            },
4723            ToolCase {
4724                // R3-S1.HMAC (2026-05-13): memory_subscribe now requires
4725                // either a per-sub `secret` or a server-wide HMAC config.
4726                name: "memory_subscribe",
4727                valid_args: json!({"url": "https://example.com/webhook", "secret": "tool-case-secret"}),
4728                required_arg: Some("url"),
4729            },
4730            ToolCase {
4731                name: "memory_unsubscribe",
4732                valid_args: json!({"id": "fake-id-for-test"}),
4733                required_arg: Some("id"),
4734            },
4735            ToolCase {
4736                name: "memory_list_subscriptions",
4737                valid_args: json!({}),
4738                required_arg: None,
4739            },
4740            // v0.7 K7 — subscription reliability inspection tools.
4741            ToolCase {
4742                name: "memory_subscription_replay",
4743                valid_args: json!({
4744                    "subscription_id": "smoke-id",
4745                    "since": "1970-01-01T00:00:00Z"
4746                }),
4747                required_arg: Some("subscription_id"),
4748            },
4749            ToolCase {
4750                name: "memory_subscription_dlq_list",
4751                valid_args: json!({}),
4752                required_arg: None,
4753            },
4754            // v0.7 K8 — per-agent quota status. Optional agent_id; on
4755            // omission returns every quota row.
4756            ToolCase {
4757                name: "memory_quota_status",
4758                valid_args: json!({}),
4759                required_arg: None,
4760            },
4761            // v0.7.0 (issue #691) — substrate-level agent-action rules
4762            // engine. Read-only check; happy path on `bash` kind with
4763            // a literal command (empty rule table → Allow).
4764            ToolCase {
4765                name: "memory_check_agent_action",
4766                valid_args: json!({"kind": "bash", "command": "echo hello"}),
4767                required_arg: Some("kind"),
4768            },
4769            // v0.7.0 (issue #691) — rule list. No required args; empty
4770            // governance_rules table returns count=0.
4771            ToolCase {
4772                name: "memory_rule_list",
4773                valid_args: json!({}),
4774                required_arg: None,
4775            },
4776        ];
4777
4778        // Tier 1: happy path tests
4779        for case in cases {
4780            let req = make_tools_call(case.name, case.valid_args.clone());
4781            let resp = handle_request(
4782                &conn,
4783                std::path::Path::new(":memory:"),
4784                &req,
4785                None,
4786                None,
4787                None,
4788                &tier_config,
4789                &crate::config::ResolvedModels::from_tier_preset(&tier_config),
4790                None,
4791                &resolved_ttl,
4792                &resolved_scoring,
4793                true,
4794                false,
4795                None,
4796                &crate::profile::Profile::full(),
4797                None,
4798                None,
4799                None,
4800                None,           // federation_forward_url (#318)
4801                None,           // recall_scope (#518)
4802                None,           // atomise_handler (WT-1-C)
4803                None,           // ingest_multistep_handler (Form 3 / #756)
4804                None,           // nag_watcher (#1389/#1398 L1)
4805                "test-session", // nag_session_id (#1389/#1398 L1)
4806            );
4807            assert!(
4808                resp.error.is_none(),
4809                "happy path failed for {}: {:?}",
4810                case.name,
4811                resp.error
4812            );
4813            assert!(
4814                resp.result.is_some(),
4815                "missing result for happy path {}: {:?}",
4816                case.name,
4817                resp
4818            );
4819        }
4820
4821        // Tier 2: required arg validation
4822        for case in cases {
4823            if let Some(required_arg) = case.required_arg {
4824                let mut bad_args = case.valid_args.clone();
4825                bad_args.as_object_mut().unwrap().remove(required_arg);
4826
4827                let req = make_tools_call(case.name, bad_args);
4828                let resp = handle_request(
4829                    &conn,
4830                    std::path::Path::new(":memory:"),
4831                    &req,
4832                    None,
4833                    None,
4834                    None,
4835                    &tier_config,
4836                    &crate::config::ResolvedModels::from_tier_preset(&tier_config),
4837                    None,
4838                    &resolved_ttl,
4839                    &resolved_scoring,
4840                    true,
4841                    false,
4842                    None,
4843                    &crate::profile::Profile::full(),
4844                    None,
4845                    None,
4846                    None,
4847                    None,           // federation_forward_url (#318)
4848                    None,           // recall_scope (#518)
4849                    None,           // atomise_handler (WT-1-C)
4850                    None,           // ingest_multistep_handler (Form 3 / #756)
4851                    None,           // nag_watcher (#1389/#1398 L1)
4852                    "test-session", // nag_session_id (#1389/#1398 L1)
4853                );
4854
4855                // Missing required args should produce an error response (handler returns Err)
4856                // which becomes an ok_response with isError=true, not a JSON-RPC error
4857                assert!(
4858                    resp.error.is_none() || resp.result.is_some(),
4859                    "unexpected RPC-layer error for {} (missing {}) should be handler-level Err",
4860                    case.name,
4861                    required_arg
4862                );
4863            }
4864        }
4865    }
4866
4867    // =====================================================================
4868    // W9 / Closer M9 — mcp.rs sweep
4869    //
4870    // Targets the four areas identified in the W9 close-out: tool-handler
4871    // happy/error pairs (per family), JSON-RPC framing (parse / unknown
4872    // method / invalid params), `auto_register_path_hierarchy`, and
4873    // `inject_namespace_standard`. All tests append-only at end of the
4874    // tests module — production code is untouched.
4875    //
4876    // Inner-fn factor-out: `dispatch_line` is added below as a test-only
4877    // helper that mirrors the parse-and-dispatch loop in `run_mcp_server`.
4878    // It is `#[cfg(test)]` and lives inside the `tests` module so it
4879    // does NOT leak into the public surface (no production callers are
4880    // affected). This is the minimum needed to drive parse-error /
4881    // truncation / two-requests-per-line cases without spinning up the
4882    // real stdio loop.
4883    // =====================================================================
4884
4885    /// Build a fully-defaulted handle_request invocation against an
4886    /// in-memory connection. Returns the response so individual tests
4887    /// can assert on `error` / `result` shape.
4888    fn invoke_handle_request(conn: &rusqlite::Connection, req: &RpcRequest) -> RpcResponse {
4889        let tier_config = FeatureTier::Keyword.config();
4890        let resolved_ttl = crate::config::ResolvedTtl::default();
4891        let resolved_scoring = crate::config::ResolvedScoring::default();
4892        handle_request(
4893            conn,
4894            std::path::Path::new(":memory:"),
4895            req,
4896            None,
4897            None,
4898            None,
4899            &tier_config,
4900            &crate::config::ResolvedModels::from_tier_preset(&tier_config),
4901            None,
4902            &resolved_ttl,
4903            &resolved_scoring,
4904            true,
4905            false,
4906            None,
4907            &crate::profile::Profile::full(),
4908            None,
4909            None,
4910            None,
4911            None,           // federation_forward_url (#318)
4912            None,           // recall_scope (#518)
4913            None,           // atomise_handler (WT-1-C)
4914            None,           // ingest_multistep_handler (Form 3 / #756)
4915            None,           // nag_watcher (#1389/#1398 L1) — disabled in this helper
4916            "test-session", // nag_session_id (#1389/#1398 L1)
4917        )
4918    }
4919
4920    /// Like [`invoke_handle_request`] but threads a live capture-nag
4921    /// watcher + session id through to the dispatch loop, exercising the
4922    /// #1389/#1398 L1 wiring end to end.
4923    fn invoke_handle_request_with_nag(
4924        conn: &rusqlite::Connection,
4925        req: &RpcRequest,
4926        nag_watcher: &crate::recover::nag::CaptureNagWatcher,
4927        nag_session_id: &str,
4928        mcp_client: Option<&str>,
4929    ) -> RpcResponse {
4930        let tier_config = FeatureTier::Keyword.config();
4931        let resolved_ttl = crate::config::ResolvedTtl::default();
4932        let resolved_scoring = crate::config::ResolvedScoring::default();
4933        handle_request(
4934            conn,
4935            std::path::Path::new(":memory:"),
4936            req,
4937            None,
4938            None,
4939            None,
4940            &tier_config,
4941            &crate::config::ResolvedModels::from_tier_preset(&tier_config),
4942            None,
4943            &resolved_ttl,
4944            &resolved_scoring,
4945            true,
4946            false,
4947            mcp_client,
4948            &crate::profile::Profile::full(),
4949            None,
4950            None,
4951            None,
4952            None, // federation_forward_url (#318)
4953            None, // recall_scope (#518)
4954            None, // atomise_handler (WT-1-C)
4955            None, // ingest_multistep_handler (Form 3 / #756)
4956            Some(nag_watcher),
4957            nag_session_id,
4958        )
4959    }
4960
4961    /// Count the `capture_lag` lines captured by an in-memory audit sink
4962    /// buffer. Each emitted audit event is one NDJSON line carrying
4963    /// `"action":"capture_lag"`.
4964    fn count_capture_lag_lines(buf: &std::sync::Arc<std::sync::Mutex<Vec<u8>>>) -> usize {
4965        let bytes = buf.lock().unwrap().clone();
4966        String::from_utf8(bytes)
4967            .unwrap()
4968            .lines()
4969            .filter(|l| l.contains("\"action\":\"capture_lag\""))
4970            .count()
4971    }
4972
4973    // ----- #1389 / #1398 L1 — capture-nag dispatch wiring -----
4974
4975    /// A `None` watcher is a no-op: `observe_capture_nag` returns
4976    /// `NagAction::None` and never touches the audit sink. Guards the
4977    /// "L1 disabled" path that the test helpers + opt-out transports take.
4978    #[test]
4979    fn observe_capture_nag_none_watcher_is_noop() {
4980        use crate::recover::nag::NagAction;
4981        let action = observe_capture_nag(None, "s", "memory_recall", &json!({}), Some("c"));
4982        assert_eq!(action, NagAction::None);
4983    }
4984
4985    /// The dispatch loop honours the watcher: N consecutive non-capture
4986    /// `tools/call`s cross the threshold and emit exactly one
4987    /// `capture_lag` audit event; a `memory_store`-class call resets the
4988    /// streak so a later drift re-arms the WARN. Drives the real
4989    /// `handle_request` path, proving the wiring (not just the watcher).
4990    #[test]
4991    fn dispatch_loop_emits_capture_lag_past_threshold_and_resets_on_write() {
4992        let _g = crate::audit::sink_test_lock();
4993        let buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>> =
4994            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4995        crate::audit::init_for_test(buf.clone());
4996
4997        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4998        // Primary threshold 3, escalation disabled so we isolate the
4999        // primary tier. `memory_capabilities` is always loaded under the
5000        // full profile, needs no DB rows, and classifies as `Other`.
5001        let watcher = crate::recover::nag::CaptureNagWatcher::new(3, 0);
5002        let session = "sess-1398";
5003        let client = Some("testclient");
5004
5005        let cap = make_tools_call("memory_capabilities", json!({}));
5006        // Two non-capture calls — under threshold, no event yet.
5007        for _ in 0..2 {
5008            let resp = invoke_handle_request_with_nag(&conn, &cap, &watcher, session, client);
5009            assert!(resp.error.is_none());
5010        }
5011        assert_eq!(
5012            count_capture_lag_lines(&buf),
5013            0,
5014            "no capture_lag before the threshold is crossed"
5015        );
5016
5017        // Third call crosses threshold 3 → exactly one capture_lag event.
5018        let resp = invoke_handle_request_with_nag(&conn, &cap, &watcher, session, client);
5019        assert!(resp.error.is_none());
5020        assert_eq!(
5021            count_capture_lag_lines(&buf),
5022            1,
5023            "primary threshold crossing emits exactly one capture_lag event"
5024        );
5025
5026        // Fourth call must NOT re-emit (idempotent per session/tier).
5027        invoke_handle_request_with_nag(&conn, &cap, &watcher, session, client);
5028        assert_eq!(
5029            count_capture_lag_lines(&buf),
5030            1,
5031            "no duplicate capture_lag while still in the same warned tier"
5032        );
5033
5034        // A write-class capture resets the streak, then a fresh run of
5035        // non-capture calls re-arms and fires a second, distinct event.
5036        let store = make_tools_call(
5037            "memory_store",
5038            json!({"title": "t", "content": "c", "tier": "short", "agent_id": "ai:testclient"}),
5039        );
5040        let store_resp = invoke_handle_request_with_nag(&conn, &store, &watcher, session, client);
5041        assert!(
5042            store_resp.error.is_none(),
5043            "store should succeed: {store_resp:?}"
5044        );
5045        for _ in 0..3 {
5046            invoke_handle_request_with_nag(&conn, &cap, &watcher, session, client);
5047        }
5048        assert_eq!(
5049            count_capture_lag_lines(&buf),
5050            2,
5051            "re-armed WARN after a write reset fires a second capture_lag"
5052        );
5053
5054        crate::audit::shutdown_for_test();
5055    }
5056
5057    /// The audit chain stays intact across `capture_lag` emissions — the
5058    /// new action is a first-class hash-chained event, not a side-channel.
5059    #[test]
5060    fn capture_lag_events_are_chained() {
5061        let _g = crate::audit::sink_test_lock();
5062        let buf: std::sync::Arc<std::sync::Mutex<Vec<u8>>> =
5063            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
5064        crate::audit::init_for_test(buf.clone());
5065
5066        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5067        let watcher = crate::recover::nag::CaptureNagWatcher::new(1, 2);
5068        let cap = make_tools_call("memory_capabilities", json!({}));
5069        // Two calls: first crosses primary (1), second crosses escalation (2).
5070        invoke_handle_request_with_nag(&conn, &cap, &watcher, "s", Some("c"));
5071        invoke_handle_request_with_nag(&conn, &cap, &watcher, "s", Some("c"));
5072        assert_eq!(
5073            count_capture_lag_lines(&buf),
5074            2,
5075            "primary + escalation each emit a capture_lag event"
5076        );
5077
5078        let bytes = buf.lock().unwrap().clone();
5079        let report = crate::audit::verify_chain_from_reader(bytes.as_slice()).unwrap();
5080        assert!(
5081            report.first_failure.is_none(),
5082            "capture_lag emissions must keep the hash chain intact: {:?}",
5083            report.first_failure
5084        );
5085
5086        crate::audit::shutdown_for_test();
5087    }
5088
5089    /// Test-only helper that mirrors the parse-then-dispatch portion of
5090    /// `run_mcp_server`'s stdin loop for a single line. Returns:
5091    /// - `Some(RpcResponse)` for any line that produces a response
5092    ///   (including parse errors and successful dispatches),
5093    /// - `None` for lines that should not produce a response (blank
5094    ///   lines, valid notifications without an id).
5095    ///
5096    /// This is the minimum factor-out needed to exercise the framing
5097    /// branches that live inside `run_mcp_server` (parse error, blank
5098    /// skip, notification skip) without spinning up real stdio.
5099    fn dispatch_line(conn: &rusqlite::Connection, line: &str) -> Option<RpcResponse> {
5100        if line.trim().is_empty() {
5101            return None;
5102        }
5103        let req: RpcRequest = match serde_json::from_str(line) {
5104            Ok(r) => r,
5105            Err(e) => {
5106                return Some(err_response(
5107                    Value::Null,
5108                    -32700,
5109                    format!("parse error: {e}"),
5110                ));
5111            }
5112        };
5113        if req.id.is_none() || req.id == Some(Value::Null) {
5114            return None;
5115        }
5116        Some(invoke_handle_request(conn, &req))
5117    }
5118
5119    // ------------------------------------------------------------------
5120    // Tool-handler happy-path coverage (paired with error tests below).
5121    // The smoke matrix above already confirms every tool dispatches; the
5122    // tests below assert on the *shape* of the success result so a
5123    // handler that silently changes its return key set fails loudly.
5124    // ------------------------------------------------------------------
5125
5126    #[test]
5127    fn handle_store_happy_returns_id_and_tier() {
5128        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5129        let req = make_tools_call(
5130            "memory_store",
5131            json!({"title": "t", "content": "c", "namespace": "m9-store", "tier": Tier::Short.as_str()}),
5132        );
5133        let resp = invoke_handle_request(&conn, &req);
5134        assert!(resp.error.is_none());
5135        let text = resp.result.unwrap()["content"][0]["text"]
5136            .as_str()
5137            .unwrap()
5138            .to_string();
5139        let val: Value = serde_json::from_str(&text).unwrap();
5140        assert!(val["id"].is_string());
5141        assert_eq!(val["tier"], Tier::Short.as_str());
5142    }
5143
5144    #[test]
5145    fn handle_store_error_missing_title() {
5146        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5147        let req = make_tools_call("memory_store", json!({"content": "c"}));
5148        let resp = invoke_handle_request(&conn, &req);
5149        // Handler-level errors come back as ok_response with isError=true.
5150        let result = resp.result.unwrap();
5151        assert_eq!(result["isError"], true);
5152    }
5153
5154    #[test]
5155    fn handle_recall_happy_returns_memories_array() {
5156        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5157        let req = make_tools_call(
5158            "memory_recall",
5159            json!({"context": "anything", "format": "json"}),
5160        );
5161        let resp = invoke_handle_request(&conn, &req);
5162        assert!(resp.error.is_none());
5163        let text = resp.result.unwrap()["content"][0]["text"]
5164            .as_str()
5165            .unwrap()
5166            .to_string();
5167        let val: Value = serde_json::from_str(&text).unwrap();
5168        assert!(val["memories"].is_array());
5169        assert!(val["count"].is_u64());
5170    }
5171
5172    #[test]
5173    fn handle_recall_budget_tokens_zero_returns_empty() {
5174        // Phase P6 (R1): budget_tokens=0 is now a valid request — the
5175        // user explicitly asked for zero context. Returns an empty
5176        // memories array with meta.budget_overflow=false (the user
5177        // didn't overflow anything, they asked for nothing). Supersedes
5178        // the v0.6.3 Ultrareview #348 hard-reject of 0.
5179        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5180        let req = make_tools_call(
5181            "memory_recall",
5182            json!({"context": "x", "budget_tokens": 0, "format": "json"}),
5183        );
5184        let resp = invoke_handle_request(&conn, &req);
5185        assert!(resp.error.is_none(), "budget_tokens=0 must not error");
5186        let text = resp.result.unwrap()["content"][0]["text"]
5187            .as_str()
5188            .unwrap()
5189            .to_string();
5190        let val: Value = serde_json::from_str(&text).unwrap();
5191        assert_eq!(val["count"], 0, "budget_tokens=0 returns zero memories");
5192        assert_eq!(val["budget_tokens"], 0);
5193        assert_eq!(val["tokens_used"], 0);
5194        assert_eq!(val["meta"]["budget_overflow"], false);
5195        assert_eq!(val["meta"]["budget_tokens_used"], 0);
5196        assert_eq!(val["meta"]["budget_tokens_remaining"], 0);
5197    }
5198
5199    #[test]
5200    fn handle_search_happy_returns_results_array() {
5201        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5202        let req = make_tools_call(
5203            "memory_search",
5204            json!({"query": "needle", "format": "json"}),
5205        );
5206        let resp = invoke_handle_request(&conn, &req);
5207        assert!(resp.error.is_none());
5208        let text = resp.result.unwrap()["content"][0]["text"]
5209            .as_str()
5210            .unwrap()
5211            .to_string();
5212        let val: Value = serde_json::from_str(&text).unwrap();
5213        assert!(val["results"].is_array());
5214        assert!(val["count"].is_u64());
5215    }
5216
5217    #[test]
5218    fn handle_search_error_missing_query() {
5219        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5220        let req = make_tools_call("memory_search", json!({}));
5221        let resp = invoke_handle_request(&conn, &req);
5222        let result = resp.result.unwrap();
5223        assert_eq!(result["isError"], true);
5224    }
5225
5226    #[test]
5227    fn handle_get_happy_returns_memory() {
5228        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5229        // Insert a memory directly to know the id.
5230        let mem = Memory {
5231            id: uuid::Uuid::new_v4().to_string(),
5232            tier: Tier::Mid,
5233            namespace: "m9-get".into(),
5234            title: "t".into(),
5235            content: "c".into(),
5236            tags: vec![],
5237            priority: 5,
5238            confidence: 1.0,
5239            source: "test".into(),
5240            access_count: 0,
5241            created_at: chrono::Utc::now().to_rfc3339(),
5242            updated_at: chrono::Utc::now().to_rfc3339(),
5243            last_accessed_at: None,
5244            expires_at: None,
5245            metadata: json!({}),
5246            reflection_depth: 0,
5247            memory_kind: crate::models::MemoryKind::Observation,
5248            entity_id: None,
5249            persona_version: None,
5250            citations: Vec::new(),
5251            source_uri: None,
5252            source_span: None,
5253            confidence_source: crate::models::ConfidenceSource::CallerProvided,
5254            confidence_signals: None,
5255            confidence_decayed_at: None,
5256            version: 1,
5257        };
5258        let id = db::insert(&conn, &mem).unwrap();
5259        let req = make_tools_call("memory_get", json!({"id": id}));
5260        let resp = invoke_handle_request(&conn, &req);
5261        assert!(resp.error.is_none());
5262        let text = resp.result.unwrap()["content"][0]["text"]
5263            .as_str()
5264            .unwrap()
5265            .to_string();
5266        let val: Value = serde_json::from_str(&text).unwrap();
5267        assert_eq!(val["title"], "t");
5268        assert_eq!(val["namespace"], "m9-get");
5269        assert!(val["links"].is_array());
5270    }
5271
5272    #[test]
5273    fn handle_get_error_unknown_id() {
5274        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5275        let req = make_tools_call(
5276            "memory_get",
5277            json!({"id": "00000000-0000-0000-0000-000000000000"}),
5278        );
5279        let resp = invoke_handle_request(&conn, &req);
5280        let result = resp.result.unwrap();
5281        assert_eq!(result["isError"], true);
5282        assert!(
5283            result["content"][0]["text"]
5284                .as_str()
5285                .unwrap()
5286                .contains("not found")
5287        );
5288    }
5289
5290    #[test]
5291    fn handle_list_happy_returns_memories_array() {
5292        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5293        let req = make_tools_call("memory_list", json!({"format": "json"}));
5294        let resp = invoke_handle_request(&conn, &req);
5295        assert!(resp.error.is_none());
5296        let text = resp.result.unwrap()["content"][0]["text"]
5297            .as_str()
5298            .unwrap()
5299            .to_string();
5300        let val: Value = serde_json::from_str(&text).unwrap();
5301        assert!(val["memories"].is_array());
5302    }
5303
5304    #[test]
5305    fn handle_list_error_invalid_agent_id() {
5306        // Invalid agent_id (contains a space) is rejected upstream.
5307        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5308        let req = make_tools_call("memory_list", json!({"agent_id": "has space"}));
5309        let resp = invoke_handle_request(&conn, &req);
5310        let result = resp.result.unwrap();
5311        assert_eq!(result["isError"], true);
5312    }
5313
5314    #[test]
5315    fn handle_delete_happy_removes_existing_memory() {
5316        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5317        let mem = Memory {
5318            id: uuid::Uuid::new_v4().to_string(),
5319            tier: Tier::Mid,
5320            namespace: "m9-del".into(),
5321            title: "t".into(),
5322            content: "c".into(),
5323            tags: vec![],
5324            priority: 5,
5325            confidence: 1.0,
5326            source: "test".into(),
5327            access_count: 0,
5328            created_at: chrono::Utc::now().to_rfc3339(),
5329            updated_at: chrono::Utc::now().to_rfc3339(),
5330            last_accessed_at: None,
5331            expires_at: None,
5332            metadata: json!({}),
5333            reflection_depth: 0,
5334            memory_kind: crate::models::MemoryKind::Observation,
5335            entity_id: None,
5336            persona_version: None,
5337            citations: Vec::new(),
5338            source_uri: None,
5339            source_span: None,
5340            confidence_source: crate::models::ConfidenceSource::CallerProvided,
5341            confidence_signals: None,
5342            confidence_decayed_at: None,
5343            version: 1,
5344        };
5345        let id = db::insert(&conn, &mem).unwrap();
5346        let req = make_tools_call("memory_delete", json!({"id": id}));
5347        let resp = invoke_handle_request(&conn, &req);
5348        assert!(resp.error.is_none());
5349        let text = resp.result.unwrap()["content"][0]["text"]
5350            .as_str()
5351            .unwrap()
5352            .to_string();
5353        let val: Value = serde_json::from_str(&text).unwrap();
5354        assert_eq!(val["deleted"], true);
5355    }
5356
5357    #[test]
5358    fn handle_delete_error_empty_id() {
5359        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5360        let req = make_tools_call("memory_delete", json!({"id": ""}));
5361        let resp = invoke_handle_request(&conn, &req);
5362        let result = resp.result.unwrap();
5363        assert_eq!(result["isError"], true);
5364    }
5365
5366    #[test]
5367    fn handle_link_happy_returns_linked_true() {
5368        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5369        let mut ids = Vec::new();
5370        for tag in ["a", "b"] {
5371            let mem = Memory {
5372                id: uuid::Uuid::new_v4().to_string(),
5373                tier: Tier::Mid,
5374                namespace: "m9-link".into(),
5375                title: tag.into(),
5376                content: "c".into(),
5377                tags: vec![],
5378                priority: 5,
5379                confidence: 1.0,
5380                source: "test".into(),
5381                access_count: 0,
5382                created_at: chrono::Utc::now().to_rfc3339(),
5383                updated_at: chrono::Utc::now().to_rfc3339(),
5384                last_accessed_at: None,
5385                expires_at: None,
5386                metadata: json!({}),
5387                reflection_depth: 0,
5388                memory_kind: crate::models::MemoryKind::Observation,
5389                entity_id: None,
5390                persona_version: None,
5391                citations: Vec::new(),
5392                source_uri: None,
5393                source_span: None,
5394                confidence_source: crate::models::ConfidenceSource::CallerProvided,
5395                confidence_signals: None,
5396                confidence_decayed_at: None,
5397                version: 1,
5398            };
5399            ids.push(db::insert(&conn, &mem).unwrap());
5400        }
5401        let req = make_tools_call(
5402            "memory_link",
5403            json!({"source_id": ids[0], "target_id": ids[1], "relation": "related_to"}),
5404        );
5405        let resp = invoke_handle_request(&conn, &req);
5406        assert!(resp.error.is_none());
5407        let text = resp.result.unwrap()["content"][0]["text"]
5408            .as_str()
5409            .unwrap()
5410            .to_string();
5411        let val: Value = serde_json::from_str(&text).unwrap();
5412        assert_eq!(val["linked"], true);
5413        // v0.7 H2 — wire response carries attest_level. Default
5414        // `invoke_handle_request` passes `active_keypair = None` so
5415        // the level is "unsigned" — the v0.6.4 backward-compat shape.
5416        assert_eq!(val["attest_level"], "unsigned");
5417    }
5418
5419    // v0.7 H2 — when an active keypair is plumbed through to the
5420    // memory_link MCP handler, the wire response reports
5421    // attest_level = "self_signed" and the underlying row carries a
5422    // 64-byte signature.
5423    #[test]
5424    fn handle_link_with_active_keypair_returns_self_signed() {
5425        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5426        let mut ids = Vec::new();
5427        for tag in ["a", "b"] {
5428            let mem = Memory {
5429                id: uuid::Uuid::new_v4().to_string(),
5430                tier: Tier::Mid,
5431                namespace: "h2-link".into(),
5432                title: tag.into(),
5433                content: "c".into(),
5434                tags: vec![],
5435                priority: 5,
5436                confidence: 1.0,
5437                source: "test".into(),
5438                access_count: 0,
5439                created_at: chrono::Utc::now().to_rfc3339(),
5440                updated_at: chrono::Utc::now().to_rfc3339(),
5441                last_accessed_at: None,
5442                expires_at: None,
5443                metadata: json!({}),
5444                reflection_depth: 0,
5445                memory_kind: crate::models::MemoryKind::Observation,
5446                entity_id: None,
5447                persona_version: None,
5448                citations: Vec::new(),
5449                source_uri: None,
5450                source_span: None,
5451                confidence_source: crate::models::ConfidenceSource::CallerProvided,
5452                confidence_signals: None,
5453                confidence_decayed_at: None,
5454                version: 1,
5455            };
5456            ids.push(db::insert(&conn, &mem).unwrap());
5457        }
5458        let req = make_tools_call(
5459            "memory_link",
5460            json!({"source_id": ids[0], "target_id": ids[1], "relation": "related_to"}),
5461        );
5462
5463        // Drive handle_request directly so we can pass an active keypair.
5464        let kp = crate::identity::keypair::generate("alice").unwrap();
5465        let tier_config = FeatureTier::Keyword.config();
5466        let resolved_ttl = crate::config::ResolvedTtl::default();
5467        let resolved_scoring = crate::config::ResolvedScoring::default();
5468        let resp = handle_request(
5469            &conn,
5470            std::path::Path::new(":memory:"),
5471            &req,
5472            None,
5473            None,
5474            None,
5475            &tier_config,
5476            &crate::config::ResolvedModels::from_tier_preset(&tier_config),
5477            None,
5478            &resolved_ttl,
5479            &resolved_scoring,
5480            true,
5481            false,
5482            None,
5483            &crate::profile::Profile::full(),
5484            None,
5485            Some(&kp),
5486            None,
5487            None,           // federation_forward_url (#318)
5488            None,           // recall_scope (#518)
5489            None,           // atomise_handler (WT-1-C)
5490            None,           // ingest_multistep_handler (Form 3 / #756)
5491            None,           // nag_watcher (#1389/#1398 L1)
5492            "test-session", // nag_session_id (#1389/#1398 L1)
5493        );
5494        assert!(resp.error.is_none(), "MCP error: {:?}", resp.error);
5495        let text = resp.result.unwrap()["content"][0]["text"]
5496            .as_str()
5497            .unwrap()
5498            .to_string();
5499        let val: Value = serde_json::from_str(&text).unwrap();
5500        assert_eq!(val["linked"], true);
5501        assert_eq!(
5502            val["attest_level"], "self_signed",
5503            "active keypair path must surface self_signed"
5504        );
5505
5506        // The signature column is now populated and 64 bytes.
5507        let sig: Option<Vec<u8>> = conn
5508            .query_row(
5509                "SELECT signature FROM memory_links \
5510                 WHERE source_id = ?1 AND target_id = ?2",
5511                rusqlite::params![&ids[0], &ids[1]],
5512                |r| r.get(0),
5513            )
5514            .unwrap();
5515        let sig_bytes = sig.expect("signed link must persist a signature blob");
5516        assert_eq!(sig_bytes.len(), 64);
5517    }
5518
5519    // Issue #815 — when an active keypair is plumbed through to the
5520    // memory_reflect MCP handler, every reflects_on edge written
5521    // inside the reflect transaction lands as attest_level =
5522    // 'self_signed' with a 64-byte Ed25519 signature. Mirrors the
5523    // `handle_link_with_active_keypair_returns_self_signed` shape
5524    // above so the substrate's two write-paths-that-create-links
5525    // (memory_link, memory_reflect) are pinned by parallel
5526    // regression tests.
5527    //
5528    // Pre-#815 every reflects_on edge from memory_reflect landed
5529    // as 'unsigned' because storage::reflect_with_hooks called
5530    // `create_link` (the unsigned helper) regardless of whether
5531    // the caller had loaded a daemon keypair. The fix routes
5532    // through `create_link_signed` with the keypair threaded via
5533    // ReflectHooks::active_keypair; this test pins that contract.
5534    #[test]
5535    fn handle_reflect_with_active_keypair_returns_signed_reflects_on_edges() {
5536        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5537        // Three source observations the reflection will fan out to.
5538        // Three is the minimum interesting count: it pins that every
5539        // link in the loop gets signed, not just the first.
5540        let mut source_ids = Vec::new();
5541        for tag in ["src-a", "src-b", "src-c"] {
5542            let mem = Memory {
5543                id: uuid::Uuid::new_v4().to_string(),
5544                tier: Tier::Mid,
5545                namespace: "issue-815-reflect".into(),
5546                title: tag.into(),
5547                content: format!("body for {tag}"),
5548                tags: vec![],
5549                priority: 5,
5550                confidence: 1.0,
5551                source: "test".into(),
5552                access_count: 0,
5553                created_at: chrono::Utc::now().to_rfc3339(),
5554                updated_at: chrono::Utc::now().to_rfc3339(),
5555                last_accessed_at: None,
5556                expires_at: None,
5557                metadata: json!({}),
5558                reflection_depth: 0,
5559                memory_kind: crate::models::MemoryKind::Observation,
5560                entity_id: None,
5561                persona_version: None,
5562                citations: Vec::new(),
5563                source_uri: None,
5564                source_span: None,
5565                confidence_source: crate::models::ConfidenceSource::CallerProvided,
5566                confidence_signals: None,
5567                confidence_decayed_at: None,
5568                version: 1,
5569            };
5570            source_ids.push(db::insert(&conn, &mem).unwrap());
5571        }
5572        let req = make_tools_call(
5573            "memory_reflect",
5574            json!({
5575                "source_ids": source_ids,
5576                "title": "issue-815 reflect signing pin",
5577                "content": "reflects_on edges must come back self_signed",
5578                "namespace": "issue-815-reflect",
5579            }),
5580        );
5581
5582        let kp = crate::identity::keypair::generate("alice").unwrap();
5583        let tier_config = FeatureTier::Keyword.config();
5584        let resolved_ttl = crate::config::ResolvedTtl::default();
5585        let resolved_scoring = crate::config::ResolvedScoring::default();
5586        let resp = handle_request(
5587            &conn,
5588            std::path::Path::new(":memory:"),
5589            &req,
5590            None,
5591            None,
5592            None,
5593            &tier_config,
5594            &crate::config::ResolvedModels::from_tier_preset(&tier_config),
5595            None,
5596            &resolved_ttl,
5597            &resolved_scoring,
5598            true,
5599            false,
5600            None,
5601            &crate::profile::Profile::full(),
5602            None,
5603            Some(&kp),
5604            None,
5605            None,           // federation_forward_url (#318)
5606            None,           // recall_scope (#518)
5607            None,           // atomise_handler (WT-1-C)
5608            None,           // ingest_multistep_handler (Form 3 / #756)
5609            None,           // nag_watcher (#1389/#1398 L1)
5610            "test-session", // nag_session_id (#1389/#1398 L1)
5611        );
5612        assert!(resp.error.is_none(), "MCP error: {:?}", resp.error);
5613        let text = resp.result.unwrap()["content"][0]["text"]
5614            .as_str()
5615            .unwrap()
5616            .to_string();
5617        let val: Value = serde_json::from_str(&text).unwrap();
5618        let reflection_id = val["id"]
5619            .as_str()
5620            .expect("reflect response must carry the new memory id")
5621            .to_string();
5622
5623        // Every reflects_on edge from this reflection must be signed
5624        // with a 64-byte Ed25519 signature, and the row's attest_level
5625        // must read 'self_signed'. We check all three edges so a
5626        // partial-fix regression (signs the first edge only) cannot
5627        // pass.
5628        let mut stmt = conn
5629            .prepare(
5630                "SELECT target_id, attest_level, signature \
5631                 FROM memory_links \
5632                 WHERE source_id = ?1 AND relation = 'reflects_on' \
5633                 ORDER BY created_at",
5634            )
5635            .unwrap();
5636        let rows: Vec<(String, String, Option<Vec<u8>>)> = stmt
5637            .query_map(rusqlite::params![&reflection_id], |r| {
5638                Ok((
5639                    r.get::<_, String>(0)?,
5640                    r.get::<_, String>(1)?,
5641                    r.get::<_, Option<Vec<u8>>>(2)?,
5642                ))
5643            })
5644            .unwrap()
5645            .map(std::result::Result::unwrap)
5646            .collect();
5647        assert_eq!(
5648            rows.len(),
5649            source_ids.len(),
5650            "expected one reflects_on edge per source; got {rows:?}"
5651        );
5652        for (target_id, attest_level, signature) in &rows {
5653            assert_eq!(
5654                attest_level, "self_signed",
5655                "reflects_on edge to {target_id} must surface self_signed (got {attest_level})"
5656            );
5657            let sig_bytes = signature.as_ref().unwrap_or_else(|| {
5658                panic!("reflects_on edge to {target_id} must persist a signature blob")
5659            });
5660            assert_eq!(
5661                sig_bytes.len(),
5662                64,
5663                "reflects_on edge to {target_id} signature must be 64 bytes (got {})",
5664                sig_bytes.len()
5665            );
5666        }
5667    }
5668
5669    // Issue #1315 — `memory_reflect` MCP **wire-layer** metadata
5670    // passthrough regression pin.
5671    //
5672    // The PR #1177 / issue #1172 fix landed the substrate pin
5673    // (`db_reflect_preserves_caller_supplied_entity_id`) and a
5674    // direct-handler pin (`mcp_handle_reflect_preserves_caller_
5675    // supplied_entity_id` in `tests/issue_1172_reflect_metadata_
5676    // passthrough.rs`) — but invariant 3 of that suite calls
5677    // `mcp::handle_reflect` **directly**, bypassing the JSON-RPC
5678    // `tools/call` dispatcher (`handle_request`'s `arguments`
5679    // extraction → `lookup_dispatch` → `dispatch_memory_reflect` →
5680    // `handle_reflect`). The gap left every layer between
5681    // `req.params["arguments"]` extraction and the substrate write
5682    // path unpinned — a future refactor that, say, threaded the
5683    // dispatcher through `serde_json::from_value::<ReflectRequest>`
5684    // with `#[schemars(deny_unknown_fields)]` would silently strip
5685    // every custom caller key and the existing #1172 suite would
5686    // still pass.
5687    //
5688    // This pin closes the gap. It builds a real JSON-RPC `tools/call`
5689    // request and runs it through `handle_request` (the same surface
5690    // `run_mcp_server`'s stdin loop drives) so a regression in any of
5691    // the layers between `req.params["arguments"]` extraction and
5692    // `handle_reflect`'s `params["metadata"]` read fires here, not in
5693    // production. Asserts:
5694    //
5695    //   1. `metadata.entity_id` (the PERF-8 step-1 path) survives the
5696    //      transport.
5697    //   2. An arbitrary caller-supplied key (`probe="P2"`) survives —
5698    //      pins that the drop point is not specific to `entity_id` but
5699    //      preserves the full additive contract.
5700    //   3. `mentioned_entity_id` denormalised column is populated, the
5701    //      end-to-end persona-binding path (#1172 invariant 4) holds
5702    //      via the wire layer too.
5703    #[test]
5704    fn issue_1315_memory_reflect_wire_layer_preserves_caller_metadata() {
5705        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5706        // Seed three source observations the reflection will fan out
5707        // to (matching the #815 test shape so the reflect transaction
5708        // exercises the same code paths).
5709        let mut source_ids = Vec::new();
5710        for tag in ["src-a", "src-b", "src-c"] {
5711            let mem = Memory {
5712                id: uuid::Uuid::new_v4().to_string(),
5713                tier: Tier::Mid,
5714                namespace: "issue-1315-wire".into(),
5715                title: tag.into(),
5716                content: format!("body for {tag}"),
5717                tags: vec![],
5718                priority: 5,
5719                confidence: 1.0,
5720                source: "api".into(),
5721                access_count: 0,
5722                created_at: chrono::Utc::now().to_rfc3339(),
5723                updated_at: chrono::Utc::now().to_rfc3339(),
5724                last_accessed_at: None,
5725                expires_at: None,
5726                metadata: json!({}),
5727                reflection_depth: 0,
5728                memory_kind: crate::models::MemoryKind::Observation,
5729                entity_id: None,
5730                persona_version: None,
5731                citations: Vec::new(),
5732                source_uri: None,
5733                source_span: None,
5734                confidence_source: crate::models::ConfidenceSource::CallerProvided,
5735                confidence_signals: None,
5736                confidence_decayed_at: None,
5737                version: 1,
5738            };
5739            source_ids.push(db::insert(&conn, &mem).unwrap());
5740        }
5741
5742        // Build a wire-shape JSON-RPC tools/call with caller-supplied
5743        // custom metadata keys. `entity_id` and `probe` are both
5744        // expected to survive transport per the documented additive
5745        // metadata contract — the #1172 PR claimed to fix entity_id,
5746        // but the wire path drops every custom key in flight.
5747        let req = make_tools_call(
5748            "memory_reflect",
5749            json!({
5750                "source_ids": source_ids,
5751                "title": "issue-1315 wire-layer metadata pin",
5752                "content": "caller metadata.entity_id + probe must round-trip through tools/call",
5753                "namespace": "issue-1315-wire",
5754                "metadata": {
5755                    "entity_id": "entity-1315-wire",
5756                    "probe": "P2",
5757                },
5758            }),
5759        );
5760
5761        let resp = invoke_handle_request(&conn, &req);
5762        assert!(
5763            resp.error.is_none(),
5764            "expected ok rpc envelope; got error: {:?}",
5765            resp.error
5766        );
5767        // MCP-spec tool envelope: success carries `content[0].text`
5768        // with the handler's JSON result serialized as a string.
5769        let result = resp.result.expect("result envelope");
5770        assert!(
5771            result.get("isError").is_none_or(|v| v != true),
5772            "tools/call must not surface isError=true; result: {result}"
5773        );
5774        let text = result["content"][0]["text"]
5775            .as_str()
5776            .expect("content[0].text on memory_reflect ok envelope");
5777        let parsed: Value = serde_json::from_str(text).expect("reflect result text is JSON");
5778        let reflection_id = parsed["id"]
5779            .as_str()
5780            .expect("reflect result carries the new memory id")
5781            .to_string();
5782
5783        // Pull the row's metadata + denormalised mention column
5784        // straight from sqlite — this is the same shape the #1172
5785        // suite probes and the same column `persona::load_
5786        // reflections_for_entity` keys off in production.
5787        let (meta_str, mention): (String, Option<String>) = conn
5788            .query_row(
5789                "SELECT metadata, mentioned_entity_id FROM memories WHERE id = ?1",
5790                rusqlite::params![&reflection_id],
5791                |r| Ok((r.get(0)?, r.get(1)?)),
5792            )
5793            .expect("select reflection row");
5794        let meta: Value = serde_json::from_str(&meta_str).expect("metadata is JSON");
5795
5796        // Invariant (1): entity_id round-trips through the wire path.
5797        assert_eq!(
5798            meta.get("entity_id").and_then(Value::as_str),
5799            Some("entity-1315-wire"),
5800            "wire path must preserve metadata.entity_id; full metadata = {meta}"
5801        );
5802        // Invariant (2): arbitrary caller-supplied key survives too.
5803        // The defect drops every custom key — not just entity_id — so
5804        // pinning a second key proves the fix isn't a one-key hack.
5805        assert_eq!(
5806            meta.get("probe").and_then(Value::as_str),
5807            Some("P2"),
5808            "wire path must preserve every caller-supplied metadata key; full metadata = {meta}"
5809        );
5810        // Invariant (3): PERF-8 denormalised column reflects the
5811        // round-tripped entity_id so the persona-binding query
5812        // (`WHERE mentioned_entity_id = ?`) still finds the row when
5813        // it was minted through the wire path.
5814        assert_eq!(
5815            mention.as_deref(),
5816            Some("entity-1315-wire"),
5817            "mentioned_entity_id column must be populated from the wire-supplied metadata.entity_id"
5818        );
5819        // The system-generated keys also land alongside (additive
5820        // contract — caller wins on collision, but the system splices
5821        // its own keys when the caller didn't pre-set them).
5822        assert!(
5823            meta.get("agent_id").is_some(),
5824            "system-generated agent_id must still be present"
5825        );
5826        assert!(
5827            meta.get("reflection_metadata").is_some(),
5828            "system-generated reflection_metadata block must still be present"
5829        );
5830    }
5831
5832    #[test]
5833    fn handle_link_error_missing_target_id() {
5834        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5835        let req = make_tools_call("memory_link", json!({"source_id": "x"}));
5836        let resp = invoke_handle_request(&conn, &req);
5837        let result = resp.result.unwrap();
5838        assert_eq!(result["isError"], true);
5839    }
5840
5841    #[test]
5842    fn handle_promote_error_unknown_id() {
5843        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5844        let req = make_tools_call(
5845            "memory_promote",
5846            json!({"id": "00000000-0000-0000-0000-000000000000"}),
5847        );
5848        let resp = invoke_handle_request(&conn, &req);
5849        let result = resp.result.unwrap();
5850        assert_eq!(result["isError"], true);
5851    }
5852
5853    #[test]
5854    fn handle_consolidate_error_missing_summary_keyword_tier() {
5855        // Keyword tier has no LLM, so `summary` is required.
5856        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5857        let req = make_tools_call(
5858            "memory_consolidate",
5859            json!({"ids": ["a", "b"], "title": "t"}),
5860        );
5861        let resp = invoke_handle_request(&conn, &req);
5862        let result = resp.result.unwrap();
5863        assert_eq!(result["isError"], true);
5864        let msg = result["content"][0]["text"].as_str().unwrap();
5865        assert!(msg.contains("summary"));
5866    }
5867
5868    #[test]
5869    fn handle_capabilities_happy_returns_tier_struct() {
5870        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5871        let req = make_tools_call("memory_capabilities", json!({}));
5872        let resp = invoke_handle_request(&conn, &req);
5873        assert!(resp.error.is_none());
5874        let text = resp.result.unwrap()["content"][0]["text"]
5875            .as_str()
5876            .unwrap()
5877            .to_string();
5878        let val: Value = serde_json::from_str(&text).unwrap();
5879        assert!(val["tier"].is_string());
5880        assert!(val["features"].is_object());
5881    }
5882
5883    /// v0.6.3.1 (capabilities schema v2 — P1 honesty patch).
5884    /// Every new top-level block is present with the expected shape.
5885    /// Dropped fields (`rule_summary`, `by_event`, `subscribers`,
5886    /// `default_timeout_seconds`) must be absent from v2 output.
5887    ///
5888    /// v0.7.0 A5: this test pins v2 explicitly via `accept="v2"` since
5889    /// the default is now v3. v2 backward-compat is preserved
5890    /// indefinitely; this test is the contract that proves it.
5891    #[test]
5892    fn mcp_capabilities_v2_schema_includes_all_blocks() {
5893        // v0.7.0 K3: serialize on the gate-mode atomic + clear any
5894        // sibling-test override so `permissions.mode` reflects the
5895        // documented `advisory` zero-state.
5896        let _gate = crate::config::lock_permissions_mode_for_test();
5897        crate::config::clear_permissions_mode_override_for_test();
5898        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5899        let req = make_tools_call("memory_capabilities", json!({"accept": "v2"}));
5900        let resp = invoke_handle_request(&conn, &req);
5901        let text = resp.result.unwrap()["content"][0]["text"]
5902            .as_str()
5903            .unwrap()
5904            .to_string();
5905        let val: Value = serde_json::from_str(&text).unwrap();
5906
5907        assert_eq!(val["schema_version"], "2", "schema_version bumped to 2");
5908
5909        // permissions block — `mode` flipped from "ask" to "advisory"
5910        // (P1 honesty patch: no enforcement gate exists pre-P4).
5911        assert!(val["permissions"].is_object(), "permissions block present");
5912        assert_eq!(val["permissions"]["mode"], "advisory");
5913        assert!(val["permissions"]["active_rules"].is_number());
5914        assert!(
5915            val["permissions"].get("rule_summary").is_none(),
5916            "v2 drops rule_summary (no per-rule serializer)"
5917        );
5918        // v0.6.3.1 (P4, audit G1): inheritance posture must be reported
5919        // as "enforced" so consumers can distinguish a fixed deployment
5920        // from a pre-fix one (which historically returned "display_only").
5921        assert_eq!(val["permissions"]["inheritance"], "enforced");
5922
5923        // hooks block — `by_event` dropped (no event registry).
5924        assert!(val["hooks"].is_object(), "hooks block present");
5925        assert!(val["hooks"]["registered_count"].is_number());
5926        assert!(
5927            val["hooks"].get("by_event").is_none(),
5928            "v2 drops hooks.by_event (no event registry)"
5929        );
5930
5931        // compaction block — planned-feature shape (P1 honesty patch).
5932        assert!(val["compaction"].is_object(), "compaction block present");
5933        assert_eq!(val["compaction"]["planned"], true);
5934        assert_eq!(val["compaction"]["enabled"], false);
5935        assert_eq!(val["compaction"]["version"], "v0.8+");
5936        assert!(val["compaction"].get("interval_minutes").is_none());
5937        assert!(val["compaction"].get("last_run_at").is_none());
5938        assert!(val["compaction"].get("last_run_stats").is_none());
5939
5940        // approval block — `subscribers` and `default_timeout_seconds`
5941        // dropped (no subscription API, no sweeper).
5942        assert!(val["approval"].is_object(), "approval block present");
5943        assert!(val["approval"]["pending_requests"].is_number());
5944        assert!(
5945            val["approval"].get("subscribers").is_none(),
5946            "v2 drops approval.subscribers (no subscription API)"
5947        );
5948        assert!(
5949            val["approval"].get("default_timeout_seconds").is_none(),
5950            "v2 drops approval.default_timeout_seconds (no sweeper)"
5951        );
5952
5953        // v0.7.0 #1324 — transcripts substrate shipped at v0.7.0
5954        // (zstd-3 BLOB store, memory_transcripts table,
5955        // memory_transcript_links join, replay_transcript_union,
5956        // memory_replay MCP tool, lifecycle sweep). Capability flag
5957        // reads `planned: false, enabled: false` at zero-state — no
5958        // rows in `memory_transcripts` yet. The MCP / HTTP overlay
5959        // flips `enabled: true` when a non-zero count is observed.
5960        assert!(val["transcripts"].is_object(), "transcripts block present");
5961        assert_eq!(val["transcripts"]["planned"], false);
5962        assert_eq!(val["transcripts"]["enabled"], false);
5963        assert_eq!(val["transcripts"]["version"], env!("CARGO_PKG_VERSION"));
5964
5965        // memory_reflection: planned-feature object (was bool in v1).
5966        // v0.7.0 recursive-learning (issue #655) Tasks 1-6 shipped the
5967        // primitive, so the flag is `planned=false, enabled=true`.
5968        assert_eq!(val["features"]["memory_reflection"]["planned"], false);
5969        assert_eq!(val["features"]["memory_reflection"]["enabled"], true);
5970
5971        // Live runtime overlays: keyword-tier daemon with no embedder
5972        // and no reranker → disabled / off.
5973        assert_eq!(val["features"]["recall_mode_active"], "disabled");
5974        assert_eq!(val["features"]["reranker_active"], "off");
5975    }
5976
5977    /// v0.6.3.1 (P1 honesty patch). Default v2 response keeps the legacy
5978    /// top-level keys (`tier`, `version`, `features`, `models`) so old
5979    /// path-readers don't break, even though `memory_reflection` was
5980    /// reshaped into an object.
5981    #[test]
5982    fn mcp_capabilities_v2_backwards_compatible() {
5983        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
5984        let req = make_tools_call("memory_capabilities", json!({}));
5985        let resp = invoke_handle_request(&conn, &req);
5986        let text = resp.result.unwrap()["content"][0]["text"]
5987            .as_str()
5988            .unwrap()
5989            .to_string();
5990        let val: Value = serde_json::from_str(&text).unwrap();
5991
5992        // v1 top-level keys preserved at the same paths
5993        assert!(val["tier"].is_string(), "v1: tier preserved");
5994        assert!(val["version"].is_string(), "v1: version preserved");
5995        assert!(val["features"].is_object(), "v1: features preserved");
5996        assert!(val["models"].is_object(), "v1: models preserved");
5997
5998        // Well-known v1 sub-fields still resolve.
5999        assert!(val["features"]["keyword_search"].is_boolean());
6000        assert!(val["features"]["semantic_search"].is_boolean());
6001        assert!(val["features"]["embedder_loaded"].is_boolean());
6002        assert!(val["models"]["embedding"].is_string());
6003        assert!(val["models"]["llm"].is_string());
6004        assert!(val["models"]["cross_encoder"].is_string());
6005    }
6006
6007    /// P1 honesty patch: explicit `accept = "v1"` returns the legacy
6008    /// shape (no `schema_version`, `memory_reflection` is a bool, no
6009    /// v2-only blocks).
6010    #[test]
6011    fn mcp_capabilities_accept_v1_returns_legacy_shape() {
6012        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6013        let req = make_tools_call("memory_capabilities", json!({"accept": "v1"}));
6014        let resp = invoke_handle_request(&conn, &req);
6015        let text = resp.result.unwrap()["content"][0]["text"]
6016            .as_str()
6017            .unwrap()
6018            .to_string();
6019        let val: Value = serde_json::from_str(&text).unwrap();
6020
6021        // Round-2 F13 — v1 wire shape now carries
6022        // `schema_version: "1"` so clients can negotiate wire-version.
6023        // The struct itself (`CapabilitiesV1`) still doesn't have the
6024        // field; the dispatcher injects it on the wire. This is the
6025        // F13 fix: clients need the discriminator to detect they're
6026        // looking at v1 vs an accidental v2.
6027        assert_eq!(
6028            val.get("schema_version").and_then(Value::as_str),
6029            Some("1"),
6030            "Round-2 F13 — v1 must carry schema_version on the wire"
6031        );
6032        // v2-only blocks are absent
6033        assert!(val.get("permissions").is_none());
6034        assert!(val.get("hooks").is_none());
6035        assert!(val.get("compaction").is_none());
6036        assert!(val.get("approval").is_none());
6037        assert!(val.get("transcripts").is_none());
6038        // v1 features.memory_reflection is a bool (not the v2 object)
6039        assert!(val["features"]["memory_reflection"].is_boolean());
6040        // v1 features carry no recall_mode_active / reranker_active
6041        assert!(val["features"].get("recall_mode_active").is_none());
6042        assert!(val["features"].get("reranker_active").is_none());
6043    }
6044
6045    /// v0.6.3 (capabilities schema v2). `approval.pending_requests`
6046    /// reflects the live `pending_actions` count — the one block that is
6047    /// already wired through to a real subsystem instead of zero-state.
6048    #[test]
6049    fn mcp_capabilities_pending_requests_reflects_db() {
6050        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6051        // Insert a pending action by hand (the queue path is exercised
6052        // elsewhere; here we only need the count to bump).
6053        let payload = serde_json::json!({"foo": "bar"}).to_string();
6054        conn.execute(
6055            "INSERT INTO pending_actions (id, action_type, memory_id, namespace,
6056                payload, requested_by, requested_at, status)
6057             VALUES ('p-1', 'store', NULL, 'global', ?1, 'agent-1',
6058                '2026-04-27T00:00:00Z', 'pending')",
6059            rusqlite::params![payload],
6060        )
6061        .unwrap();
6062
6063        let req = make_tools_call("memory_capabilities", json!({}));
6064        let resp = invoke_handle_request(&conn, &req);
6065        let text = resp.result.unwrap()["content"][0]["text"]
6066            .as_str()
6067            .unwrap()
6068            .to_string();
6069        let val: Value = serde_json::from_str(&text).unwrap();
6070
6071        assert_eq!(
6072            val["approval"]["pending_requests"], 1,
6073            "pending_actions(status=pending) count surfaces live"
6074        );
6075    }
6076
6077    #[test]
6078    fn handle_subscribe_error_unregistered_agent() {
6079        // memory_subscribe refuses unregistered callers (#301 item 4).
6080        // R3-S1.HMAC (2026-05-13): supply secret so the registration
6081        // gate (not the HMAC gate) is what this test pins.
6082        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6083        let req = make_tools_call(
6084            "memory_subscribe",
6085            json!({"url": "https://example.com/hook", "secret": "mcp-sub-test-secret"}),
6086        );
6087        let resp = invoke_handle_request(&conn, &req);
6088        let result = resp.result.unwrap();
6089        assert_eq!(result["isError"], true);
6090        let msg = result["content"][0]["text"].as_str().unwrap();
6091        assert!(msg.contains("not registered"));
6092    }
6093
6094    // ------------------------------------------------------------------
6095    // JSON-RPC framing — drives `dispatch_line` and `handle_request`.
6096    // ------------------------------------------------------------------
6097
6098    #[test]
6099    fn test_jsonrpc_handles_well_formed_request() {
6100        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6101        let line = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
6102        let resp = dispatch_line(&conn, line).expect("expected response");
6103        assert!(resp.error.is_none());
6104        let result = resp.result.unwrap();
6105        assert!(result["tools"].is_array());
6106    }
6107
6108    #[test]
6109    fn test_jsonrpc_handles_malformed_json() {
6110        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6111        // Garbage on a single line.
6112        let resp = dispatch_line(&conn, "this is not json at all").expect("expected response");
6113        let err = resp.error.unwrap();
6114        assert_eq!(err.code, -32700);
6115        assert!(err.message.contains("parse error"));
6116        // Spec: id MUST be Null for parse errors.
6117        assert_eq!(resp.id, Value::Null);
6118    }
6119
6120    #[test]
6121    fn test_jsonrpc_handles_truncated_request() {
6122        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6123        // Incomplete JSON object — serde_json must reject.
6124        let resp = dispatch_line(&conn, r#"{"jsonrpc":"2.0","id":1,"method":"#)
6125            .expect("expected response");
6126        let err = resp.error.unwrap();
6127        assert_eq!(err.code, -32700);
6128    }
6129
6130    #[test]
6131    fn test_jsonrpc_handles_two_requests_per_line() {
6132        // The MCP framing is line-delimited JSON: one request per line.
6133        // If a peer accidentally pastes two JSON objects on one line
6134        // (`{...}{...}`), serde_json::from_str must reject as parse
6135        // error rather than silently process the first.
6136        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6137        let line = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"} {"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
6138        let resp = dispatch_line(&conn, line).expect("expected response");
6139        let err = resp.error.unwrap();
6140        assert_eq!(err.code, -32700);
6141    }
6142
6143    #[test]
6144    fn test_jsonrpc_handles_blank_line() {
6145        // Blank lines are skipped (no response).
6146        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6147        assert!(dispatch_line(&conn, "").is_none());
6148        assert!(dispatch_line(&conn, "   \t  ").is_none());
6149    }
6150
6151    #[test]
6152    fn test_jsonrpc_handles_notification_no_response() {
6153        // Requests without an `id` are JSON-RPC notifications — no
6154        // response should be emitted per spec.
6155        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6156        let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
6157        assert!(dispatch_line(&conn, line).is_none());
6158        // Explicit id:null is also a notification.
6159        let line_null = r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized"}"#;
6160        assert!(dispatch_line(&conn, line_null).is_none());
6161    }
6162
6163    #[test]
6164    fn test_jsonrpc_handles_method_not_found() {
6165        // Unknown JSON-RPC method must return -32601.
6166        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6167        let req = RpcRequest {
6168            jsonrpc: "2.0".into(),
6169            id: Some(json!(7)),
6170            method: "no/such/method".into(),
6171            params: json!({}),
6172        };
6173        let resp = invoke_handle_request(&conn, &req);
6174        let err = resp.error.unwrap();
6175        assert_eq!(err.code, -32601);
6176        assert!(err.message.contains("method not found"));
6177    }
6178
6179    #[test]
6180    fn test_jsonrpc_handles_invalid_params() {
6181        // tools/call with a missing tool name must surface -32602.
6182        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6183        let req = RpcRequest {
6184            jsonrpc: "2.0".into(),
6185            id: Some(json!(8)),
6186            method: "tools/call".into(),
6187            params: json!({"arguments": {}}),
6188        };
6189        let resp = invoke_handle_request(&conn, &req);
6190        let err = resp.error.unwrap();
6191        assert_eq!(err.code, -32602);
6192    }
6193
6194    #[test]
6195    fn test_jsonrpc_handles_unknown_tool_returns_minus_32601() {
6196        // Ultrareview #349: unknown tool = method-not-found, not isError.
6197        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6198        let req = make_tools_call("memory_does_not_exist", json!({}));
6199        let resp = invoke_handle_request(&conn, &req);
6200        let err = resp.error.unwrap();
6201        assert_eq!(err.code, -32601);
6202        assert!(err.message.contains("memory_does_not_exist"));
6203    }
6204
6205    /// #1254 (MED, 2026-05-25) — regression: by default a `tools/call`
6206    /// against a tool that exists in a higher-profile family returns a
6207    /// uniform `"unknown tool: <name>"` error from a lower-profile
6208    /// client. Pre-#1254 the daemon leaked the family name + a
6209    /// "Restart with --profile <name>" hint, which an untrusted
6210    /// lower-profile client could walk to enumerate the higher-profile
6211    /// surface (e.g. probe for admin tool names from a core profile).
6212    ///
6213    /// Operators opt back into the helpful hint via the new
6214    /// `McpConfig.profile_hint_in_errors = true` knob for single-tenant
6215    /// dev posture where every caller sees the full surface anyway.
6216    #[test]
6217    fn issue_1254_tool_name_leak_gated_behind_profile_hint_in_errors() {
6218        use crate::config::McpConfig;
6219        let tier_config = FeatureTier::Keyword.config();
6220        let resolved_ttl = crate::config::ResolvedTtl::default();
6221        let resolved_scoring = crate::config::ResolvedScoring::default();
6222        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6223
6224        // `memory_atomise` lives in `Family::Power` — it is registered
6225        // under `--profile full` and `--profile power`, NOT under the
6226        // default `--profile core`. The pre-#1254 leak was: a
6227        // `--profile core` client could call `memory_atomise` and the
6228        // refusal text named the family + advised `--profile power`
6229        // / `--profile core,power` to load it. That string leaks the
6230        // higher-profile tool name + family membership to a probing
6231        // client.
6232        let core_profile = crate::profile::Profile::core();
6233        assert!(
6234            !core_profile.loads("memory_atomise"),
6235            "test sentinel: memory_atomise must not be loaded under --profile core; \
6236             pick a different higher-profile tool if this changes"
6237        );
6238        let req = make_tools_call("memory_atomise", json!({}));
6239
6240        // ---- Default posture (profile_hint_in_errors absent) ----
6241        // No McpConfig at all — same as a daemon booted without a
6242        // `[mcp]` block.
6243        let resp_default = handle_request(
6244            &conn,
6245            std::path::Path::new(":memory:"),
6246            &req,
6247            None,
6248            None,
6249            None,
6250            &tier_config,
6251            &crate::config::ResolvedModels::from_tier_preset(&tier_config),
6252            None,
6253            &resolved_ttl,
6254            &resolved_scoring,
6255            true,
6256            false,
6257            None,
6258            &core_profile,
6259            None,
6260            None,
6261            None,
6262            None,
6263            None,
6264            None,
6265            None,
6266            None,           // nag_watcher (#1389/#1398 L1)
6267            "test-session", // nag_session_id (#1389/#1398 L1)
6268        );
6269        let err_default = resp_default
6270            .error
6271            .expect("tools/call against a non-loaded tool must error");
6272        assert_eq!(
6273            err_default.code, -32601,
6274            "method-not-found code unchanged across the hint posture"
6275        );
6276        // The default-secure message must be the uniform shape.
6277        assert!(
6278            err_default.message.starts_with("unknown tool: "),
6279            "#1254: default posture must return a uniform 'unknown tool: <name>' \
6280             error regardless of family membership; got: {}",
6281            err_default.message
6282        );
6283        assert!(
6284            err_default.message.contains("memory_atomise"),
6285            "the refused tool name is fine — the leak was the FAMILY name, not the \
6286             tool name (the client supplied that); got: {}",
6287            err_default.message
6288        );
6289        // The pre-#1254 leak fingerprint MUST be absent.
6290        assert!(
6291            !err_default.message.contains("family"),
6292            "#1254: default posture must NOT leak family membership; got: {}",
6293            err_default.message
6294        );
6295        assert!(
6296            !err_default.message.contains("--profile"),
6297            "#1254: default posture must NOT advise which profile would load the tool; got: {}",
6298            err_default.message
6299        );
6300
6301        // ---- Opt-in dev posture (profile_hint_in_errors = true) ----
6302        let cfg_with_hint = McpConfig {
6303            profile_hint_in_errors: true,
6304            ..McpConfig::default()
6305        };
6306        let resp_hint = handle_request(
6307            &conn,
6308            std::path::Path::new(":memory:"),
6309            &req,
6310            None,
6311            None,
6312            None,
6313            &tier_config,
6314            &crate::config::ResolvedModels::from_tier_preset(&tier_config),
6315            None,
6316            &resolved_ttl,
6317            &resolved_scoring,
6318            true,
6319            false,
6320            None,
6321            &core_profile,
6322            Some(&cfg_with_hint),
6323            None,
6324            None,
6325            None,
6326            None,
6327            None,
6328            None,
6329            None,           // nag_watcher (#1389/#1398 L1)
6330            "test-session", // nag_session_id (#1389/#1398 L1)
6331        );
6332        let err_hint = resp_hint
6333            .error
6334            .expect("hint-enabled tools/call must still error on non-loaded tool");
6335        assert_eq!(err_hint.code, -32601);
6336        // Hint-enabled message restores the family-membership advisory
6337        // so the operator-debug posture still works.
6338        assert!(
6339            err_hint.message.contains("family"),
6340            "#1254: profile_hint_in_errors=true must surface the family hint; \
6341             got: {}",
6342            err_hint.message
6343        );
6344        assert!(
6345            err_hint.message.contains("--profile"),
6346            "#1254: profile_hint_in_errors=true must advise the operator on \
6347             how to load the tool; got: {}",
6348            err_hint.message
6349        );
6350    }
6351
6352    #[test]
6353    fn test_jsonrpc_rejects_wrong_version() {
6354        // jsonrpc field must be exactly "2.0" — anything else = -32600.
6355        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6356        let req = RpcRequest {
6357            jsonrpc: "1.0".into(),
6358            id: Some(json!(1)),
6359            method: "tools/list".into(),
6360            params: json!({}),
6361        };
6362        let resp = invoke_handle_request(&conn, &req);
6363        let err = resp.error.unwrap();
6364        assert_eq!(err.code, -32600);
6365    }
6366
6367    #[test]
6368    fn test_jsonrpc_handles_initialize() {
6369        // Initialize handshake returns serverInfo + protocolVersion.
6370        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6371        let req = RpcRequest {
6372            jsonrpc: "2.0".into(),
6373            id: Some(json!(1)),
6374            method: "initialize".into(),
6375            params: json!({"clientInfo": {"name": "test-client"}}),
6376        };
6377        let resp = invoke_handle_request(&conn, &req);
6378        assert!(resp.error.is_none());
6379        let result = resp.result.unwrap();
6380        assert_eq!(result["protocolVersion"], "2024-11-05");
6381        assert_eq!(result["serverInfo"]["name"], "ai-memory");
6382    }
6383
6384    // ------------------------------------------------------------------
6385    // auto_register_path_hierarchy — exercises the bail-out branches.
6386    //
6387    // The function only mutates rows whose `parent_namespace IS NULL`,
6388    // walking from `cwd().parent()` up to the home directory. The
6389    // working directory in `cargo test` is the crate root, which
6390    // typically lives under `home`, so the walk runs but finds no
6391    // matching parent (no namespace_meta row for any ancestor dir
6392    // name). Tests below cover: (1) no-op when an explicit parent is
6393    // already set, (2) no-op when the namespace has no row, (3) safe
6394    // call with an empty-string namespace, (4) idempotency.
6395    // ------------------------------------------------------------------
6396
6397    #[test]
6398    fn test_auto_register_creates_top_level_namespace() {
6399        // With no namespace_meta row at all, the walk finds nothing
6400        // and the table stays empty (silent no-op, never panics).
6401        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6402        super::auto_register_path_hierarchy(&conn, "m9-top");
6403        let count: i64 = conn
6404            .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
6405            .unwrap();
6406        assert_eq!(count, 0);
6407    }
6408
6409    #[test]
6410    fn test_auto_register_creates_nested_hierarchy() {
6411        // Pre-seed a row for "repo/team/sub" with parent NULL. The walk
6412        // looks for any ancestor *directory name* that has a standard;
6413        // since none of the test-runner's cwd ancestors will collide
6414        // with synthetic namespace names, the row's parent stays NULL.
6415        // The contract tested is: function tolerates nested-form inputs
6416        // without panicking and never overwrites a row whose parent is
6417        // already set.
6418        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6419        // Insert a synthetic standard for "m9-parent" so the walk *could*
6420        // match if cwd happened to be inside a "m9-parent" dir; in
6421        // practice it won't, so the row's parent stays NULL.
6422        let mem = Memory {
6423            id: uuid::Uuid::new_v4().to_string(),
6424            tier: Tier::Long,
6425            namespace: "m9-parent".into(),
6426            title: "parent standard".into(),
6427            content: "...".into(),
6428            tags: vec![],
6429            priority: 5,
6430            confidence: 1.0,
6431            source: "test".into(),
6432            access_count: 0,
6433            created_at: chrono::Utc::now().to_rfc3339(),
6434            updated_at: chrono::Utc::now().to_rfc3339(),
6435            last_accessed_at: None,
6436            expires_at: None,
6437            metadata: json!({}),
6438            reflection_depth: 0,
6439            memory_kind: crate::models::MemoryKind::Observation,
6440            entity_id: None,
6441            persona_version: None,
6442            citations: Vec::new(),
6443            source_uri: None,
6444            source_span: None,
6445            confidence_source: crate::models::ConfidenceSource::CallerProvided,
6446            confidence_signals: None,
6447            confidence_decayed_at: None,
6448            version: 1,
6449        };
6450        let std_id = db::insert(&conn, &mem).unwrap();
6451        db::set_namespace_standard(&conn, "m9-parent", &std_id, None).unwrap();
6452        // Seed a child row with parent NULL.
6453        let child_mem = Memory {
6454            id: uuid::Uuid::new_v4().to_string(),
6455            tier: Tier::Long,
6456            namespace: "repo/team/sub".into(),
6457            title: "child".into(),
6458            content: "...".into(),
6459            tags: vec![],
6460            priority: 5,
6461            confidence: 1.0,
6462            source: "test".into(),
6463            access_count: 0,
6464            created_at: chrono::Utc::now().to_rfc3339(),
6465            updated_at: chrono::Utc::now().to_rfc3339(),
6466            last_accessed_at: None,
6467            expires_at: None,
6468            metadata: json!({}),
6469            reflection_depth: 0,
6470            memory_kind: crate::models::MemoryKind::Observation,
6471            entity_id: None,
6472            persona_version: None,
6473            citations: Vec::new(),
6474            source_uri: None,
6475            source_span: None,
6476            confidence_source: crate::models::ConfidenceSource::CallerProvided,
6477            confidence_signals: None,
6478            confidence_decayed_at: None,
6479            version: 1,
6480        };
6481        let child_id = db::insert(&conn, &child_mem).unwrap();
6482        db::set_namespace_standard(&conn, "repo/team/sub", &child_id, None).unwrap();
6483        // Run the walk — must not panic, must not corrupt rows.
6484        super::auto_register_path_hierarchy(&conn, "repo/team/sub");
6485        // The seeded standard is still readable.
6486        let id = db::get_namespace_standard(&conn, "repo/team/sub")
6487            .unwrap()
6488            .unwrap();
6489        assert_eq!(id, child_id);
6490    }
6491
6492    #[test]
6493    fn test_auto_register_idempotent() {
6494        // Calling twice must not corrupt state — even when no match is
6495        // found, the second call observes the same DB.
6496        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6497        super::auto_register_path_hierarchy(&conn, "m9-idem");
6498        super::auto_register_path_hierarchy(&conn, "m9-idem");
6499        let count: i64 = conn
6500            .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
6501            .unwrap();
6502        assert_eq!(count, 0);
6503    }
6504
6505    #[test]
6506    fn test_auto_register_handles_empty_string_or_root() {
6507        // Empty / root-y inputs must not panic. The walk is a pure
6508        // observer when the namespace_meta row is absent.
6509        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6510        super::auto_register_path_hierarchy(&conn, "");
6511        super::auto_register_path_hierarchy(&conn, "/");
6512        super::auto_register_path_hierarchy(&conn, "*");
6513        // Still no rows, no crash.
6514        let count: i64 = conn
6515            .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
6516            .unwrap();
6517        assert_eq!(count, 0);
6518    }
6519
6520    #[test]
6521    fn test_auto_register_skips_when_explicit_parent_set() {
6522        // Early-return path: if `get_namespace_parent` already returns
6523        // Some, the walk is skipped entirely. We verify by calling the
6524        // function and asserting that the explicit parent is preserved.
6525        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6526        // Seed two memories so we can register parent and child.
6527        let parent_mem = Memory {
6528            id: uuid::Uuid::new_v4().to_string(),
6529            tier: Tier::Long,
6530            namespace: "m9-explicit-parent".into(),
6531            title: "p".into(),
6532            content: "c".into(),
6533            tags: vec![],
6534            priority: 5,
6535            confidence: 1.0,
6536            source: "test".into(),
6537            access_count: 0,
6538            created_at: chrono::Utc::now().to_rfc3339(),
6539            updated_at: chrono::Utc::now().to_rfc3339(),
6540            last_accessed_at: None,
6541            expires_at: None,
6542            metadata: json!({}),
6543            reflection_depth: 0,
6544            memory_kind: crate::models::MemoryKind::Observation,
6545            entity_id: None,
6546            persona_version: None,
6547            citations: Vec::new(),
6548            source_uri: None,
6549            source_span: None,
6550            confidence_source: crate::models::ConfidenceSource::CallerProvided,
6551            confidence_signals: None,
6552            confidence_decayed_at: None,
6553            version: 1,
6554        };
6555        let parent_id = db::insert(&conn, &parent_mem).unwrap();
6556        db::set_namespace_standard(&conn, "m9-explicit-parent", &parent_id, None).unwrap();
6557
6558        let child_mem = Memory {
6559            id: uuid::Uuid::new_v4().to_string(),
6560            tier: Tier::Long,
6561            namespace: "m9-explicit-child".into(),
6562            title: "c".into(),
6563            content: "c".into(),
6564            tags: vec![],
6565            priority: 5,
6566            confidence: 1.0,
6567            source: "test".into(),
6568            access_count: 0,
6569            created_at: chrono::Utc::now().to_rfc3339(),
6570            updated_at: chrono::Utc::now().to_rfc3339(),
6571            last_accessed_at: None,
6572            expires_at: None,
6573            metadata: json!({}),
6574            reflection_depth: 0,
6575            memory_kind: crate::models::MemoryKind::Observation,
6576            entity_id: None,
6577            persona_version: None,
6578            citations: Vec::new(),
6579            source_uri: None,
6580            source_span: None,
6581            confidence_source: crate::models::ConfidenceSource::CallerProvided,
6582            confidence_signals: None,
6583            confidence_decayed_at: None,
6584            version: 1,
6585        };
6586        let child_id = db::insert(&conn, &child_mem).unwrap();
6587        db::set_namespace_standard(
6588            &conn,
6589            "m9-explicit-child",
6590            &child_id,
6591            Some("m9-explicit-parent"),
6592        )
6593        .unwrap();
6594
6595        // Pre-condition: parent is set.
6596        assert_eq!(
6597            db::get_namespace_parent(&conn, "m9-explicit-child"),
6598            Some("m9-explicit-parent".to_string())
6599        );
6600        super::auto_register_path_hierarchy(&conn, "m9-explicit-child");
6601        // Post-condition: parent unchanged.
6602        assert_eq!(
6603            db::get_namespace_parent(&conn, "m9-explicit-child"),
6604            Some("m9-explicit-parent".to_string())
6605        );
6606    }
6607
6608    // ------------------------------------------------------------------
6609    // inject_namespace_standard — coverage for the four shape branches.
6610    // ------------------------------------------------------------------
6611
6612    fn make_recall_response(memories: Vec<Value>) -> Value {
6613        let count = memories.len();
6614        json!({
6615            "memories": memories,
6616            "count": count,
6617            "mode": "keyword",
6618        })
6619    }
6620
6621    fn seed_namespace_standard(
6622        conn: &rusqlite::Connection,
6623        namespace: &str,
6624        title: &str,
6625    ) -> String {
6626        let mem = Memory {
6627            id: uuid::Uuid::new_v4().to_string(),
6628            tier: Tier::Long,
6629            namespace: namespace.into(),
6630            title: title.into(),
6631            content: "policy text".into(),
6632            tags: vec!["_standard".into()],
6633            priority: 5,
6634            confidence: 1.0,
6635            source: "test".into(),
6636            access_count: 0,
6637            created_at: chrono::Utc::now().to_rfc3339(),
6638            updated_at: chrono::Utc::now().to_rfc3339(),
6639            last_accessed_at: None,
6640            expires_at: None,
6641            metadata: json!({}),
6642            reflection_depth: 0,
6643            memory_kind: crate::models::MemoryKind::Observation,
6644            entity_id: None,
6645            persona_version: None,
6646            citations: Vec::new(),
6647            source_uri: None,
6648            source_span: None,
6649            confidence_source: crate::models::ConfidenceSource::CallerProvided,
6650            confidence_signals: None,
6651            confidence_decayed_at: None,
6652            version: 1,
6653        };
6654        let id = db::insert(conn, &mem).unwrap();
6655        db::set_namespace_standard(conn, namespace, &id, None).unwrap();
6656        id
6657    }
6658
6659    #[test]
6660    fn test_inject_namespace_standard_attaches_when_present() {
6661        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6662        let std_id = seed_namespace_standard(&conn, "m9-inject-attach", "S");
6663        let mut resp = make_recall_response(vec![]);
6664        super::inject_namespace_standard(&conn, Some("m9-inject-attach"), &mut resp);
6665        assert!(resp["standard"].is_object(), "expected attached standard");
6666        assert_eq!(resp["standard"]["id"].as_str().unwrap(), std_id);
6667    }
6668
6669    #[test]
6670    fn test_inject_namespace_standard_skips_when_absent() {
6671        // No standard set anywhere → response is unchanged (no
6672        // `standard` / `standards` field added).
6673        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6674        let mut resp = make_recall_response(vec![]);
6675        let before = resp.clone();
6676        super::inject_namespace_standard(&conn, Some("m9-inject-empty"), &mut resp);
6677        assert_eq!(resp, before);
6678        assert!(resp.get("standard").is_none());
6679        assert!(resp.get("standards").is_none());
6680    }
6681
6682    #[test]
6683    fn test_inject_namespace_standard_top_of_recall_response() {
6684        // The standard's own memory must be filtered OUT of the
6685        // `memories` array so the client doesn't see the policy
6686        // duplicated as a result + as the `standard` field.
6687        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6688        let std_id = seed_namespace_standard(&conn, "m9-inject-dedup", "S");
6689        // Pretend recall returned the standard as one of its hits.
6690        let dup = json!({"id": std_id, "title": "S", "content": "policy text"});
6691        let other = json!({"id": "other-id", "title": "noise", "content": "x"});
6692        let mut resp = make_recall_response(vec![dup.clone(), other.clone()]);
6693        super::inject_namespace_standard(&conn, Some("m9-inject-dedup"), &mut resp);
6694        assert_eq!(resp["standard"]["id"].as_str().unwrap(), std_id);
6695        let memories = resp["memories"].as_array().unwrap();
6696        assert_eq!(memories.len(), 1);
6697        assert_eq!(memories[0]["id"], "other-id");
6698        assert_eq!(resp["count"], 1);
6699    }
6700
6701    #[test]
6702    fn test_inject_namespace_standard_preserves_other_response_fields() {
6703        // Mode / count / unrelated fields must survive injection.
6704        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6705        seed_namespace_standard(&conn, "m9-inject-preserve", "S");
6706        let mut resp = json!({
6707            "memories": [],
6708            "count": 0,
6709            "mode": "hybrid",
6710            "diagnostics": {"latency_ms": 42},
6711        });
6712        super::inject_namespace_standard(&conn, Some("m9-inject-preserve"), &mut resp);
6713        assert_eq!(resp["mode"], "hybrid");
6714        assert_eq!(resp["diagnostics"]["latency_ms"], 42);
6715        assert!(resp["standard"].is_object());
6716    }
6717
6718    #[test]
6719    fn test_inject_namespace_standard_no_namespace_uses_global() {
6720        // When `namespace` is None, only the global "*" standard is
6721        // consulted. We seed "*" and assert it's attached.
6722        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6723        seed_namespace_standard(&conn, "*", "global standard");
6724        let mut resp = make_recall_response(vec![]);
6725        super::inject_namespace_standard(&conn, None, &mut resp);
6726        assert_eq!(resp["standard"]["title"], "global standard");
6727    }
6728
6729    #[test]
6730    fn test_inject_namespace_standard_multiple_levels_emits_array() {
6731        // When more than one standard applies (global + namespace),
6732        // the response gets a `standards` array, not a single
6733        // `standard` object.
6734        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6735        seed_namespace_standard(&conn, "*", "GLOBAL");
6736        seed_namespace_standard(&conn, "m9-multi", "LOCAL");
6737        let mut resp = make_recall_response(vec![]);
6738        super::inject_namespace_standard(&conn, Some("m9-multi"), &mut resp);
6739        assert!(resp["standards"].is_array());
6740        let arr = resp["standards"].as_array().unwrap();
6741        assert_eq!(arr.len(), 2);
6742        // Order: global ("*") first, then namespace-specific.
6743        assert_eq!(arr[0]["title"], "GLOBAL");
6744        assert_eq!(arr[1]["title"], "LOCAL");
6745        assert!(resp.get("standard").is_none());
6746    }
6747
6748    // =====================================================================
6749    // W12 / Closer W12-A — mcp.rs deeper sweep
6750    //
6751    // M9 covered the first 40 tests. W12-A targets the residual ~750
6752    // uncovered lines with focus on:
6753    //   1) Less-common tool handlers (archive_*, kg_*, agent_*, notify,
6754    //      inbox, namespace_*, pending_*, gc, session_start)
6755    //   2) Per-handler error branches not hit by the smoke matrix's "drop
6756    //      one required arg" pass — invalid argument shape, validation
6757    //      failures, "not found" lookups
6758    //   3) JSON-RPC framing edge cases beyond M9's six (nested method
6759    //      strings, unicode, empty params, prompts/list, prompts/get
6760    //      errors, ping)
6761    //   4) Helper-fn coverage holes — `inject_namespace_standard` shape
6762    //      branches, `auto_register_path_hierarchy` walk variants
6763    //
6764    // All tests use the test-only `invoke_handle_request` helper from
6765    // M9 to avoid repeating the 13-arg call site.
6766    // =====================================================================
6767
6768    // ------------------------------------------------------------------
6769    // Less-common tool handlers — happy paths
6770    // ------------------------------------------------------------------
6771
6772    #[test]
6773    fn handle_archive_list_returns_empty_when_no_archived() {
6774        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6775        let req = make_tools_call("memory_archive_list", json!({}));
6776        let resp = invoke_handle_request(&conn, &req);
6777        assert!(resp.error.is_none());
6778        let text = resp.result.unwrap()["content"][0]["text"]
6779            .as_str()
6780            .unwrap()
6781            .to_string();
6782        let val: Value = serde_json::from_str(&text).unwrap();
6783        assert_eq!(val["count"], 0);
6784        assert!(val["archived"].is_array());
6785    }
6786
6787    #[test]
6788    fn handle_archive_list_with_namespace_filter() {
6789        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6790        let req = make_tools_call(
6791            "memory_archive_list",
6792            json!({"namespace": "w12-archive", "limit": 5, "offset": 0}),
6793        );
6794        let resp = invoke_handle_request(&conn, &req);
6795        assert!(resp.error.is_none());
6796    }
6797
6798    #[test]
6799    fn handle_archive_restore_unknown_id_returns_error() {
6800        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6801        let req = make_tools_call(
6802            "memory_archive_restore",
6803            json!({"id": "00000000-0000-0000-0000-000000000000"}),
6804        );
6805        let resp = invoke_handle_request(&conn, &req);
6806        let result = resp.result.unwrap();
6807        assert_eq!(result["isError"], true);
6808        let msg = result["content"][0]["text"].as_str().unwrap();
6809        assert!(msg.contains("archive") || msg.contains("not found"));
6810    }
6811
6812    #[test]
6813    fn handle_archive_purge_with_older_than_zero() {
6814        // older_than_days=0 → purges all entries; on an empty DB this is
6815        // a no-op that still hits the success branch.
6816        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6817        let req = make_tools_call("memory_archive_purge", json!({"older_than_days": 0}));
6818        let resp = invoke_handle_request(&conn, &req);
6819        assert!(resp.error.is_none());
6820        let text = resp.result.unwrap()["content"][0]["text"]
6821            .as_str()
6822            .unwrap()
6823            .to_string();
6824        let val: Value = serde_json::from_str(&text).unwrap();
6825        assert!(val["purged"].is_u64() || val["purged"].is_i64());
6826    }
6827
6828    #[test]
6829    fn handle_archive_stats_returns_struct() {
6830        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6831        let req = make_tools_call("memory_archive_stats", json!({}));
6832        let resp = invoke_handle_request(&conn, &req);
6833        assert!(resp.error.is_none());
6834        let text = resp.result.unwrap()["content"][0]["text"]
6835            .as_str()
6836            .unwrap()
6837            .to_string();
6838        let val: Value = serde_json::from_str(&text).unwrap();
6839        // Stats fields vary; just confirm the response is an object/value.
6840        assert!(val.is_object() || val.is_number() || val.is_array());
6841    }
6842
6843    #[test]
6844    fn handle_kg_timeline_unknown_source_returns_empty_events() {
6845        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6846        let req = make_tools_call(
6847            "memory_kg_timeline",
6848            json!({"source_id": "00000000-0000-0000-0000-000000000000"}),
6849        );
6850        let resp = invoke_handle_request(&conn, &req);
6851        assert!(resp.error.is_none());
6852        let text = resp.result.unwrap()["content"][0]["text"]
6853            .as_str()
6854            .unwrap()
6855            .to_string();
6856        let val: Value = serde_json::from_str(&text).unwrap();
6857        assert!(val["events"].is_array());
6858        assert_eq!(val["count"], 0);
6859    }
6860
6861    #[test]
6862    fn handle_kg_timeline_with_since_until_filters() {
6863        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6864        let req = make_tools_call(
6865            "memory_kg_timeline",
6866            json!({
6867                "source_id": "00000000-0000-0000-0000-000000000000",
6868                "since": "2024-01-01T00:00:00Z",
6869                "until": "2025-01-01T00:00:00Z",
6870                "limit": 50,
6871            }),
6872        );
6873        let resp = invoke_handle_request(&conn, &req);
6874        assert!(resp.error.is_none());
6875    }
6876
6877    #[test]
6878    fn handle_kg_timeline_invalid_since_returns_error() {
6879        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6880        let req = make_tools_call(
6881            "memory_kg_timeline",
6882            json!({
6883                "source_id": "00000000-0000-0000-0000-000000000000",
6884                "since": "this-is-not-a-timestamp",
6885            }),
6886        );
6887        let resp = invoke_handle_request(&conn, &req);
6888        let result = resp.result.unwrap();
6889        assert_eq!(result["isError"], true);
6890    }
6891
6892    #[test]
6893    fn handle_kg_invalidate_no_match_returns_found_false() {
6894        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6895        let req = make_tools_call(
6896            "memory_kg_invalidate",
6897            json!({
6898                "source_id": "00000000-0000-0000-0000-000000000000",
6899                "target_id": "11111111-1111-1111-1111-111111111111",
6900                "relation": "related_to",
6901            }),
6902        );
6903        let resp = invoke_handle_request(&conn, &req);
6904        assert!(resp.error.is_none());
6905        let text = resp.result.unwrap()["content"][0]["text"]
6906            .as_str()
6907            .unwrap()
6908            .to_string();
6909        let val: Value = serde_json::from_str(&text).unwrap();
6910        assert_eq!(val["found"], false);
6911    }
6912
6913    #[test]
6914    fn handle_kg_invalidate_with_explicit_valid_until() {
6915        // Seed source + target memories and a link, then invalidate with
6916        // an explicit timestamp — drives the Some(ts) validation branch
6917        // and the Some(res) match arm.
6918        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6919        let src = Memory {
6920            id: uuid::Uuid::new_v4().to_string(),
6921            tier: Tier::Long,
6922            namespace: "w12-kg".into(),
6923            title: "src".into(),
6924            content: "c".into(),
6925            tags: vec![],
6926            priority: 5,
6927            confidence: 1.0,
6928            source: "test".into(),
6929            access_count: 0,
6930            created_at: chrono::Utc::now().to_rfc3339(),
6931            updated_at: chrono::Utc::now().to_rfc3339(),
6932            last_accessed_at: None,
6933            expires_at: None,
6934            metadata: json!({}),
6935            reflection_depth: 0,
6936            memory_kind: crate::models::MemoryKind::Observation,
6937            entity_id: None,
6938            persona_version: None,
6939            citations: Vec::new(),
6940            source_uri: None,
6941            source_span: None,
6942            confidence_source: crate::models::ConfidenceSource::CallerProvided,
6943            confidence_signals: None,
6944            confidence_decayed_at: None,
6945            version: 1,
6946        };
6947        let mut tgt = src.clone();
6948        tgt.id = uuid::Uuid::new_v4().to_string();
6949        tgt.title = "tgt".into();
6950        let src_id = db::insert(&conn, &src).unwrap();
6951        let tgt_id = db::insert(&conn, &tgt).unwrap();
6952        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
6953
6954        let req = make_tools_call(
6955            "memory_kg_invalidate",
6956            json!({
6957                "source_id": src_id,
6958                "target_id": tgt_id,
6959                "relation": "related_to",
6960                "valid_until": "2025-01-01T00:00:00Z",
6961            }),
6962        );
6963        let resp = invoke_handle_request(&conn, &req);
6964        assert!(resp.error.is_none());
6965        let text = resp.result.unwrap()["content"][0]["text"]
6966            .as_str()
6967            .unwrap()
6968            .to_string();
6969        let val: Value = serde_json::from_str(&text).unwrap();
6970        assert_eq!(val["found"], true);
6971        assert_eq!(val["valid_until"], "2025-01-01T00:00:00Z");
6972    }
6973
6974    #[test]
6975    fn handle_kg_invalidate_invalid_valid_until_format() {
6976        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6977        let req = make_tools_call(
6978            "memory_kg_invalidate",
6979            json!({
6980                "source_id": "00000000-0000-0000-0000-000000000000",
6981                "target_id": "11111111-1111-1111-1111-111111111111",
6982                "relation": "related_to",
6983                "valid_until": "not-a-date",
6984            }),
6985        );
6986        let resp = invoke_handle_request(&conn, &req);
6987        let result = resp.result.unwrap();
6988        assert_eq!(result["isError"], true);
6989    }
6990
6991    #[test]
6992    fn handle_kg_query_with_max_depth_and_filters() {
6993        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6994        let req = make_tools_call(
6995            "memory_kg_query",
6996            json!({
6997                "source_id": "00000000-0000-0000-0000-000000000000",
6998                "max_depth": 2,
6999                "valid_at": "2025-01-01T00:00:00Z",
7000                "allowed_agents": ["agent-a", "agent-b"],
7001                "limit": 10,
7002            }),
7003        );
7004        let resp = invoke_handle_request(&conn, &req);
7005        assert!(resp.error.is_none());
7006        let text = resp.result.unwrap()["content"][0]["text"]
7007            .as_str()
7008            .unwrap()
7009            .to_string();
7010        let val: Value = serde_json::from_str(&text).unwrap();
7011        assert_eq!(val["max_depth"], 2);
7012        assert!(val["memories"].is_array());
7013    }
7014
7015    #[test]
7016    fn handle_kg_query_invalid_valid_at() {
7017        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7018        let req = make_tools_call(
7019            "memory_kg_query",
7020            json!({
7021                "source_id": "00000000-0000-0000-0000-000000000000",
7022                "valid_at": "garbage",
7023            }),
7024        );
7025        let resp = invoke_handle_request(&conn, &req);
7026        let result = resp.result.unwrap();
7027        assert_eq!(result["isError"], true);
7028    }
7029
7030    #[test]
7031    fn handle_kg_query_rejects_invalid_agent_id() {
7032        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7033        let req = make_tools_call(
7034            "memory_kg_query",
7035            json!({
7036                "source_id": "00000000-0000-0000-0000-000000000000",
7037                "allowed_agents": ["bad agent with spaces!!"],
7038            }),
7039        );
7040        let resp = invoke_handle_request(&conn, &req);
7041        let result = resp.result.unwrap();
7042        assert_eq!(result["isError"], true);
7043    }
7044
7045    #[test]
7046    fn handle_session_start_happy_returns_memories() {
7047        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7048        // Seed a memory so list returns at least one row.
7049        let mem = Memory {
7050            id: uuid::Uuid::new_v4().to_string(),
7051            tier: Tier::Long,
7052            namespace: "w12-session".into(),
7053            title: "seed".into(),
7054            content: "c".into(),
7055            tags: vec![],
7056            priority: 5,
7057            confidence: 1.0,
7058            source: "test".into(),
7059            access_count: 0,
7060            created_at: chrono::Utc::now().to_rfc3339(),
7061            updated_at: chrono::Utc::now().to_rfc3339(),
7062            last_accessed_at: None,
7063            expires_at: None,
7064            metadata: json!({}),
7065            reflection_depth: 0,
7066            memory_kind: crate::models::MemoryKind::Observation,
7067            entity_id: None,
7068            persona_version: None,
7069            citations: Vec::new(),
7070            source_uri: None,
7071            source_span: None,
7072            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7073            confidence_signals: None,
7074            confidence_decayed_at: None,
7075            version: 1,
7076        };
7077        db::insert(&conn, &mem).unwrap();
7078        let req = make_tools_call(
7079            "memory_session_start",
7080            json!({"namespace": "w12-session", "limit": 5, "format": "json"}),
7081        );
7082        let resp = invoke_handle_request(&conn, &req);
7083        assert!(resp.error.is_none());
7084        let text = resp.result.unwrap()["content"][0]["text"]
7085            .as_str()
7086            .unwrap()
7087            .to_string();
7088        let val: Value = serde_json::from_str(&text).unwrap();
7089        assert_eq!(val["mode"], "session_start");
7090        assert!(val["memories"].is_array());
7091    }
7092
7093    #[test]
7094    fn handle_session_start_empty_namespace_returns_zero() {
7095        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7096        let req = make_tools_call(
7097            "memory_session_start",
7098            json!({"namespace": "w12-empty-ns", "format": "json"}),
7099        );
7100        let resp = invoke_handle_request(&conn, &req);
7101        assert!(resp.error.is_none());
7102        let text = resp.result.unwrap()["content"][0]["text"]
7103            .as_str()
7104            .unwrap()
7105            .to_string();
7106        let val: Value = serde_json::from_str(&text).unwrap();
7107        assert_eq!(val["count"], 0);
7108    }
7109
7110    /// B4 (R2-LOW) — `handle_session_start` MUST call
7111    /// `validate::validate_namespace` so a space-containing
7112    /// `namespace` argument is rejected at the MCP entry point
7113    /// before reaching the storage layer.
7114    ///
7115    /// The handler-level error envelope is the MCP-spec text shape:
7116    /// `result.isError = true` + `content[0].text` carries the
7117    /// validator's message (per the B4 doc comment on the dispatch
7118    /// `Err` arm in this module).
7119    #[test]
7120    fn handle_session_start_rejects_invalid_namespace() {
7121        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7122        let req = make_tools_call(
7123            "memory_session_start",
7124            // Space is unconditionally rejected by `validate_namespace`.
7125            json!({"namespace": "foo bar", "format": "json"}),
7126        );
7127        let resp = invoke_handle_request(&conn, &req);
7128        // No protocol-level error (handler validates → ok_response
7129        // with isError=true).
7130        assert!(resp.error.is_none(), "must not surface as RPC error");
7131        let result = resp.result.expect("ok_response present");
7132        assert_eq!(
7133            result.get("isError").and_then(|v| v.as_bool()),
7134            Some(true),
7135            "invalid namespace must return isError=true, got {result}"
7136        );
7137        let text = result["content"][0]["text"]
7138            .as_str()
7139            .unwrap_or_default()
7140            .to_string();
7141        assert!(
7142            text.to_lowercase().contains("namespace"),
7143            "error message should mention namespace, got: {text}"
7144        );
7145    }
7146
7147    #[test]
7148    fn handle_inbox_returns_empty_for_unregistered_caller() {
7149        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7150        let req = make_tools_call("memory_inbox", json!({"agent_id": "test-bot"}));
7151        let resp = invoke_handle_request(&conn, &req);
7152        assert!(resp.error.is_none());
7153        let text = resp.result.unwrap()["content"][0]["text"]
7154            .as_str()
7155            .unwrap()
7156            .to_string();
7157        let val: Value = serde_json::from_str(&text).unwrap();
7158        assert_eq!(val["agent_id"], "test-bot");
7159        assert!(val["namespace"].as_str().unwrap().starts_with("_messages/"));
7160        assert_eq!(val["count"], 0);
7161    }
7162
7163    #[test]
7164    fn handle_inbox_with_unread_only_filter() {
7165        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7166        let req = make_tools_call(
7167            "memory_inbox",
7168            json!({"agent_id": "test-bot", "unread_only": true, "limit": 10}),
7169        );
7170        let resp = invoke_handle_request(&conn, &req);
7171        assert!(resp.error.is_none());
7172        let text = resp.result.unwrap()["content"][0]["text"]
7173            .as_str()
7174            .unwrap()
7175            .to_string();
7176        let val: Value = serde_json::from_str(&text).unwrap();
7177        assert_eq!(val["unread_only"], true);
7178    }
7179
7180    #[test]
7181    fn handle_notify_happy_returns_message_id() {
7182        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7183        let req = make_tools_call(
7184            "memory_notify",
7185            json!({
7186                "target_agent_id": "alice",
7187                "title": "hello",
7188                "payload": "world",
7189                "tier": Tier::Mid.as_str(),
7190                "priority": 5,
7191            }),
7192        );
7193        let resp = invoke_handle_request(&conn, &req);
7194        assert!(resp.error.is_none());
7195        let text = resp.result.unwrap()["content"][0]["text"]
7196            .as_str()
7197            .unwrap()
7198            .to_string();
7199        let val: Value = serde_json::from_str(&text).unwrap();
7200        assert!(val["id"].is_string());
7201        assert_eq!(val["to"], "alice");
7202        assert_eq!(val["namespace"], "_messages/alice");
7203    }
7204
7205    #[test]
7206    fn handle_notify_invalid_tier_returns_error() {
7207        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7208        let req = make_tools_call(
7209            "memory_notify",
7210            json!({
7211                "target_agent_id": "bob",
7212                "title": "hi",
7213                "payload": "p",
7214                "tier": "bogus-tier",
7215            }),
7216        );
7217        let resp = invoke_handle_request(&conn, &req);
7218        let result = resp.result.unwrap();
7219        assert_eq!(result["isError"], true);
7220        let msg = result["content"][0]["text"].as_str().unwrap();
7221        assert!(msg.contains("invalid tier"));
7222    }
7223
7224    #[test]
7225    fn handle_agent_register_then_list() {
7226        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7227        // Register — `agent_type` must match the closed set or `ai:<name>`.
7228        let req = make_tools_call(
7229            "memory_agent_register",
7230            json!({
7231                "agent_id": "w12-bot",
7232                "agent_type": "ai:w12-bot",
7233                "capabilities": ["read", "write"],
7234            }),
7235        );
7236        let resp = invoke_handle_request(&conn, &req);
7237        assert!(resp.error.is_none());
7238        let text = resp.result.unwrap()["content"][0]["text"]
7239            .as_str()
7240            .unwrap()
7241            .to_string();
7242        let val: Value = serde_json::from_str(&text).unwrap();
7243        assert_eq!(val["registered"], true);
7244        // List
7245        let req2 = make_tools_call("memory_agent_list", json!({}));
7246        let resp2 = invoke_handle_request(&conn, &req2);
7247        assert!(resp2.error.is_none());
7248        let text2 = resp2.result.unwrap()["content"][0]["text"]
7249            .as_str()
7250            .unwrap()
7251            .to_string();
7252        let val2: Value = serde_json::from_str(&text2).unwrap();
7253        assert!(val2["count"].as_u64().unwrap() >= 1);
7254    }
7255
7256    #[test]
7257    fn handle_agent_register_invalid_type_rejects() {
7258        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7259        let req = make_tools_call(
7260            "memory_agent_register",
7261            json!({"agent_id": "w12-bot2", "agent_type": "  not-allowed-type with spaces  "}),
7262        );
7263        let resp = invoke_handle_request(&conn, &req);
7264        let result = resp.result.unwrap();
7265        assert_eq!(result["isError"], true);
7266    }
7267
7268    #[test]
7269    fn handle_namespace_set_get_clear_round_trip() {
7270        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7271        // Seed a memory we can use as the standard
7272        let mem = Memory {
7273            id: uuid::Uuid::new_v4().to_string(),
7274            tier: Tier::Long,
7275            namespace: "w12-ns".into(),
7276            title: "policy".into(),
7277            content: "be excellent".into(),
7278            tags: vec![],
7279            priority: 5,
7280            confidence: 1.0,
7281            source: "test".into(),
7282            access_count: 0,
7283            created_at: chrono::Utc::now().to_rfc3339(),
7284            updated_at: chrono::Utc::now().to_rfc3339(),
7285            last_accessed_at: None,
7286            expires_at: None,
7287            metadata: json!({}),
7288            reflection_depth: 0,
7289            memory_kind: crate::models::MemoryKind::Observation,
7290            entity_id: None,
7291            persona_version: None,
7292            citations: Vec::new(),
7293            source_uri: None,
7294            source_span: None,
7295            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7296            confidence_signals: None,
7297            confidence_decayed_at: None,
7298            version: 1,
7299        };
7300        let std_id = db::insert(&conn, &mem).unwrap();
7301
7302        // Set
7303        let set_req = make_tools_call(
7304            "memory_namespace_set_standard",
7305            json!({"namespace": "w12-ns", "id": std_id.clone()}),
7306        );
7307        let set_resp = invoke_handle_request(&conn, &set_req);
7308        assert!(set_resp.error.is_none());
7309        let set_text = set_resp.result.unwrap()["content"][0]["text"]
7310            .as_str()
7311            .unwrap()
7312            .to_string();
7313        let set_val: Value = serde_json::from_str(&set_text).unwrap();
7314        assert_eq!(set_val["set"], true);
7315
7316        // Get
7317        let get_req = make_tools_call(
7318            "memory_namespace_get_standard",
7319            json!({"namespace": "w12-ns"}),
7320        );
7321        let get_resp = invoke_handle_request(&conn, &get_req);
7322        assert!(get_resp.error.is_none());
7323        let get_text = get_resp.result.unwrap()["content"][0]["text"]
7324            .as_str()
7325            .unwrap()
7326            .to_string();
7327        let get_val: Value = serde_json::from_str(&get_text).unwrap();
7328        assert_eq!(get_val["standard_id"], std_id);
7329
7330        // Clear
7331        let clr_req = make_tools_call(
7332            "memory_namespace_clear_standard",
7333            json!({"namespace": "w12-ns"}),
7334        );
7335        let clr_resp = invoke_handle_request(&conn, &clr_req);
7336        assert!(clr_resp.error.is_none());
7337        let clr_text = clr_resp.result.unwrap()["content"][0]["text"]
7338            .as_str()
7339            .unwrap()
7340            .to_string();
7341        let clr_val: Value = serde_json::from_str(&clr_text).unwrap();
7342        assert_eq!(clr_val["cleared"], true);
7343    }
7344
7345    #[test]
7346    fn handle_namespace_get_standard_missing_returns_null() {
7347        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7348        let req = make_tools_call(
7349            "memory_namespace_get_standard",
7350            json!({"namespace": "w12-no-standard-here"}),
7351        );
7352        let resp = invoke_handle_request(&conn, &req);
7353        assert!(resp.error.is_none());
7354        let text = resp.result.unwrap()["content"][0]["text"]
7355            .as_str()
7356            .unwrap()
7357            .to_string();
7358        let val: Value = serde_json::from_str(&text).unwrap();
7359        assert!(val["standard_id"].is_null());
7360    }
7361
7362    #[test]
7363    fn handle_namespace_get_standard_inherit_returns_chain() {
7364        // Seed two standards: one global "*" and one for "w12-inh", and
7365        // request --inherit so the resolved chain branch fires.
7366        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7367        seed_namespace_standard(&conn, "*", "global rule");
7368        seed_namespace_standard(&conn, "w12-inh", "specific rule");
7369        let req = make_tools_call(
7370            "memory_namespace_get_standard",
7371            json!({"namespace": "w12-inh", "inherit": true}),
7372        );
7373        let resp = invoke_handle_request(&conn, &req);
7374        assert!(resp.error.is_none());
7375        let text = resp.result.unwrap()["content"][0]["text"]
7376            .as_str()
7377            .unwrap()
7378            .to_string();
7379        let val: Value = serde_json::from_str(&text).unwrap();
7380        assert!(val["chain"].is_array());
7381        assert!(val["standards"].is_array());
7382        assert!(val["count"].as_u64().unwrap() >= 1);
7383    }
7384
7385    #[test]
7386    fn handle_namespace_set_standard_with_invalid_governance_rejected() {
7387        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7388        let mem = Memory {
7389            id: uuid::Uuid::new_v4().to_string(),
7390            tier: Tier::Long,
7391            namespace: "w12-gov".into(),
7392            title: "p".into(),
7393            content: "c".into(),
7394            tags: vec![],
7395            priority: 5,
7396            confidence: 1.0,
7397            source: "test".into(),
7398            access_count: 0,
7399            created_at: chrono::Utc::now().to_rfc3339(),
7400            updated_at: chrono::Utc::now().to_rfc3339(),
7401            last_accessed_at: None,
7402            expires_at: None,
7403            metadata: json!({}),
7404            reflection_depth: 0,
7405            memory_kind: crate::models::MemoryKind::Observation,
7406            entity_id: None,
7407            persona_version: None,
7408            citations: Vec::new(),
7409            source_uri: None,
7410            source_span: None,
7411            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7412            confidence_signals: None,
7413            confidence_decayed_at: None,
7414            version: 1,
7415        };
7416        let id = db::insert(&conn, &mem).unwrap();
7417        let req = make_tools_call(
7418            "memory_namespace_set_standard",
7419            json!({
7420                "namespace": "w12-gov",
7421                "id": id,
7422                "governance": {"this": "is not a valid policy"},
7423            }),
7424        );
7425        let resp = invoke_handle_request(&conn, &req);
7426        let result = resp.result.unwrap();
7427        assert_eq!(result["isError"], true);
7428        let msg = result["content"][0]["text"].as_str().unwrap();
7429        assert!(msg.contains("invalid governance") || msg.contains("governance"));
7430    }
7431
7432    #[test]
7433    fn handle_namespace_set_standard_invalid_namespace_rejected() {
7434        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7435        let req = make_tools_call(
7436            "memory_namespace_set_standard",
7437            json!({"namespace": "bad ns with spaces!!", "id": "any"}),
7438        );
7439        let resp = invoke_handle_request(&conn, &req);
7440        let result = resp.result.unwrap();
7441        assert_eq!(result["isError"], true);
7442    }
7443
7444    #[test]
7445    fn handle_pending_list_happy_returns_array() {
7446        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7447        let req = make_tools_call(
7448            "memory_pending_list",
7449            json!({"status": "pending", "limit": 100}),
7450        );
7451        let resp = invoke_handle_request(&conn, &req);
7452        assert!(resp.error.is_none());
7453        let text = resp.result.unwrap()["content"][0]["text"]
7454            .as_str()
7455            .unwrap()
7456            .to_string();
7457        let val: Value = serde_json::from_str(&text).unwrap();
7458        assert!(val["pending"].is_array());
7459        assert!(val["count"].is_u64());
7460    }
7461
7462    #[test]
7463    fn handle_pending_approve_unknown_id_returns_error() {
7464        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7465        let req = make_tools_call(
7466            "memory_pending_approve",
7467            json!({"id": "00000000-0000-0000-0000-000000000000", "agent_id": "human:approver"}),
7468        );
7469        let resp = invoke_handle_request(&conn, &req);
7470        let result = resp.result.unwrap();
7471        // Either isError true or a not-found / rejected response — both
7472        // exercise the unknown-id code path in approve_with_approver_type.
7473        assert!(result.is_object());
7474    }
7475
7476    #[test]
7477    fn handle_pending_reject_unknown_id_returns_not_found() {
7478        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7479        let req = make_tools_call(
7480            "memory_pending_reject",
7481            json!({"id": "00000000-0000-0000-0000-000000000000", "agent_id": "human:rejector"}),
7482        );
7483        let resp = invoke_handle_request(&conn, &req);
7484        let result = resp.result.unwrap();
7485        assert_eq!(result["isError"], true);
7486        let msg = result["content"][0]["text"].as_str().unwrap();
7487        assert!(msg.contains("not found") || msg.contains("already decided"));
7488    }
7489
7490    #[test]
7491    fn handle_gc_dry_run_returns_count_without_deleting() {
7492        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7493        let req = make_tools_call("memory_gc", json!({"dry_run": true}));
7494        let resp = invoke_handle_request(&conn, &req);
7495        assert!(resp.error.is_none());
7496        let text = resp.result.unwrap()["content"][0]["text"]
7497            .as_str()
7498            .unwrap()
7499            .to_string();
7500        let val: Value = serde_json::from_str(&text).unwrap();
7501        assert_eq!(val["dry_run"], true);
7502        assert!(val["collected"].is_u64() || val["collected"].is_i64());
7503    }
7504
7505    #[test]
7506    fn handle_gc_actual_run_returns_zero_on_empty_db() {
7507        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7508        let req = make_tools_call("memory_gc", json!({}));
7509        let resp = invoke_handle_request(&conn, &req);
7510        assert!(resp.error.is_none());
7511        let text = resp.result.unwrap()["content"][0]["text"]
7512            .as_str()
7513            .unwrap()
7514            .to_string();
7515        let val: Value = serde_json::from_str(&text).unwrap();
7516        assert_eq!(val["dry_run"], false);
7517    }
7518
7519    #[test]
7520    fn handle_forget_dry_run_with_filters() {
7521        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7522        let req = make_tools_call(
7523            "memory_forget",
7524            json!({"namespace": "w12-forget", "tier": Tier::Short.as_str(), "dry_run": true}),
7525        );
7526        let resp = invoke_handle_request(&conn, &req);
7527        assert!(resp.error.is_none());
7528        let text = resp.result.unwrap()["content"][0]["text"]
7529            .as_str()
7530            .unwrap()
7531            .to_string();
7532        let val: Value = serde_json::from_str(&text).unwrap();
7533        assert_eq!(val["dry_run"], true);
7534    }
7535
7536    #[test]
7537    fn handle_forget_actual_with_namespace() {
7538        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7539        let req = make_tools_call(
7540            "memory_forget",
7541            json!({"namespace": "w12-forget-actual", "dry_run": false}),
7542        );
7543        let resp = invoke_handle_request(&conn, &req);
7544        assert!(resp.error.is_none());
7545    }
7546
7547    #[test]
7548    fn handle_unsubscribe_unknown_returns_false() {
7549        // db::subscriptions::delete returns a bool — false when no row
7550        // matched. The handler propagates that verbatim.
7551        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7552        let req = make_tools_call(
7553            "memory_unsubscribe",
7554            json!({"id": "00000000-0000-0000-0000-000000000000"}),
7555        );
7556        let resp = invoke_handle_request(&conn, &req);
7557        assert!(resp.error.is_none());
7558        let text = resp.result.unwrap()["content"][0]["text"]
7559            .as_str()
7560            .unwrap()
7561            .to_string();
7562        let val: Value = serde_json::from_str(&text).unwrap();
7563        // Either a bool false or numeric 0 — the contract is "no row removed".
7564        assert!(
7565            val["removed"] == json!(false) || val["removed"] == json!(0),
7566            "unexpected removed value: {:?}",
7567            val["removed"]
7568        );
7569    }
7570
7571    #[test]
7572    fn handle_list_subscriptions_returns_array() {
7573        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7574        let req = make_tools_call("memory_list_subscriptions", json!({}));
7575        let resp = invoke_handle_request(&conn, &req);
7576        assert!(resp.error.is_none());
7577    }
7578
7579    #[test]
7580    fn handle_entity_register_happy() {
7581        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7582        let req = make_tools_call(
7583            "memory_entity_register",
7584            json!({
7585                "canonical_name": "Hugo Boss",
7586                "namespace": "w12-people",
7587                "aliases": ["HB", "Hugo"],
7588            }),
7589        );
7590        let resp = invoke_handle_request(&conn, &req);
7591        assert!(resp.error.is_none());
7592        let text = resp.result.unwrap()["content"][0]["text"]
7593            .as_str()
7594            .unwrap()
7595            .to_string();
7596        let val: Value = serde_json::from_str(&text).unwrap();
7597        assert!(val["entity_id"].is_string());
7598        assert_eq!(val["canonical_name"], "Hugo Boss");
7599    }
7600
7601    #[test]
7602    fn handle_entity_register_invalid_namespace() {
7603        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7604        let req = make_tools_call(
7605            "memory_entity_register",
7606            json!({"canonical_name": "X", "namespace": "INVALID NS!"}),
7607        );
7608        let resp = invoke_handle_request(&conn, &req);
7609        let result = resp.result.unwrap();
7610        assert_eq!(result["isError"], true);
7611    }
7612
7613    #[test]
7614    fn handle_entity_get_by_alias_not_found_returns_null() {
7615        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7616        let req = make_tools_call(
7617            "memory_entity_get_by_alias",
7618            json!({"alias": "no-such-alias", "namespace": "w12-people"}),
7619        );
7620        let resp = invoke_handle_request(&conn, &req);
7621        assert!(resp.error.is_none());
7622        let text = resp.result.unwrap()["content"][0]["text"]
7623            .as_str()
7624            .unwrap()
7625            .to_string();
7626        let val: Value = serde_json::from_str(&text).unwrap();
7627        assert_eq!(val["found"], false);
7628    }
7629
7630    #[test]
7631    fn handle_get_taxonomy_with_prefix_and_depth() {
7632        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7633        let req = make_tools_call(
7634            "memory_get_taxonomy",
7635            json!({"namespace_prefix": "w12-tax", "depth": 4, "limit": 100}),
7636        );
7637        let resp = invoke_handle_request(&conn, &req);
7638        assert!(resp.error.is_none());
7639        let text = resp.result.unwrap()["content"][0]["text"]
7640            .as_str()
7641            .unwrap()
7642            .to_string();
7643        let val: Value = serde_json::from_str(&text).unwrap();
7644        assert!(val["tree"].is_object() || val["tree"].is_array());
7645    }
7646
7647    #[test]
7648    fn handle_get_taxonomy_strips_trailing_slash() {
7649        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7650        let req = make_tools_call(
7651            "memory_get_taxonomy",
7652            json!({"namespace_prefix": "w12-tax/", "depth": 2}),
7653        );
7654        let resp = invoke_handle_request(&conn, &req);
7655        // Trailing-slash forgiveness branch: must not error.
7656        assert!(resp.error.is_none());
7657    }
7658
7659    #[test]
7660    fn handle_get_taxonomy_invalid_prefix_after_strip() {
7661        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7662        let req = make_tools_call(
7663            "memory_get_taxonomy",
7664            json!({"namespace_prefix": "BAD NS!"}),
7665        );
7666        let resp = invoke_handle_request(&conn, &req);
7667        let result = resp.result.unwrap();
7668        assert_eq!(result["isError"], true);
7669    }
7670
7671    #[test]
7672    fn handle_check_duplicate_no_embedder_errors() {
7673        // Without embedder, check_duplicate must error (it requires
7674        // semantic tier or above).
7675        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7676        let req = make_tools_call(
7677            "memory_check_duplicate",
7678            json!({"title": "T", "content": "C"}),
7679        );
7680        let resp = invoke_handle_request(&conn, &req);
7681        let result = resp.result.unwrap();
7682        assert_eq!(result["isError"], true);
7683        let msg = result["content"][0]["text"].as_str().unwrap();
7684        assert!(msg.contains("embedder") || msg.contains("semantic"));
7685    }
7686
7687    #[test]
7688    fn handle_expand_query_no_llm_errors() {
7689        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7690        let req = make_tools_call("memory_expand_query", json!({"query": "test"}));
7691        let resp = invoke_handle_request(&conn, &req);
7692        let result = resp.result.unwrap();
7693        assert_eq!(result["isError"], true);
7694        let msg = result["content"][0]["text"].as_str().unwrap();
7695        assert!(msg.contains("smart") || msg.contains("LLM") || msg.contains("Ollama"));
7696    }
7697
7698    #[test]
7699    fn handle_auto_tag_no_llm_errors() {
7700        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7701        let req = make_tools_call(
7702            "memory_auto_tag",
7703            json!({"id": "00000000-0000-0000-0000-000000000000"}),
7704        );
7705        let resp = invoke_handle_request(&conn, &req);
7706        let result = resp.result.unwrap();
7707        assert_eq!(result["isError"], true);
7708    }
7709
7710    #[test]
7711    fn handle_detect_contradiction_no_llm_errors() {
7712        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7713        let req = make_tools_call(
7714            "memory_detect_contradiction",
7715            json!({"id_a": "00000000-0000-0000-0000-000000000000", "id_b": "11111111-1111-1111-1111-111111111111"}),
7716        );
7717        let resp = invoke_handle_request(&conn, &req);
7718        let result = resp.result.unwrap();
7719        assert_eq!(result["isError"], true);
7720    }
7721
7722    #[test]
7723    fn handle_update_unknown_id_returns_not_found() {
7724        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7725        let req = make_tools_call(
7726            "memory_update",
7727            json!({
7728                "id": "00000000-0000-0000-0000-000000000000",
7729                "title": "new title",
7730            }),
7731        );
7732        let resp = invoke_handle_request(&conn, &req);
7733        let result = resp.result.unwrap();
7734        assert_eq!(result["isError"], true);
7735        let msg = result["content"][0]["text"].as_str().unwrap();
7736        assert!(msg.contains("not found"));
7737    }
7738
7739    #[test]
7740    fn handle_update_invalid_priority_rejected() {
7741        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7742        // First insert a memory we can target.
7743        let mem = Memory {
7744            id: uuid::Uuid::new_v4().to_string(),
7745            tier: Tier::Long,
7746            namespace: "w12-update".into(),
7747            title: "t".into(),
7748            content: "c".into(),
7749            tags: vec![],
7750            priority: 5,
7751            confidence: 1.0,
7752            source: "test".into(),
7753            access_count: 0,
7754            created_at: chrono::Utc::now().to_rfc3339(),
7755            updated_at: chrono::Utc::now().to_rfc3339(),
7756            last_accessed_at: None,
7757            expires_at: None,
7758            metadata: json!({}),
7759            reflection_depth: 0,
7760            memory_kind: crate::models::MemoryKind::Observation,
7761            entity_id: None,
7762            persona_version: None,
7763            citations: Vec::new(),
7764            source_uri: None,
7765            source_span: None,
7766            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7767            confidence_signals: None,
7768            confidence_decayed_at: None,
7769            version: 1,
7770        };
7771        let id = db::insert(&conn, &mem).unwrap();
7772        let req = make_tools_call(
7773            "memory_update",
7774            json!({"id": id, "priority": 99_i64}), // out of 1..=10 range
7775        );
7776        let resp = invoke_handle_request(&conn, &req);
7777        let result = resp.result.unwrap();
7778        assert_eq!(result["isError"], true);
7779    }
7780
7781    #[test]
7782    fn handle_update_with_metadata_object_accepted() {
7783        // Drives the metadata-is-object branch which validates and merges
7784        // the agent_id-preserving payload.
7785        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7786        let mem = Memory {
7787            id: uuid::Uuid::new_v4().to_string(),
7788            tier: Tier::Mid,
7789            namespace: "w12-meta".into(),
7790            title: "t".into(),
7791            content: "c".into(),
7792            tags: vec![],
7793            priority: 5,
7794            confidence: 1.0,
7795            source: "test".into(),
7796            access_count: 0,
7797            created_at: chrono::Utc::now().to_rfc3339(),
7798            updated_at: chrono::Utc::now().to_rfc3339(),
7799            last_accessed_at: None,
7800            expires_at: None,
7801            metadata: json!({}),
7802            reflection_depth: 0,
7803            memory_kind: crate::models::MemoryKind::Observation,
7804            entity_id: None,
7805            persona_version: None,
7806            citations: Vec::new(),
7807            source_uri: None,
7808            source_span: None,
7809            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7810            confidence_signals: None,
7811            confidence_decayed_at: None,
7812            version: 1,
7813        };
7814        let id = db::insert(&conn, &mem).unwrap();
7815        let req = make_tools_call(
7816            "memory_update",
7817            json!({
7818                "id": id,
7819                "metadata": {"custom": "field", "numbers": [1, 2, 3]},
7820            }),
7821        );
7822        let resp = invoke_handle_request(&conn, &req);
7823        assert!(resp.error.is_none());
7824    }
7825
7826    #[test]
7827    fn handle_get_links_unknown_id_returns_empty() {
7828        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7829        let req = make_tools_call(
7830            "memory_get_links",
7831            json!({"id": "00000000-0000-0000-0000-000000000000"}),
7832        );
7833        let resp = invoke_handle_request(&conn, &req);
7834        assert!(resp.error.is_none());
7835        let text = resp.result.unwrap()["content"][0]["text"]
7836            .as_str()
7837            .unwrap()
7838            .to_string();
7839        let val: Value = serde_json::from_str(&text).unwrap();
7840        assert!(val["links"].is_array());
7841        assert_eq!(val["count"], 0);
7842    }
7843
7844    #[test]
7845    fn handle_link_invalid_relation_rejected() {
7846        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7847        let req = make_tools_call(
7848            "memory_link",
7849            json!({
7850                "source_id": "00000000-0000-0000-0000-000000000000",
7851                "target_id": "11111111-1111-1111-1111-111111111111",
7852                "relation": "BADRELATIONNOTALLOWED",
7853            }),
7854        );
7855        let resp = invoke_handle_request(&conn, &req);
7856        let result = resp.result.unwrap();
7857        assert_eq!(result["isError"], true);
7858    }
7859
7860    #[test]
7861    fn handle_promote_to_namespace_with_explicit_target() {
7862        // Vertical-promote branch: when `to_namespace` is provided, the
7863        // memory is cloned to an ancestor namespace and linked with
7864        // `derived_from`. db::promote_to_namespace requires the target
7865        // to be an ancestor of the source's namespace, so use a
7866        // hierarchical namespace.
7867        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7868        let mem = Memory {
7869            id: uuid::Uuid::new_v4().to_string(),
7870            tier: Tier::Mid,
7871            namespace: "w12-parent/w12-child".into(),
7872            title: "t".into(),
7873            content: "c".into(),
7874            tags: vec![],
7875            priority: 5,
7876            confidence: 1.0,
7877            source: "test".into(),
7878            access_count: 0,
7879            created_at: chrono::Utc::now().to_rfc3339(),
7880            updated_at: chrono::Utc::now().to_rfc3339(),
7881            last_accessed_at: None,
7882            expires_at: None,
7883            metadata: json!({}),
7884            reflection_depth: 0,
7885            memory_kind: crate::models::MemoryKind::Observation,
7886            entity_id: None,
7887            persona_version: None,
7888            citations: Vec::new(),
7889            source_uri: None,
7890            source_span: None,
7891            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7892            confidence_signals: None,
7893            confidence_decayed_at: None,
7894            version: 1,
7895        };
7896        let id = db::insert(&conn, &mem).unwrap();
7897        let req = make_tools_call(
7898            "memory_promote",
7899            json!({"id": id, "to_namespace": "w12-parent"}),
7900        );
7901        let resp = invoke_handle_request(&conn, &req);
7902        assert!(resp.error.is_none());
7903        let text = resp.result.unwrap()["content"][0]["text"]
7904            .as_str()
7905            .unwrap()
7906            .to_string();
7907        let val: Value = serde_json::from_str(&text).unwrap();
7908        assert_eq!(val["mode"], "vertical");
7909        assert!(val["clone_id"].is_string());
7910    }
7911
7912    #[test]
7913    fn handle_promote_invalid_to_namespace_rejected() {
7914        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7915        let mem = Memory {
7916            id: uuid::Uuid::new_v4().to_string(),
7917            tier: Tier::Mid,
7918            namespace: "w12-pm".into(),
7919            title: "t".into(),
7920            content: "c".into(),
7921            tags: vec![],
7922            priority: 5,
7923            confidence: 1.0,
7924            source: "test".into(),
7925            access_count: 0,
7926            created_at: chrono::Utc::now().to_rfc3339(),
7927            updated_at: chrono::Utc::now().to_rfc3339(),
7928            last_accessed_at: None,
7929            expires_at: None,
7930            metadata: json!({}),
7931            reflection_depth: 0,
7932            memory_kind: crate::models::MemoryKind::Observation,
7933            entity_id: None,
7934            persona_version: None,
7935            citations: Vec::new(),
7936            source_uri: None,
7937            source_span: None,
7938            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7939            confidence_signals: None,
7940            confidence_decayed_at: None,
7941            version: 1,
7942        };
7943        let id = db::insert(&conn, &mem).unwrap();
7944        let req = make_tools_call(
7945            "memory_promote",
7946            json!({"id": id, "to_namespace": "BAD NS WITH SPACES"}),
7947        );
7948        let resp = invoke_handle_request(&conn, &req);
7949        let result = resp.result.unwrap();
7950        assert_eq!(result["isError"], true);
7951    }
7952
7953    #[test]
7954    fn handle_consolidate_with_explicit_summary_no_llm() {
7955        // Drives the "explicit summary" branch (no LLM call needed).
7956        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
7957        let mem_a = Memory {
7958            id: uuid::Uuid::new_v4().to_string(),
7959            tier: Tier::Mid,
7960            namespace: "w12-cons".into(),
7961            title: "a".into(),
7962            content: "alpha".into(),
7963            tags: vec![],
7964            priority: 5,
7965            confidence: 1.0,
7966            source: "test".into(),
7967            access_count: 0,
7968            created_at: chrono::Utc::now().to_rfc3339(),
7969            updated_at: chrono::Utc::now().to_rfc3339(),
7970            last_accessed_at: None,
7971            expires_at: None,
7972            metadata: json!({}),
7973            reflection_depth: 0,
7974            memory_kind: crate::models::MemoryKind::Observation,
7975            entity_id: None,
7976            persona_version: None,
7977            citations: Vec::new(),
7978            source_uri: None,
7979            source_span: None,
7980            confidence_source: crate::models::ConfidenceSource::CallerProvided,
7981            confidence_signals: None,
7982            confidence_decayed_at: None,
7983            version: 1,
7984        };
7985        let mut mem_b = mem_a.clone();
7986        mem_b.id = uuid::Uuid::new_v4().to_string();
7987        mem_b.title = "b".into();
7988        mem_b.content = "beta".into();
7989        let id_a = db::insert(&conn, &mem_a).unwrap();
7990        let id_b = db::insert(&conn, &mem_b).unwrap();
7991
7992        let req = make_tools_call(
7993            "memory_consolidate",
7994            json!({
7995                "ids": [id_a, id_b],
7996                "title": "merged",
7997                "summary": "merged summary",
7998                "namespace": "w12-cons",
7999            }),
8000        );
8001        let resp = invoke_handle_request(&conn, &req);
8002        assert!(resp.error.is_none());
8003        let text = resp.result.unwrap()["content"][0]["text"]
8004            .as_str()
8005            .unwrap()
8006            .to_string();
8007        let val: Value = serde_json::from_str(&text).unwrap();
8008        assert!(val["id"].is_string());
8009        assert_eq!(val["consolidated"], 2);
8010    }
8011
8012    #[test]
8013    fn handle_consolidate_non_string_id_rejected() {
8014        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8015        let req = make_tools_call(
8016            "memory_consolidate",
8017            json!({"ids": [42, "valid-id"], "title": "t", "summary": "s"}),
8018        );
8019        let resp = invoke_handle_request(&conn, &req);
8020        let result = resp.result.unwrap();
8021        assert_eq!(result["isError"], true);
8022        let msg = result["content"][0]["text"].as_str().unwrap();
8023        assert!(msg.contains("must be a string"));
8024    }
8025
8026    // ------------------------------------------------------------------
8027    // JSON-RPC framing — additional edge cases beyond M9's six.
8028    // ------------------------------------------------------------------
8029
8030    #[test]
8031    fn test_jsonrpc_handles_ping() {
8032        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8033        let req = RpcRequest {
8034            jsonrpc: "2.0".into(),
8035            id: Some(json!(1)),
8036            method: "ping".into(),
8037            params: json!({}),
8038        };
8039        let resp = invoke_handle_request(&conn, &req);
8040        assert!(resp.error.is_none());
8041    }
8042
8043    #[test]
8044    fn test_jsonrpc_handles_notifications_initialized() {
8045        // The client→server "I'm ready" notification — handler returns
8046        // the same empty body as ping.
8047        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8048        let req = RpcRequest {
8049            jsonrpc: "2.0".into(),
8050            id: Some(json!(2)),
8051            method: "notifications/initialized".into(),
8052            params: json!({}),
8053        };
8054        let resp = invoke_handle_request(&conn, &req);
8055        assert!(resp.error.is_none());
8056    }
8057
8058    #[test]
8059    fn test_jsonrpc_prompts_list_returns_array() {
8060        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8061        let req = RpcRequest {
8062            jsonrpc: "2.0".into(),
8063            id: Some(json!(3)),
8064            method: "prompts/list".into(),
8065            params: json!({}),
8066        };
8067        let resp = invoke_handle_request(&conn, &req);
8068        assert!(resp.error.is_none());
8069        let result = resp.result.unwrap();
8070        assert!(result["prompts"].is_array());
8071    }
8072
8073    #[test]
8074    fn test_jsonrpc_prompts_get_known_name_returns_messages() {
8075        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8076        let req = RpcRequest {
8077            jsonrpc: "2.0".into(),
8078            id: Some(json!(4)),
8079            method: "prompts/get".into(),
8080            params: json!({"name": "recall-first"}),
8081        };
8082        let resp = invoke_handle_request(&conn, &req);
8083        assert!(resp.error.is_none());
8084        let result = resp.result.unwrap();
8085        assert!(result["messages"].is_array());
8086    }
8087
8088    #[test]
8089    fn test_jsonrpc_prompts_get_with_namespace_arg_includes_hint() {
8090        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8091        let req = RpcRequest {
8092            jsonrpc: "2.0".into(),
8093            id: Some(json!(5)),
8094            method: "prompts/get".into(),
8095            params: json!({"name": "recall-first", "arguments": {"namespace": "w12-test"}}),
8096        };
8097        let resp = invoke_handle_request(&conn, &req);
8098        assert!(resp.error.is_none());
8099        let result = resp.result.unwrap();
8100        let text = result["messages"][0]["content"]["text"].as_str().unwrap();
8101        assert!(text.contains("w12-test"));
8102    }
8103
8104    #[test]
8105    fn test_jsonrpc_prompts_get_unknown_name_returns_error() {
8106        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8107        let req = RpcRequest {
8108            jsonrpc: "2.0".into(),
8109            id: Some(json!(6)),
8110            method: "prompts/get".into(),
8111            params: json!({"name": "no-such-prompt"}),
8112        };
8113        let resp = invoke_handle_request(&conn, &req);
8114        let err = resp.error.unwrap();
8115        assert_eq!(err.code, -32602);
8116    }
8117
8118    #[test]
8119    fn test_jsonrpc_prompts_get_missing_name_returns_error() {
8120        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8121        let req = RpcRequest {
8122            jsonrpc: "2.0".into(),
8123            id: Some(json!(7)),
8124            method: "prompts/get".into(),
8125            params: json!({}),
8126        };
8127        let resp = invoke_handle_request(&conn, &req);
8128        let err = resp.error.unwrap();
8129        assert_eq!(err.code, -32602);
8130    }
8131
8132    #[test]
8133    fn test_jsonrpc_prompts_get_memory_workflow() {
8134        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8135        let req = RpcRequest {
8136            jsonrpc: "2.0".into(),
8137            id: Some(json!(8)),
8138            method: "prompts/get".into(),
8139            params: json!({"name": "memory-workflow"}),
8140        };
8141        let resp = invoke_handle_request(&conn, &req);
8142        assert!(resp.error.is_none());
8143        let result = resp.result.unwrap();
8144        assert!(result["messages"].is_array());
8145    }
8146
8147    #[test]
8148    fn test_jsonrpc_tools_call_empty_tool_name_rejected() {
8149        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8150        let req = RpcRequest {
8151            jsonrpc: "2.0".into(),
8152            id: Some(json!(9)),
8153            method: "tools/call".into(),
8154            params: json!({"name": ""}),
8155        };
8156        let resp = invoke_handle_request(&conn, &req);
8157        let err = resp.error.unwrap();
8158        assert_eq!(err.code, -32602);
8159    }
8160
8161    #[test]
8162    fn test_jsonrpc_tools_call_arguments_not_object_uses_empty() {
8163        // arguments=null is replaced with an empty object before dispatch.
8164        // Combined with a tool that has no required args, this path
8165        // exercises the `is_object()` false branch of the arguments
8166        // resolution.
8167        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8168        let req = RpcRequest {
8169            jsonrpc: "2.0".into(),
8170            id: Some(json!(10)),
8171            method: "tools/call".into(),
8172            params: json!({"name": "memory_capabilities", "arguments": null}),
8173        };
8174        let resp = invoke_handle_request(&conn, &req);
8175        // Capabilities accepts no args; with empty defaults it succeeds.
8176        assert!(resp.error.is_none());
8177    }
8178
8179    #[test]
8180    fn test_jsonrpc_tools_call_unicode_in_args() {
8181        // Unicode strings round-trip through serde_json without issue —
8182        // verifies the dispatch path doesn't choke on non-ASCII args.
8183        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8184        let req = make_tools_call(
8185            "memory_store",
8186            json!({"title": "тест", "content": "日本語 ✨", "namespace": "w12-unicode"}),
8187        );
8188        let resp = invoke_handle_request(&conn, &req);
8189        assert!(resp.error.is_none());
8190    }
8191
8192    #[test]
8193    fn test_jsonrpc_dispatch_line_with_id_zero_treated_as_request() {
8194        // id=0 is a valid JSON-RPC id (numeric, non-null). Must NOT be
8195        // treated as a notification.
8196        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8197        let line = r#"{"jsonrpc":"2.0","id":0,"method":"tools/list"}"#;
8198        let resp = dispatch_line(&conn, line);
8199        assert!(resp.is_some());
8200    }
8201
8202    #[test]
8203    fn test_jsonrpc_dispatch_line_string_id_passes_through() {
8204        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8205        let line = r#"{"jsonrpc":"2.0","id":"call-abc","method":"tools/list"}"#;
8206        let resp = dispatch_line(&conn, line).expect("expected response");
8207        assert_eq!(resp.id, json!("call-abc"));
8208    }
8209
8210    // ------------------------------------------------------------------
8211    // Helper-fn coverage — build_namespace_chain branches.
8212    // ------------------------------------------------------------------
8213
8214    #[test]
8215    fn test_build_namespace_chain_global_only() {
8216        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8217        let chain = super::build_namespace_chain(&conn, "*");
8218        assert_eq!(chain, vec!["*".to_string()]);
8219    }
8220
8221    #[test]
8222    fn test_build_namespace_chain_simple_namespace() {
8223        // A flat namespace produces ["*", "ns"].
8224        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8225        let chain = super::build_namespace_chain(&conn, "w12-flat");
8226        assert!(chain.contains(&"*".to_string()));
8227        assert!(chain.contains(&"w12-flat".to_string()));
8228    }
8229
8230    #[test]
8231    fn test_build_namespace_chain_nested_yields_ancestors() {
8232        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8233        let chain = super::build_namespace_chain(&conn, "a/b/c");
8234        // Must contain "*" and the full chain top-down.
8235        assert_eq!(chain.first().unwrap(), "*");
8236        assert!(chain.contains(&"a/b/c".to_string()));
8237        // Top-down order: a precedes a/b precedes a/b/c.
8238        let pos_a = chain.iter().position(|s| s == "a").unwrap();
8239        let pos_ab = chain.iter().position(|s| s == "a/b").unwrap();
8240        let pos_abc = chain.iter().position(|s| s == "a/b/c").unwrap();
8241        assert!(pos_a < pos_ab && pos_ab < pos_abc);
8242    }
8243
8244    #[test]
8245    fn test_build_namespace_chain_with_explicit_parent() {
8246        // Seeding an explicit `parent_namespace` row should prepend that
8247        // ancestor before the /-derived chain.
8248        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8249        // Insert a row in namespace_meta so the explicit-parent walk
8250        // has something to traverse. Use db helpers when possible.
8251        let parent_mem = Memory {
8252            id: uuid::Uuid::new_v4().to_string(),
8253            tier: Tier::Long,
8254            namespace: "w12-explicit-grand".into(),
8255            title: "g".into(),
8256            content: "c".into(),
8257            tags: vec![],
8258            priority: 5,
8259            confidence: 1.0,
8260            source: "test".into(),
8261            access_count: 0,
8262            created_at: chrono::Utc::now().to_rfc3339(),
8263            updated_at: chrono::Utc::now().to_rfc3339(),
8264            last_accessed_at: None,
8265            expires_at: None,
8266            metadata: json!({}),
8267            reflection_depth: 0,
8268            memory_kind: crate::models::MemoryKind::Observation,
8269            entity_id: None,
8270            persona_version: None,
8271            citations: Vec::new(),
8272            source_uri: None,
8273            source_span: None,
8274            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8275            confidence_signals: None,
8276            confidence_decayed_at: None,
8277            version: 1,
8278        };
8279        let pid = db::insert(&conn, &parent_mem).unwrap();
8280        db::set_namespace_standard(&conn, "w12-explicit-grand", &pid, None).unwrap();
8281
8282        let mut child_mem = parent_mem.clone();
8283        child_mem.id = uuid::Uuid::new_v4().to_string();
8284        child_mem.namespace = "w12-explicit-leaf".into();
8285        let cid = db::insert(&conn, &child_mem).unwrap();
8286        db::set_namespace_standard(&conn, "w12-explicit-leaf", &cid, Some("w12-explicit-grand"))
8287            .unwrap();
8288
8289        let chain = super::build_namespace_chain(&conn, "w12-explicit-leaf");
8290        // Explicit-parent walk should include the grandparent.
8291        assert!(chain.contains(&"w12-explicit-grand".to_string()));
8292        assert!(chain.contains(&"w12-explicit-leaf".to_string()));
8293    }
8294
8295    // ------------------------------------------------------------------
8296    // extract_governance — surface the metadata.governance branch.
8297    // ------------------------------------------------------------------
8298
8299    #[test]
8300    fn test_extract_governance_default_when_metadata_absent() {
8301        let mem_val = json!({"id": "x"});
8302        let gov = super::extract_governance(&mem_val);
8303        // Default policy is non-null and serializes to an object.
8304        assert!(gov.is_object() || gov.is_null());
8305    }
8306
8307    #[test]
8308    fn test_extract_governance_default_when_metadata_invalid() {
8309        // metadata.governance present but not a valid policy -> default.
8310        let mem_val = json!({"metadata": {"governance": {"unknown": "policy"}}});
8311        let gov = super::extract_governance(&mem_val);
8312        // Default policy is non-null and serializes to an object.
8313        assert!(gov.is_object());
8314    }
8315
8316    // ------------------------------------------------------------------
8317    // messages_namespace_for — confirm both ASCII and ai: prefixes.
8318    // ------------------------------------------------------------------
8319
8320    #[test]
8321    fn test_messages_namespace_for_plain_id() {
8322        assert_eq!(super::messages_namespace_for("alice"), "_messages/alice");
8323    }
8324
8325    #[test]
8326    fn test_messages_namespace_for_ai_prefixed_id() {
8327        let ns = super::messages_namespace_for("ai:claude@host:pid-1");
8328        assert!(ns.starts_with("_messages/"));
8329        assert!(ns.contains("ai:"));
8330    }
8331
8332    // ------------------------------------------------------------------
8333    // inject_namespace_standard — additional shape branches that M9
8334    // didn't reach (no-namespace + no-global, dedup ordering).
8335    // ------------------------------------------------------------------
8336
8337    #[test]
8338    fn test_inject_namespace_standard_no_namespace_no_global() {
8339        // namespace=None and no "*" standard set → response unchanged.
8340        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8341        let mut resp = make_recall_response(vec![]);
8342        let before = resp.clone();
8343        super::inject_namespace_standard(&conn, None, &mut resp);
8344        assert_eq!(resp, before);
8345    }
8346
8347    // ------------------------------------------------------------------
8348    // W12-A — additional coverage targets discovered after the first
8349    // sweep. These hit handler happy-paths that the smoke matrix
8350    // skipped (tier-default promotion, dedup-update, registered
8351    // subscriber) plus a few error / boundary branches.
8352    // ------------------------------------------------------------------
8353
8354    #[test]
8355    fn handle_promote_default_tier_to_long() {
8356        // Drives the "no to_namespace" branch which clears expires_at
8357        // and bumps tier to Long. This is the historical behaviour.
8358        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8359        let mem = Memory {
8360            id: uuid::Uuid::new_v4().to_string(),
8361            tier: Tier::Mid,
8362            namespace: "w12-tier-promote".into(),
8363            title: "t".into(),
8364            content: "c".into(),
8365            tags: vec![],
8366            priority: 5,
8367            confidence: 1.0,
8368            source: "test".into(),
8369            access_count: 0,
8370            created_at: chrono::Utc::now().to_rfc3339(),
8371            updated_at: chrono::Utc::now().to_rfc3339(),
8372            last_accessed_at: None,
8373            expires_at: None,
8374            metadata: json!({}),
8375            reflection_depth: 0,
8376            memory_kind: crate::models::MemoryKind::Observation,
8377            entity_id: None,
8378            persona_version: None,
8379            citations: Vec::new(),
8380            source_uri: None,
8381            source_span: None,
8382            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8383            confidence_signals: None,
8384            confidence_decayed_at: None,
8385            version: 1,
8386        };
8387        let id = db::insert(&conn, &mem).unwrap();
8388        let req = make_tools_call("memory_promote", json!({"id": id}));
8389        let resp = invoke_handle_request(&conn, &req);
8390        assert!(resp.error.is_none());
8391        let text = resp.result.unwrap()["content"][0]["text"]
8392            .as_str()
8393            .unwrap()
8394            .to_string();
8395        let val: Value = serde_json::from_str(&text).unwrap();
8396        assert_eq!(val["promoted"], true);
8397        assert_eq!(val["mode"], "tier");
8398        assert_eq!(val["tier"], Tier::Long.as_str());
8399    }
8400
8401    #[test]
8402    fn handle_store_dedup_updates_existing() {
8403        // Storing twice with the same title+namespace must hit the
8404        // dedup-update branch instead of inserting a second row.
8405        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8406        let req1 = make_tools_call(
8407            "memory_store",
8408            json!({
8409                "title": "dup-title",
8410                "content": "first",
8411                "namespace": "w12-dedup",
8412                "tier": Tier::Mid.as_str(),
8413            }),
8414        );
8415        let resp1 = invoke_handle_request(&conn, &req1);
8416        assert!(resp1.error.is_none());
8417        let text1 = resp1.result.unwrap()["content"][0]["text"]
8418            .as_str()
8419            .unwrap()
8420            .to_string();
8421        let val1: Value = serde_json::from_str(&text1).unwrap();
8422        let id1 = val1["id"].as_str().unwrap().to_string();
8423
8424        let req2 = make_tools_call(
8425            "memory_store",
8426            json!({
8427                "title": "dup-title",
8428                "content": "second-update",
8429                "namespace": "w12-dedup",
8430                "tier": Tier::Long.as_str(),
8431            }),
8432        );
8433        let resp2 = invoke_handle_request(&conn, &req2);
8434        assert!(resp2.error.is_none());
8435        let text2 = resp2.result.unwrap()["content"][0]["text"]
8436            .as_str()
8437            .unwrap()
8438            .to_string();
8439        let val2: Value = serde_json::from_str(&text2).unwrap();
8440        assert_eq!(val2["id"], id1);
8441        assert_eq!(val2["duplicate"], true);
8442        assert_eq!(val2["action"], "updated existing memory");
8443    }
8444
8445    #[test]
8446    fn handle_subscribe_with_registered_agent_succeeds() {
8447        // Drives the subscribe-after-register happy path (the smoke
8448        // matrix only catches the unregistered-error case).
8449        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8450        // Register the caller (default agent_id resolved by mcp_client=None)
8451        // — we let resolve_agent_id mint one; by registering the resolved
8452        // value we can pass the subscribe gate.
8453        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
8454        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
8455        // R3-S1.HMAC (2026-05-13): supply secret so the HMAC gate
8456        // passes (registration now requires per-sub or server-wide).
8457        let req = make_tools_call(
8458            "memory_subscribe",
8459            json!({
8460                "url": "https://example.com/hook",
8461                "events": "memory_store,memory_delete",
8462                "namespace_filter": "w12-sub",
8463                "secret": "mcp-sub-test-secret",
8464            }),
8465        );
8466        let resp = invoke_handle_request(&conn, &req);
8467        assert!(resp.error.is_none());
8468        let text = resp.result.unwrap()["content"][0]["text"]
8469            .as_str()
8470            .unwrap()
8471            .to_string();
8472        let val: Value = serde_json::from_str(&text).unwrap();
8473        assert!(val["id"].is_string());
8474        assert_eq!(val["url"], "https://example.com/hook");
8475    }
8476
8477    #[test]
8478    fn handle_subscribe_invalid_url_after_registered() {
8479        // After registering, a malformed URL still falls through to the
8480        // url-validate branch.
8481        // R3-S1.HMAC (2026-05-13): supply secret so the URL-validate
8482        // branch (not the HMAC branch) is what this test pins.
8483        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8484        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
8485        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
8486        let req = make_tools_call(
8487            "memory_subscribe",
8488            json!({"url": "not-a-url-at-all", "secret": "mcp-sub-test-secret"}),
8489        );
8490        let resp = invoke_handle_request(&conn, &req);
8491        let result = resp.result.unwrap();
8492        assert_eq!(result["isError"], true);
8493    }
8494
8495    #[test]
8496    fn handle_namespace_set_standard_with_valid_governance() {
8497        // Drives the governance-merge branch (lines 2284-2322) which
8498        // re-writes the standard memory's metadata with the resolved
8499        // policy.
8500        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8501        let mem = Memory {
8502            id: uuid::Uuid::new_v4().to_string(),
8503            tier: Tier::Long,
8504            namespace: "w12-gov-ok".into(),
8505            title: "p".into(),
8506            content: "c".into(),
8507            tags: vec![],
8508            priority: 5,
8509            confidence: 1.0,
8510            source: "test".into(),
8511            access_count: 0,
8512            created_at: chrono::Utc::now().to_rfc3339(),
8513            updated_at: chrono::Utc::now().to_rfc3339(),
8514            last_accessed_at: None,
8515            expires_at: None,
8516            metadata: json!({}),
8517            reflection_depth: 0,
8518            memory_kind: crate::models::MemoryKind::Observation,
8519            entity_id: None,
8520            persona_version: None,
8521            citations: Vec::new(),
8522            source_uri: None,
8523            source_span: None,
8524            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8525            confidence_signals: None,
8526            confidence_decayed_at: None,
8527            version: 1,
8528        };
8529        let id = db::insert(&conn, &mem).unwrap();
8530        let req = make_tools_call(
8531            "memory_namespace_set_standard",
8532            json!({
8533                "namespace": "w12-gov-ok",
8534                "id": id,
8535                "governance": {
8536                    "write": "any",
8537                    "promote": "any",
8538                    "delete": "owner",
8539                    "approver": "human",
8540                },
8541            }),
8542        );
8543        let resp = invoke_handle_request(&conn, &req);
8544        assert!(resp.error.is_none());
8545        let text = resp.result.unwrap()["content"][0]["text"]
8546            .as_str()
8547            .unwrap()
8548            .to_string();
8549        let val: Value = serde_json::from_str(&text).unwrap();
8550        assert_eq!(val["set"], true);
8551        assert!(val["governance"].is_object());
8552    }
8553
8554    #[test]
8555    fn handle_namespace_set_standard_with_parent() {
8556        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8557        let mem = Memory {
8558            id: uuid::Uuid::new_v4().to_string(),
8559            tier: Tier::Long,
8560            namespace: "w12-parent-ns".into(),
8561            title: "p".into(),
8562            content: "c".into(),
8563            tags: vec![],
8564            priority: 5,
8565            confidence: 1.0,
8566            source: "test".into(),
8567            access_count: 0,
8568            created_at: chrono::Utc::now().to_rfc3339(),
8569            updated_at: chrono::Utc::now().to_rfc3339(),
8570            last_accessed_at: None,
8571            expires_at: None,
8572            metadata: json!({}),
8573            reflection_depth: 0,
8574            memory_kind: crate::models::MemoryKind::Observation,
8575            entity_id: None,
8576            persona_version: None,
8577            citations: Vec::new(),
8578            source_uri: None,
8579            source_span: None,
8580            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8581            confidence_signals: None,
8582            confidence_decayed_at: None,
8583            version: 1,
8584        };
8585        let id = db::insert(&conn, &mem).unwrap();
8586        let req = make_tools_call(
8587            "memory_namespace_set_standard",
8588            json!({
8589                "namespace": "w12-parent-ns",
8590                "id": id,
8591                "parent": "w12-grand-ns",
8592            }),
8593        );
8594        let resp = invoke_handle_request(&conn, &req);
8595        assert!(resp.error.is_none());
8596        let text = resp.result.unwrap()["content"][0]["text"]
8597            .as_str()
8598            .unwrap()
8599            .to_string();
8600        let val: Value = serde_json::from_str(&text).unwrap();
8601        assert_eq!(val["parent"], "w12-grand-ns");
8602    }
8603
8604    #[test]
8605    fn handle_get_resolves_by_prefix_and_includes_links() {
8606        // db::resolve_id walks both exact and prefix lookup. Insert a
8607        // memory and request it by its 8-char prefix to drive the
8608        // prefix branch.
8609        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8610        let mem = Memory {
8611            id: uuid::Uuid::new_v4().to_string(),
8612            tier: Tier::Long,
8613            namespace: "w12-prefix".into(),
8614            title: "T".into(),
8615            content: "C".into(),
8616            tags: vec![],
8617            priority: 5,
8618            confidence: 1.0,
8619            source: "test".into(),
8620            access_count: 0,
8621            created_at: chrono::Utc::now().to_rfc3339(),
8622            updated_at: chrono::Utc::now().to_rfc3339(),
8623            last_accessed_at: None,
8624            expires_at: None,
8625            metadata: json!({}),
8626            reflection_depth: 0,
8627            memory_kind: crate::models::MemoryKind::Observation,
8628            entity_id: None,
8629            persona_version: None,
8630            citations: Vec::new(),
8631            source_uri: None,
8632            source_span: None,
8633            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8634            confidence_signals: None,
8635            confidence_decayed_at: None,
8636            version: 1,
8637        };
8638        let id = db::insert(&conn, &mem).unwrap();
8639        let req = make_tools_call("memory_get", json!({"id": id}));
8640        let resp = invoke_handle_request(&conn, &req);
8641        assert!(resp.error.is_none());
8642        let text = resp.result.unwrap()["content"][0]["text"]
8643            .as_str()
8644            .unwrap()
8645            .to_string();
8646        let val: Value = serde_json::from_str(&text).unwrap();
8647        assert!(val["links"].is_array());
8648        assert_eq!(val["id"], id);
8649    }
8650
8651    #[test]
8652    fn handle_link_creates_link_between_existing_memories() {
8653        // Drives the create_link happy path (smoke matrix uses bogus IDs
8654        // so the existence check fails out before INSERT).
8655        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8656        let src = Memory {
8657            id: uuid::Uuid::new_v4().to_string(),
8658            tier: Tier::Long,
8659            namespace: "w12-link".into(),
8660            title: "src".into(),
8661            content: "c".into(),
8662            tags: vec![],
8663            priority: 5,
8664            confidence: 1.0,
8665            source: "test".into(),
8666            access_count: 0,
8667            created_at: chrono::Utc::now().to_rfc3339(),
8668            updated_at: chrono::Utc::now().to_rfc3339(),
8669            last_accessed_at: None,
8670            expires_at: None,
8671            metadata: json!({}),
8672            reflection_depth: 0,
8673            memory_kind: crate::models::MemoryKind::Observation,
8674            entity_id: None,
8675            persona_version: None,
8676            citations: Vec::new(),
8677            source_uri: None,
8678            source_span: None,
8679            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8680            confidence_signals: None,
8681            confidence_decayed_at: None,
8682            version: 1,
8683        };
8684        let mut tgt = src.clone();
8685        tgt.id = uuid::Uuid::new_v4().to_string();
8686        tgt.title = "tgt".into();
8687        let src_id = db::insert(&conn, &src).unwrap();
8688        let tgt_id = db::insert(&conn, &tgt).unwrap();
8689        let req = make_tools_call(
8690            "memory_link",
8691            json!({
8692                "source_id": src_id,
8693                "target_id": tgt_id,
8694                "relation": "related_to",
8695            }),
8696        );
8697        let resp = invoke_handle_request(&conn, &req);
8698        assert!(resp.error.is_none());
8699        let text = resp.result.unwrap()["content"][0]["text"]
8700            .as_str()
8701            .unwrap()
8702            .to_string();
8703        let val: Value = serde_json::from_str(&text).unwrap();
8704        assert_eq!(val["linked"], true);
8705    }
8706
8707    #[test]
8708    fn handle_get_links_returns_outbound_and_inbound() {
8709        // Seed source+target+link, query links from source.
8710        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8711        let src = Memory {
8712            id: uuid::Uuid::new_v4().to_string(),
8713            tier: Tier::Long,
8714            namespace: "w12-getlinks".into(),
8715            title: "src".into(),
8716            content: "c".into(),
8717            tags: vec![],
8718            priority: 5,
8719            confidence: 1.0,
8720            source: "test".into(),
8721            access_count: 0,
8722            created_at: chrono::Utc::now().to_rfc3339(),
8723            updated_at: chrono::Utc::now().to_rfc3339(),
8724            last_accessed_at: None,
8725            expires_at: None,
8726            metadata: json!({}),
8727            reflection_depth: 0,
8728            memory_kind: crate::models::MemoryKind::Observation,
8729            entity_id: None,
8730            persona_version: None,
8731            citations: Vec::new(),
8732            source_uri: None,
8733            source_span: None,
8734            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8735            confidence_signals: None,
8736            confidence_decayed_at: None,
8737            version: 1,
8738        };
8739        let mut tgt = src.clone();
8740        tgt.id = uuid::Uuid::new_v4().to_string();
8741        let src_id = db::insert(&conn, &src).unwrap();
8742        let tgt_id = db::insert(&conn, &tgt).unwrap();
8743        db::create_link(&conn, &src_id, &tgt_id, "supersedes").unwrap();
8744
8745        let req = make_tools_call("memory_get_links", json!({"id": src_id}));
8746        let resp = invoke_handle_request(&conn, &req);
8747        assert!(resp.error.is_none());
8748        let text = resp.result.unwrap()["content"][0]["text"]
8749            .as_str()
8750            .unwrap()
8751            .to_string();
8752        let val: Value = serde_json::from_str(&text).unwrap();
8753        assert!(val["count"].as_u64().unwrap() >= 1);
8754    }
8755
8756    #[test]
8757    fn handle_kg_timeline_with_seeded_link_returns_event() {
8758        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8759        let src = Memory {
8760            id: uuid::Uuid::new_v4().to_string(),
8761            tier: Tier::Long,
8762            namespace: "w12-tl".into(),
8763            title: "src".into(),
8764            content: "c".into(),
8765            tags: vec![],
8766            priority: 5,
8767            confidence: 1.0,
8768            source: "test".into(),
8769            access_count: 0,
8770            created_at: chrono::Utc::now().to_rfc3339(),
8771            updated_at: chrono::Utc::now().to_rfc3339(),
8772            last_accessed_at: None,
8773            expires_at: None,
8774            metadata: json!({}),
8775            reflection_depth: 0,
8776            memory_kind: crate::models::MemoryKind::Observation,
8777            entity_id: None,
8778            persona_version: None,
8779            citations: Vec::new(),
8780            source_uri: None,
8781            source_span: None,
8782            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8783            confidence_signals: None,
8784            confidence_decayed_at: None,
8785            version: 1,
8786        };
8787        let mut tgt = src.clone();
8788        tgt.id = uuid::Uuid::new_v4().to_string();
8789        tgt.title = "tgt".into();
8790        let src_id = db::insert(&conn, &src).unwrap();
8791        let tgt_id = db::insert(&conn, &tgt).unwrap();
8792        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
8793
8794        let req = make_tools_call(
8795            "memory_kg_timeline",
8796            json!({"source_id": src_id, "limit": 10}),
8797        );
8798        let resp = invoke_handle_request(&conn, &req);
8799        assert!(resp.error.is_none());
8800        let text = resp.result.unwrap()["content"][0]["text"]
8801            .as_str()
8802            .unwrap()
8803            .to_string();
8804        let val: Value = serde_json::from_str(&text).unwrap();
8805        assert_eq!(val["count"], 1);
8806        let events = val["events"].as_array().unwrap();
8807        assert_eq!(events[0]["target_id"], tgt_id);
8808    }
8809
8810    // ------------------------------------------------------------------
8811    // Coverage-uplift (2026-05-19): exercise the by_source_uri arm of
8812    // handle_kg_query (lines 22-48 of mcp/tools/kg_query.rs).
8813    // ------------------------------------------------------------------
8814
8815    #[test]
8816    fn handle_kg_query_by_source_uri_returns_roots() {
8817        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8818        // Seed two memories sharing the same source_uri.
8819        let mk = |ns: &str, t: &str, uri: Option<&str>| Memory {
8820            id: uuid::Uuid::new_v4().to_string(),
8821            tier: Tier::Long,
8822            namespace: ns.into(),
8823            title: t.into(),
8824            content: "c".into(),
8825            tags: vec![],
8826            priority: 5,
8827            confidence: 1.0,
8828            source: "test".into(),
8829            access_count: 0,
8830            created_at: chrono::Utc::now().to_rfc3339(),
8831            updated_at: chrono::Utc::now().to_rfc3339(),
8832            last_accessed_at: None,
8833            expires_at: None,
8834            metadata: json!({}),
8835            reflection_depth: 0,
8836            memory_kind: crate::models::MemoryKind::Observation,
8837            entity_id: None,
8838            persona_version: None,
8839            citations: Vec::new(),
8840            source_uri: uri.map(str::to_string),
8841            source_span: None,
8842            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8843            confidence_signals: None,
8844            confidence_decayed_at: None,
8845            version: 1,
8846        };
8847        let uri = "doc:test-uplift/abc#section-1";
8848        db::insert(&conn, &mk("kg-uplift", "a", Some(uri))).unwrap();
8849        db::insert(&conn, &mk("kg-uplift", "b", Some(uri))).unwrap();
8850        db::insert(&conn, &mk("kg-uplift", "c", None)).unwrap();
8851
8852        let req = make_tools_call(
8853            "memory_kg_query",
8854            json!({"by_source_uri": uri, "namespace": "kg-uplift"}),
8855        );
8856        let resp = invoke_handle_request(&conn, &req);
8857        assert!(resp.error.is_none(), "{resp:?}");
8858        let text = resp.result.unwrap()["content"][0]["text"]
8859            .as_str()
8860            .unwrap()
8861            .to_string();
8862        let val: Value = serde_json::from_str(&text).unwrap();
8863        assert_eq!(val["by_source_uri"], uri);
8864        assert_eq!(val["count"], 2);
8865        let mems = val["memories"].as_array().unwrap();
8866        assert_eq!(mems.len(), 2);
8867        // The depth field of every root row is 0 (one-hop semantics).
8868        assert!(mems.iter().all(|m| m["depth"].as_u64() == Some(0)));
8869    }
8870
8871    #[test]
8872    fn handle_kg_query_by_source_uri_rejects_invalid_uri() {
8873        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8874        // Whitespace-only URI: trimmed to empty so the by_source_uri
8875        // branch falls through and source_id is required.
8876        let req = make_tools_call("memory_kg_query", json!({"by_source_uri": "   "}));
8877        let resp = invoke_handle_request(&conn, &req);
8878        let result = resp.result.unwrap();
8879        assert_eq!(result["isError"], true);
8880        // The error string is "source_id is required" (the by_source_uri
8881        // arm dropped through because the trimmed value was empty).
8882        let text = result["content"][0]["text"].as_str().unwrap();
8883        assert!(text.contains("source_id is required"));
8884    }
8885
8886    #[test]
8887    fn handle_kg_query_by_source_uri_validates_uri_shape() {
8888        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8889        // A URI that fails validate_source_uri (e.g. contains a null byte
8890        // or empty after trim). Pass a control char to trigger refusal.
8891        let req = make_tools_call(
8892            "memory_kg_query",
8893            json!({"by_source_uri": "bad\u{0007}uri"}),
8894        );
8895        let resp = invoke_handle_request(&conn, &req);
8896        let result = resp.result.unwrap();
8897        assert_eq!(result["isError"], true);
8898    }
8899
8900    #[test]
8901    fn handle_kg_query_with_seeded_link_returns_node() {
8902        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8903        let src = Memory {
8904            id: uuid::Uuid::new_v4().to_string(),
8905            tier: Tier::Long,
8906            namespace: "w12-kgq".into(),
8907            title: "src".into(),
8908            content: "c".into(),
8909            tags: vec![],
8910            priority: 5,
8911            confidence: 1.0,
8912            source: "test".into(),
8913            access_count: 0,
8914            created_at: chrono::Utc::now().to_rfc3339(),
8915            updated_at: chrono::Utc::now().to_rfc3339(),
8916            last_accessed_at: None,
8917            expires_at: None,
8918            metadata: json!({}),
8919            reflection_depth: 0,
8920            memory_kind: crate::models::MemoryKind::Observation,
8921            entity_id: None,
8922            persona_version: None,
8923            citations: Vec::new(),
8924            source_uri: None,
8925            source_span: None,
8926            confidence_source: crate::models::ConfidenceSource::CallerProvided,
8927            confidence_signals: None,
8928            confidence_decayed_at: None,
8929            version: 1,
8930        };
8931        let mut tgt = src.clone();
8932        tgt.id = uuid::Uuid::new_v4().to_string();
8933        let src_id = db::insert(&conn, &src).unwrap();
8934        let tgt_id = db::insert(&conn, &tgt).unwrap();
8935        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
8936
8937        let req = make_tools_call(
8938            "memory_kg_query",
8939            json!({"source_id": src_id, "max_depth": 1, "limit": 10}),
8940        );
8941        let resp = invoke_handle_request(&conn, &req);
8942        assert!(resp.error.is_none());
8943        let text = resp.result.unwrap()["content"][0]["text"]
8944            .as_str()
8945            .unwrap()
8946            .to_string();
8947        let val: Value = serde_json::from_str(&text).unwrap();
8948        assert!(val["count"].as_u64().unwrap() >= 1);
8949        assert!(val["paths"].is_array());
8950    }
8951
8952    #[test]
8953    fn handle_archive_list_with_pagination() {
8954        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8955        let req = make_tools_call("memory_archive_list", json!({"limit": 100, "offset": 50}));
8956        let resp = invoke_handle_request(&conn, &req);
8957        assert!(resp.error.is_none());
8958    }
8959
8960    #[test]
8961    fn handle_pending_list_with_status_filter() {
8962        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8963        for status in &["pending", "approved", "rejected"] {
8964            let req = make_tools_call(
8965                "memory_pending_list",
8966                json!({"status": status, "limit": 50}),
8967            );
8968            let resp = invoke_handle_request(&conn, &req);
8969            assert!(resp.error.is_none(), "failed for status={status}");
8970        }
8971    }
8972
8973    #[test]
8974    fn handle_pending_approve_with_seeded_pending_action() {
8975        // Seed a pending action to drive the consensus / approval branch.
8976        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
8977        let pending_id = db::queue_pending_action(
8978            &conn,
8979            crate::models::GovernedAction::Promote,
8980            "w12-approve",
8981            None,
8982            "human:requestor",
8983            &json!({"id": "00000000-0000-0000-0000-000000000000"}),
8984        )
8985        .unwrap();
8986        let req = make_tools_call(
8987            "memory_pending_approve",
8988            json!({"id": pending_id, "agent_id": "human:approver"}),
8989        );
8990        let resp = invoke_handle_request(&conn, &req);
8991        // Either approves outright or marks pending — both touch the
8992        // ApproveOutcome match arms in the handler.
8993        let result = resp.result.unwrap();
8994        assert!(result.is_object());
8995    }
8996
8997    #[test]
8998    fn handle_pending_reject_with_seeded_pending_action() {
8999        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9000        let pending_id = db::queue_pending_action(
9001            &conn,
9002            crate::models::GovernedAction::Promote,
9003            "w12-reject",
9004            None,
9005            "human:requestor",
9006            &json!({"id": "00000000-0000-0000-0000-000000000000"}),
9007        )
9008        .unwrap();
9009        let req = make_tools_call(
9010            "memory_pending_reject",
9011            json!({"id": pending_id, "agent_id": "human:rejector"}),
9012        );
9013        let resp = invoke_handle_request(&conn, &req);
9014        assert!(resp.error.is_none());
9015        let text = resp.result.unwrap()["content"][0]["text"]
9016            .as_str()
9017            .unwrap()
9018            .to_string();
9019        let val: Value = serde_json::from_str(&text).unwrap();
9020        assert_eq!(val["rejected"], true);
9021    }
9022
9023    #[test]
9024    fn handle_session_start_toon_format_default() {
9025        // session_start defaults to TOON compact format — drives the
9026        // toon_compact match arm in the format dispatch.
9027        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9028        let req = make_tools_call("memory_session_start", json!({"namespace": "w12-toon"}));
9029        let resp = invoke_handle_request(&conn, &req);
9030        assert!(resp.error.is_none());
9031        // TOON output is plain text, not JSON — just confirm it's present.
9032        let result = resp.result.unwrap();
9033        assert!(result["content"][0]["text"].is_string());
9034    }
9035
9036    #[test]
9037    fn handle_search_explicit_toon_format() {
9038        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9039        let req = make_tools_call(
9040            "memory_search",
9041            json!({"query": "anything", "format": "toon"}),
9042        );
9043        let resp = invoke_handle_request(&conn, &req);
9044        assert!(resp.error.is_none());
9045    }
9046
9047    #[test]
9048    fn handle_recall_explicit_toon_format() {
9049        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9050        let req = make_tools_call("memory_recall", json!({"context": "ctx", "format": "toon"}));
9051        let resp = invoke_handle_request(&conn, &req);
9052        assert!(resp.error.is_none());
9053    }
9054
9055    #[test]
9056    fn handle_list_explicit_toon_compact_format() {
9057        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9058        let req = make_tools_call(
9059            "memory_list",
9060            json!({"namespace": "w12-toon-list", "format": "toon_compact"}),
9061        );
9062        let resp = invoke_handle_request(&conn, &req);
9063        assert!(resp.error.is_none());
9064    }
9065
9066    #[test]
9067    fn handle_search_with_namespace_and_tier_filters() {
9068        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9069        let req = make_tools_call(
9070            "memory_search",
9071            json!({
9072                "query": "test query",
9073                "namespace": "w12-search",
9074                "tier": Tier::Long.as_str(),
9075                "limit": 10,
9076                "agent_id": "ai:bot",
9077                "format": "json",
9078            }),
9079        );
9080        let resp = invoke_handle_request(&conn, &req);
9081        assert!(resp.error.is_none());
9082    }
9083
9084    #[test]
9085    fn handle_search_invalid_agent_id_rejected() {
9086        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9087        let req = make_tools_call(
9088            "memory_search",
9089            json!({"query": "x", "agent_id": "bad agent !!"}),
9090        );
9091        let resp = invoke_handle_request(&conn, &req);
9092        let result = resp.result.unwrap();
9093        assert_eq!(result["isError"], true);
9094    }
9095
9096    #[test]
9097    fn handle_search_invalid_as_agent_rejected() {
9098        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9099        let req = make_tools_call(
9100            "memory_search",
9101            json!({"query": "x", "as_agent": "BAD AS AGENT"}),
9102        );
9103        let resp = invoke_handle_request(&conn, &req);
9104        let result = resp.result.unwrap();
9105        assert_eq!(result["isError"], true);
9106    }
9107
9108    #[test]
9109    fn handle_recall_invalid_as_agent_rejected() {
9110        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9111        let req = make_tools_call(
9112            "memory_recall",
9113            json!({"context": "x", "as_agent": "INVALID NS"}),
9114        );
9115        let resp = invoke_handle_request(&conn, &req);
9116        let result = resp.result.unwrap();
9117        assert_eq!(result["isError"], true);
9118    }
9119
9120    #[test]
9121    fn handle_recall_with_context_tokens() {
9122        // Drives the context_tokens-not-empty branch (without an embedder
9123        // it just feeds the keyword fallback).
9124        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9125        let req = make_tools_call(
9126            "memory_recall",
9127            json!({
9128                "context": "main",
9129                "context_tokens": ["recent", "tokens", "from", "convo"],
9130                "format": "json",
9131            }),
9132        );
9133        let resp = invoke_handle_request(&conn, &req);
9134        assert!(resp.error.is_none());
9135    }
9136
9137    #[test]
9138    fn handle_recall_with_budget_tokens_positive() {
9139        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9140        let req = make_tools_call(
9141            "memory_recall",
9142            json!({"context": "x", "budget_tokens": 1000, "format": "json"}),
9143        );
9144        let resp = invoke_handle_request(&conn, &req);
9145        assert!(resp.error.is_none());
9146        let text = resp.result.unwrap()["content"][0]["text"]
9147            .as_str()
9148            .unwrap()
9149            .to_string();
9150        let val: Value = serde_json::from_str(&text).unwrap();
9151        assert!(val["tokens_used"].is_u64() || val["tokens_used"].is_i64());
9152        assert_eq!(val["budget_tokens"], 1000);
9153    }
9154
9155    #[test]
9156    fn handle_recall_invalid_namespace_filter_passes_through() {
9157        // Recall accepts a namespace filter without validating; an
9158        // unknown namespace simply returns zero results.
9159        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9160        let req = make_tools_call(
9161            "memory_recall",
9162            json!({
9163                "context": "x",
9164                "namespace": "w12-no-such-namespace",
9165                "format": "json",
9166            }),
9167        );
9168        let resp = invoke_handle_request(&conn, &req);
9169        assert!(resp.error.is_none());
9170    }
9171
9172    #[test]
9173    fn handle_list_with_tier_filter() {
9174        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9175        let req = make_tools_call(
9176            "memory_list",
9177            json!({
9178                "namespace": "w12-list-tier",
9179                "tier": Tier::Long.as_str(),
9180                "agent_id": "ai:bot",
9181                "limit": 25,
9182                "format": "json",
9183            }),
9184        );
9185        let resp = invoke_handle_request(&conn, &req);
9186        assert!(resp.error.is_none());
9187    }
9188
9189    #[test]
9190    fn handle_list_invalid_tier_treated_as_none() {
9191        // tier::from_str returns None for an invalid value, which the
9192        // handler tolerates (no validation error) — drives the
9193        // and_then-None branch.
9194        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9195        let req = make_tools_call(
9196            "memory_list",
9197            json!({"namespace": "w12-list-bad-tier", "tier": "ULTRAMID", "format": "json"}),
9198        );
9199        let resp = invoke_handle_request(&conn, &req);
9200        assert!(resp.error.is_none());
9201    }
9202
9203    #[test]
9204    fn handle_get_taxonomy_invalid_depth_clamps_to_max() {
9205        // `depth` saturates against MAX_NAMESPACE_DEPTH; very large
9206        // values still succeed.
9207        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9208        let req = make_tools_call(
9209            "memory_get_taxonomy",
9210            json!({"depth": 100_000_u64, "limit": 50_000_u64}),
9211        );
9212        let resp = invoke_handle_request(&conn, &req);
9213        assert!(resp.error.is_none());
9214    }
9215
9216    #[test]
9217    fn handle_archive_purge_no_filter_purges_all() {
9218        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9219        let req = make_tools_call("memory_archive_purge", json!({}));
9220        let resp = invoke_handle_request(&conn, &req);
9221        assert!(resp.error.is_none());
9222    }
9223
9224    #[test]
9225    fn handle_check_duplicate_invalid_title_rejected() {
9226        // No embedder → standard error; but when title is empty the
9227        // validate_title path errors before the embedder check.
9228        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9229        let req = make_tools_call(
9230            "memory_check_duplicate",
9231            json!({"title": "", "content": "anything"}),
9232        );
9233        let resp = invoke_handle_request(&conn, &req);
9234        let result = resp.result.unwrap();
9235        assert_eq!(result["isError"], true);
9236    }
9237
9238    #[test]
9239    fn handle_check_duplicate_invalid_namespace_rejected() {
9240        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9241        let req = make_tools_call(
9242            "memory_check_duplicate",
9243            json!({"title": "T", "content": "C", "namespace": "BAD NS"}),
9244        );
9245        let resp = invoke_handle_request(&conn, &req);
9246        let result = resp.result.unwrap();
9247        assert_eq!(result["isError"], true);
9248    }
9249
9250    #[test]
9251    fn handle_entity_register_with_explicit_agent_id() {
9252        // Drives the explicit_agent_id-Some branch (validates +
9253        // resolves).
9254        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9255        let req = make_tools_call(
9256            "memory_entity_register",
9257            json!({
9258                "canonical_name": "Org Alpha",
9259                "namespace": "w12-orgs",
9260                "aliases": ["alpha", "α"],
9261                "agent_id": "ai:bot",
9262            }),
9263        );
9264        let resp = invoke_handle_request(&conn, &req);
9265        assert!(resp.error.is_none());
9266    }
9267
9268    #[test]
9269    fn handle_entity_register_invalid_explicit_agent_id() {
9270        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9271        let req = make_tools_call(
9272            "memory_entity_register",
9273            json!({
9274                "canonical_name": "Org Beta",
9275                "namespace": "w12-orgs",
9276                "agent_id": "BAD AGENT !!",
9277            }),
9278        );
9279        let resp = invoke_handle_request(&conn, &req);
9280        let result = resp.result.unwrap();
9281        assert_eq!(result["isError"], true);
9282    }
9283
9284    #[test]
9285    fn handle_entity_get_by_alias_no_namespace() {
9286        // Drives the namespace=None branch (alias lookup across all ns).
9287        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9288        let req = make_tools_call("memory_entity_get_by_alias", json!({"alias": "any-alias"}));
9289        let resp = invoke_handle_request(&conn, &req);
9290        assert!(resp.error.is_none());
9291    }
9292
9293    #[test]
9294    fn handle_inbox_with_message_seeded() {
9295        // Notify alice, then read alice's inbox.
9296        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9297        let notify = make_tools_call(
9298            "memory_notify",
9299            json!({
9300                "target_agent_id": "alice-w12",
9301                "title": "ping",
9302                "payload": "are you there?",
9303                "tier": Tier::Short.as_str(),
9304            }),
9305        );
9306        let _ = invoke_handle_request(&conn, &notify);
9307        let inbox = make_tools_call(
9308            "memory_inbox",
9309            json!({"agent_id": "alice-w12", "limit": 10}),
9310        );
9311        let resp = invoke_handle_request(&conn, &inbox);
9312        assert!(resp.error.is_none());
9313        let text = resp.result.unwrap()["content"][0]["text"]
9314            .as_str()
9315            .unwrap()
9316            .to_string();
9317        let val: Value = serde_json::from_str(&text).unwrap();
9318        assert!(val["count"].as_u64().unwrap() >= 1);
9319        assert_eq!(val["agent_id"], "alice-w12");
9320    }
9321
9322    #[test]
9323    fn handle_consolidate_succeeds_when_source_was_standard() {
9324        // Even when one of the source memories is a namespace standard,
9325        // consolidate must succeed (the warning branch may or may not
9326        // fire depending on whether is_namespace_standard sees the row
9327        // pre- or post-deletion). This drives both the namespace-standard
9328        // check loop and the consolidate happy path together.
9329        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9330        let mem_a = Memory {
9331            id: uuid::Uuid::new_v4().to_string(),
9332            tier: Tier::Long,
9333            namespace: "w12-cons-warn".into(),
9334            title: "a".into(),
9335            content: "alpha".into(),
9336            tags: vec![],
9337            priority: 5,
9338            confidence: 1.0,
9339            source: "test".into(),
9340            access_count: 0,
9341            created_at: chrono::Utc::now().to_rfc3339(),
9342            updated_at: chrono::Utc::now().to_rfc3339(),
9343            last_accessed_at: None,
9344            expires_at: None,
9345            metadata: json!({}),
9346            reflection_depth: 0,
9347            memory_kind: crate::models::MemoryKind::Observation,
9348            entity_id: None,
9349            persona_version: None,
9350            citations: Vec::new(),
9351            source_uri: None,
9352            source_span: None,
9353            confidence_source: crate::models::ConfidenceSource::CallerProvided,
9354            confidence_signals: None,
9355            confidence_decayed_at: None,
9356            version: 1,
9357        };
9358        let mut mem_b = mem_a.clone();
9359        mem_b.id = uuid::Uuid::new_v4().to_string();
9360        mem_b.title = "b".into();
9361        mem_b.content = "beta".into();
9362        let id_a = db::insert(&conn, &mem_a).unwrap();
9363        let id_b = db::insert(&conn, &mem_b).unwrap();
9364        // Mark id_a as the standard for w12-cons-warn.
9365        db::set_namespace_standard(&conn, "w12-cons-warn", &id_a, None).unwrap();
9366
9367        let req = make_tools_call(
9368            "memory_consolidate",
9369            json!({
9370                "ids": [id_a, id_b],
9371                "title": "merged-warn",
9372                "summary": "merged summary",
9373                "namespace": "w12-cons-warn",
9374            }),
9375        );
9376        let resp = invoke_handle_request(&conn, &req);
9377        assert!(resp.error.is_none());
9378        let text = resp.result.unwrap()["content"][0]["text"]
9379            .as_str()
9380            .unwrap()
9381            .to_string();
9382        let val: Value = serde_json::from_str(&text).unwrap();
9383        assert!(val["id"].is_string());
9384        assert_eq!(val["consolidated"], 2);
9385    }
9386
9387    #[test]
9388    fn handle_update_clears_expires_with_empty_string() {
9389        // expires_at="" path is special-cased by db::update to clear
9390        // the column.
9391        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9392        let mem = Memory {
9393            id: uuid::Uuid::new_v4().to_string(),
9394            tier: Tier::Short,
9395            namespace: "w12-clear-exp".into(),
9396            title: "t".into(),
9397            content: "c".into(),
9398            tags: vec![],
9399            priority: 5,
9400            confidence: 1.0,
9401            source: "test".into(),
9402            access_count: 0,
9403            created_at: chrono::Utc::now().to_rfc3339(),
9404            updated_at: chrono::Utc::now().to_rfc3339(),
9405            last_accessed_at: None,
9406            expires_at: Some(chrono::Utc::now().to_rfc3339()),
9407            metadata: json!({}),
9408            reflection_depth: 0,
9409            memory_kind: crate::models::MemoryKind::Observation,
9410            entity_id: None,
9411            persona_version: None,
9412            citations: Vec::new(),
9413            source_uri: None,
9414            source_span: None,
9415            confidence_source: crate::models::ConfidenceSource::CallerProvided,
9416            confidence_signals: None,
9417            confidence_decayed_at: None,
9418            version: 1,
9419        };
9420        let id = db::insert(&conn, &mem).unwrap();
9421        let req = make_tools_call("memory_update", json!({"id": id, "expires_at": ""}));
9422        let resp = invoke_handle_request(&conn, &req);
9423        // empty "" is rejected by validate_expires_at_format; the
9424        // handler returns isError.
9425        let result = resp.result.unwrap();
9426        // The result shape depends on whether validate accepts "" — both
9427        // outcomes exercise distinct paths, so accept either.
9428        assert!(result.is_object());
9429    }
9430
9431    #[test]
9432    fn handle_update_change_namespace() {
9433        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9434        let mem = Memory {
9435            id: uuid::Uuid::new_v4().to_string(),
9436            tier: Tier::Mid,
9437            namespace: "w12-update-ns".into(),
9438            title: "t".into(),
9439            content: "c".into(),
9440            tags: vec![],
9441            priority: 5,
9442            confidence: 1.0,
9443            source: "test".into(),
9444            access_count: 0,
9445            created_at: chrono::Utc::now().to_rfc3339(),
9446            updated_at: chrono::Utc::now().to_rfc3339(),
9447            last_accessed_at: None,
9448            expires_at: None,
9449            metadata: json!({}),
9450            reflection_depth: 0,
9451            memory_kind: crate::models::MemoryKind::Observation,
9452            entity_id: None,
9453            persona_version: None,
9454            citations: Vec::new(),
9455            source_uri: None,
9456            source_span: None,
9457            confidence_source: crate::models::ConfidenceSource::CallerProvided,
9458            confidence_signals: None,
9459            confidence_decayed_at: None,
9460            version: 1,
9461        };
9462        let id = db::insert(&conn, &mem).unwrap();
9463        let req = make_tools_call(
9464            "memory_update",
9465            json!({
9466                "id": id,
9467                "namespace": "w12-update-ns-new",
9468                "tags": ["a", "b"],
9469                "title": "new-title",
9470                "content": "new-content",
9471                "tier": Tier::Long.as_str(),
9472                "priority": 8_i64,
9473                "confidence": 0.9_f64,
9474            }),
9475        );
9476        let resp = invoke_handle_request(&conn, &req);
9477        assert!(resp.error.is_none());
9478    }
9479
9480    #[test]
9481    fn handle_delete_with_prefix_id_lookup() {
9482        // db::get_by_prefix is consulted when exact ID lookup misses.
9483        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9484        let mem = Memory {
9485            id: uuid::Uuid::new_v4().to_string(),
9486            tier: Tier::Mid,
9487            namespace: "w12-delete-prefix".into(),
9488            title: "t".into(),
9489            content: "c".into(),
9490            tags: vec![],
9491            priority: 5,
9492            confidence: 1.0,
9493            source: "test".into(),
9494            access_count: 0,
9495            created_at: chrono::Utc::now().to_rfc3339(),
9496            updated_at: chrono::Utc::now().to_rfc3339(),
9497            last_accessed_at: None,
9498            expires_at: None,
9499            metadata: json!({}),
9500            reflection_depth: 0,
9501            memory_kind: crate::models::MemoryKind::Observation,
9502            entity_id: None,
9503            persona_version: None,
9504            citations: Vec::new(),
9505            source_uri: None,
9506            source_span: None,
9507            confidence_source: crate::models::ConfidenceSource::CallerProvided,
9508            confidence_signals: None,
9509            confidence_decayed_at: None,
9510            version: 1,
9511        };
9512        let id = db::insert(&conn, &mem).unwrap();
9513        let req = make_tools_call("memory_delete", json!({"id": id}));
9514        let resp = invoke_handle_request(&conn, &req);
9515        assert!(resp.error.is_none());
9516        let text = resp.result.unwrap()["content"][0]["text"]
9517            .as_str()
9518            .unwrap()
9519            .to_string();
9520        let val: Value = serde_json::from_str(&text).unwrap();
9521        assert_eq!(val["deleted"], true);
9522    }
9523
9524    #[test]
9525    fn handle_unsubscribe_after_subscribe_removes_row() {
9526        // Drives the removed=1 branch.
9527        // R3-S1.HMAC (2026-05-13): supply secret.
9528        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9529        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
9530        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
9531        let sub = make_tools_call(
9532            "memory_subscribe",
9533            json!({"url": "https://example.com/hook2", "secret": "mcp-sub-test-secret"}),
9534        );
9535        let sub_resp = invoke_handle_request(&conn, &sub);
9536        let sub_text = sub_resp.result.unwrap()["content"][0]["text"]
9537            .as_str()
9538            .unwrap()
9539            .to_string();
9540        let sub_val: Value = serde_json::from_str(&sub_text).unwrap();
9541        let id = sub_val["id"].as_str().unwrap().to_string();
9542
9543        let unsub = make_tools_call("memory_unsubscribe", json!({"id": id}));
9544        let unsub_resp = invoke_handle_request(&conn, &unsub);
9545        assert!(unsub_resp.error.is_none());
9546        let unsub_text = unsub_resp.result.unwrap()["content"][0]["text"]
9547            .as_str()
9548            .unwrap()
9549            .to_string();
9550        let unsub_val: Value = serde_json::from_str(&unsub_text).unwrap();
9551        assert!(
9552            unsub_val["removed"] == json!(true) || unsub_val["removed"] == json!(1),
9553            "unexpected removed value: {:?}",
9554            unsub_val["removed"]
9555        );
9556    }
9557
9558    #[test]
9559    fn handle_list_subscriptions_after_subscribe_returns_one() {
9560        // R3-S1.HMAC (2026-05-13): supply secret.
9561        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9562        let resolved = crate::identity::resolve_agent_id(None, None).unwrap();
9563        db::register_agent(&conn, &resolved, "human", &[]).unwrap();
9564        let sub = make_tools_call(
9565            "memory_subscribe",
9566            json!({"url": "https://example.com/listed", "secret": "mcp-sub-test-secret"}),
9567        );
9568        let _ = invoke_handle_request(&conn, &sub);
9569        let req = make_tools_call("memory_list_subscriptions", json!({}));
9570        let resp = invoke_handle_request(&conn, &req);
9571        assert!(resp.error.is_none());
9572        let text = resp.result.unwrap()["content"][0]["text"]
9573            .as_str()
9574            .unwrap()
9575            .to_string();
9576        let val: Value = serde_json::from_str(&text).unwrap();
9577        // subscriptions field holds the array; the count field may be at
9578        // top level — accept either key.
9579        assert!(val.get("subscriptions").is_some() || val.get("count").is_some() || val.is_array());
9580    }
9581
9582    #[test]
9583    fn test_inject_namespace_standard_dedup_keeps_originals_order() {
9584        // When the standard is one of the recall hits, dedup removes it
9585        // but preserves the relative order of remaining results.
9586        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9587        let std_id = seed_namespace_standard(&conn, "w12-order", "S");
9588        let mems = vec![
9589            json!({"id": "first", "title": "f"}),
9590            json!({"id": std_id, "title": "S"}),
9591            json!({"id": "third", "title": "t"}),
9592        ];
9593        let mut resp = make_recall_response(mems);
9594        super::inject_namespace_standard(&conn, Some("w12-order"), &mut resp);
9595        let memories = resp["memories"].as_array().unwrap();
9596        assert_eq!(memories.len(), 2);
9597        assert_eq!(memories[0]["id"], "first");
9598        assert_eq!(memories[1]["id"], "third");
9599    }
9600
9601    // =====================================================================
9602    // v0.7.0 I4 — `memory_replay` tool. Tests cover the four documented
9603    // shapes: empty / single / multiple / truncation (verbose=false vs
9604    // verbose=true). Each test seeds the I1 transcript table + I2 join
9605    // table directly via the public helpers, then dispatches a real
9606    // `tools/call` request through the MCP envelope so the response goes
9607    // through the JSON wrapping layer the daemon emits in production.
9608    // =====================================================================
9609
9610    /// I4 test helper — INSERT a stub `memories` row so the I2 join
9611    /// table FK is satisfied. Mirrors the same helper in
9612    /// `transcripts::tests::insert_test_memory` so the I4 test suite
9613    /// doesn't need to import the test-only path.
9614    fn i4_insert_test_memory(conn: &rusqlite::Connection, id: &str) {
9615        let now = chrono::Utc::now().to_rfc3339();
9616        // v0.7.0 fix campaign R1-M2 — substrate CHECK trigger enforces
9617        // tier ∈ {short, mid, long}. The pre-fix label "short_term"
9618        // pre-dated the closed-set enum and was silently accepted.
9619        //
9620        // v0.7.0 #1075 (SR-1 #1, HIGH) — seed `scope: "public"` so
9621        // these fixtures pass the visibility gate added to
9622        // `memory_replay` regardless of which agent_id the caller
9623        // resolves to (test caller resolution depends on env / PID).
9624        // Real-world rows owned by a specific agent rely on the
9625        // owner check; the test fixtures use the public-scope branch
9626        // so they survive the gate without taking a dependency on a
9627        // stable caller identity.
9628        conn.execute(
9629            "INSERT INTO memories (
9630                id, tier, namespace, title, content, created_at, updated_at, metadata
9631             ) VALUES (?1, 'short', 'team/eng', ?2, 'body', ?3, ?3, '{\"scope\":\"public\"}')",
9632            rusqlite::params![id, format!("title-{id}"), now],
9633        )
9634        .unwrap();
9635    }
9636
9637    /// I4 test helper — pull the inner JSON out of an MCP wire response
9638    /// (which wraps the handler's payload as `result.content[0].text`).
9639    fn i4_decode_response_payload(resp: &RpcResponse) -> Value {
9640        let text = resp
9641            .result
9642            .as_ref()
9643            .expect("expected ok response")
9644            .get("content")
9645            .and_then(|c| c.get(0))
9646            .and_then(|c| c.get("text"))
9647            .and_then(Value::as_str)
9648            .expect("response wrapper must have content[0].text");
9649        serde_json::from_str(text).expect("response payload must be JSON")
9650    }
9651
9652    /// I4-EMPTY — a memory with no linked transcripts returns an empty
9653    /// `transcripts` array (and `count: 0`). Documents the lower
9654    /// boundary of the replay surface so an LLM that calls
9655    /// `memory_replay` on a memory with no provenance gets an honest
9656    /// "nothing to replay" response instead of an error.
9657    #[test]
9658    fn i4_replay_no_links_returns_empty_array() {
9659        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9660        i4_insert_test_memory(&conn, "mem-empty");
9661
9662        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-empty"}));
9663        let resp = invoke_handle_request(&conn, &req);
9664        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9665
9666        let payload = i4_decode_response_payload(&resp);
9667        assert_eq!(payload["memory_id"], "mem-empty");
9668        assert_eq!(payload["count"], 0);
9669        let transcripts = payload["transcripts"].as_array().unwrap();
9670        assert!(transcripts.is_empty());
9671    }
9672
9673    /// I4-SINGLE — one linked transcript flows through with
9674    /// decompressed content and full span metadata
9675    /// (`compressed_size`, `original_size`, `span_start`, `span_end`,
9676    /// `created_at`).
9677    #[test]
9678    fn i4_replay_single_transcript_returns_content_and_metadata() {
9679        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9680        i4_insert_test_memory(&conn, "mem-single");
9681        let body = "the canonical conversation that produced this memory";
9682        let t = crate::transcripts::store(&conn, "team/eng", body, None).unwrap();
9683        crate::transcripts::link_transcript(&conn, "mem-single", &t.id, Some(2), Some(20)).unwrap();
9684
9685        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-single"}));
9686        let resp = invoke_handle_request(&conn, &req);
9687        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9688
9689        let payload = i4_decode_response_payload(&resp);
9690        assert_eq!(payload["memory_id"], "mem-single");
9691        assert_eq!(payload["count"], 1);
9692        let transcripts = payload["transcripts"].as_array().unwrap();
9693        assert_eq!(transcripts.len(), 1);
9694        let entry = &transcripts[0];
9695        assert_eq!(entry["id"], t.id);
9696        assert_eq!(entry["content"], body);
9697        assert_eq!(entry["span_start"], 2);
9698        assert_eq!(entry["span_end"], 20);
9699        assert_eq!(entry["original_size"].as_i64().unwrap(), body.len() as i64);
9700        // compressed_size is whatever zstd-3 emits; assert it's positive
9701        // so a future encoder swap that emits zero-byte blobs gets caught.
9702        assert!(entry["compressed_size"].as_i64().unwrap() > 0);
9703        assert!(entry["created_at"].is_string());
9704        // No truncation flag on a sub-threshold transcript.
9705        assert!(entry.get("truncated").is_none());
9706    }
9707
9708    /// I4-MULTI — two linked transcripts come back in chronological
9709    /// order (oldest first) regardless of the order they were linked.
9710    /// Pins the chronological-replay contract so a future refactor
9711    /// can't silently fall back to insertion order.
9712    #[test]
9713    fn i4_replay_multiple_transcripts_chronological_order() {
9714        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9715        i4_insert_test_memory(&conn, "mem-multi");
9716
9717        // Insert the OLDER transcript first, then backdate it so its
9718        // `created_at` is unambiguously earlier than the SECOND insert.
9719        let older = crate::transcripts::store(&conn, "team/eng", "older body", None).unwrap();
9720        let backdate =
9721            (chrono::Utc::now() - chrono::Duration::seconds(crate::SECS_PER_HOUR)).to_rfc3339();
9722        conn.execute(
9723            "UPDATE memory_transcripts SET created_at = ?1 WHERE id = ?2",
9724            rusqlite::params![backdate, older.id],
9725        )
9726        .unwrap();
9727
9728        let newer = crate::transcripts::store(&conn, "team/eng", "newer body", None).unwrap();
9729
9730        // Link the NEWER one first so the I2 helper's
9731        // ORDER-BY-transcript-id natural ordering doesn't already
9732        // happen to give us the right result by accident.
9733        crate::transcripts::link_transcript(&conn, "mem-multi", &newer.id, None, None).unwrap();
9734        crate::transcripts::link_transcript(&conn, "mem-multi", &older.id, None, None).unwrap();
9735
9736        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-multi"}));
9737        let resp = invoke_handle_request(&conn, &req);
9738        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9739
9740        let payload = i4_decode_response_payload(&resp);
9741        let transcripts = payload["transcripts"].as_array().unwrap();
9742        assert_eq!(transcripts.len(), 2);
9743        // Older first, newer second.
9744        assert_eq!(transcripts[0]["id"], older.id);
9745        assert_eq!(transcripts[0]["content"], "older body");
9746        assert_eq!(transcripts[1]["id"], newer.id);
9747        assert_eq!(transcripts[1]["content"], "newer body");
9748    }
9749
9750    /// I4-TRUNCATE-DEFAULT — a > 100 KB transcript with the default
9751    /// `verbose=false` returns the metadata block + `truncated: true`
9752    /// and OMITS the content field, forcing operators to opt into the
9753    /// large-dump path.
9754    #[test]
9755    fn i4_replay_large_transcript_truncates_when_verbose_false() {
9756        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9757        i4_insert_test_memory(&conn, "mem-large");
9758
9759        // 200 KB body — well past the 100 KB threshold.
9760        let body: String = "abcdefghij".repeat(20_000); // 200_000 bytes
9761        assert!(body.len() > REPLAY_VERBOSE_THRESHOLD_BYTES as usize);
9762        let t = crate::transcripts::store(&conn, "team/eng", &body, None).unwrap();
9763        crate::transcripts::link_transcript(&conn, "mem-large", &t.id, None, None).unwrap();
9764
9765        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-large"}));
9766        let resp = invoke_handle_request(&conn, &req);
9767        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9768
9769        let payload = i4_decode_response_payload(&resp);
9770        let transcripts = payload["transcripts"].as_array().unwrap();
9771        assert_eq!(transcripts.len(), 1);
9772        let entry = &transcripts[0];
9773        assert_eq!(entry["truncated"], true);
9774        assert!(
9775            entry.get("content").is_none(),
9776            "content must be OMITTED when truncated; got: {entry}"
9777        );
9778        // Metadata is still present so the caller can decide whether
9779        // to re-issue with verbose=true.
9780        assert_eq!(entry["original_size"].as_i64().unwrap(), body.len() as i64);
9781        assert!(entry["compressed_size"].as_i64().unwrap() > 0);
9782    }
9783
9784    /// I4-TRUNCATE-VERBOSE — the same > 100 KB transcript with
9785    /// `verbose=true` returns the full content (no `truncated` flag).
9786    /// Pins the opt-in-large-dump escape hatch.
9787    #[test]
9788    fn i4_replay_large_transcript_returns_content_when_verbose_true() {
9789        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9790        i4_insert_test_memory(&conn, "mem-large-verbose");
9791
9792        let body: String = "abcdefghij".repeat(20_000);
9793        let t = crate::transcripts::store(&conn, "team/eng", &body, None).unwrap();
9794        crate::transcripts::link_transcript(&conn, "mem-large-verbose", &t.id, None, None).unwrap();
9795
9796        let req = make_tools_call(
9797            "memory_replay",
9798            json!({"memory_id": "mem-large-verbose", "verbose": true}),
9799        );
9800        let resp = invoke_handle_request(&conn, &req);
9801        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9802
9803        let payload = i4_decode_response_payload(&resp);
9804        let transcripts = payload["transcripts"].as_array().unwrap();
9805        assert_eq!(transcripts.len(), 1);
9806        let entry = &transcripts[0];
9807        assert!(
9808            entry.get("truncated").is_none(),
9809            "verbose=true must NOT set truncated"
9810        );
9811        assert_eq!(entry["content"].as_str().unwrap(), body);
9812    }
9813
9814    /// I4-MISSING-ARG — omitting the required `memory_id` argument
9815    /// returns a handler-level error (wrapped as an isError result),
9816    /// not a JSON-RPC -32601. Same shape as the rest of the smoke
9817    /// matrix for required-arg validation.
9818    #[test]
9819    fn i4_replay_missing_memory_id_yields_handler_error() {
9820        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9821        let req = make_tools_call("memory_replay", json!({}));
9822        let resp = invoke_handle_request(&conn, &req);
9823        // Handler errors come back as ok_response with isError=true.
9824        // The RPC error field stays None so a downstream client can
9825        // distinguish "method not found" from "the method ran and
9826        // failed".
9827        assert!(
9828            resp.error.is_none(),
9829            "expected handler-level error, not RPC error"
9830        );
9831        let result = resp.result.expect("must surface a result envelope");
9832        assert_eq!(result["isError"], true);
9833    }
9834
9835    /// I4-DANGLING-LINK — a link row whose target transcript was
9836    /// pruned (I3) is silently dropped from the replay output.
9837    /// Documents the contract so a future refactor that surfaces
9838    /// dangling ids to the caller fails this test loudly.
9839    #[test]
9840    fn i4_replay_skips_dangling_transcript_link() {
9841        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9842        i4_insert_test_memory(&conn, "mem-dangling");
9843
9844        let live = crate::transcripts::store(&conn, "team/eng", "live body", None).unwrap();
9845        let pruned = crate::transcripts::store(&conn, "team/eng", "pruned body", None).unwrap();
9846        crate::transcripts::link_transcript(&conn, "mem-dangling", &live.id, None, None).unwrap();
9847        crate::transcripts::link_transcript(&conn, "mem-dangling", &pruned.id, None, None).unwrap();
9848
9849        // Sneak past the I2 ON DELETE CASCADE by disabling foreign keys
9850        // for this single DELETE — production won't get here (cascade
9851        // would clean up the link), but the handler must still be
9852        // robust if the substrate ever surfaces a dangling row.
9853        conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
9854        conn.execute(
9855            "DELETE FROM memory_transcripts WHERE id = ?1",
9856            rusqlite::params![pruned.id],
9857        )
9858        .unwrap();
9859        conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
9860
9861        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-dangling"}));
9862        let resp = invoke_handle_request(&conn, &req);
9863        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9864
9865        let payload = i4_decode_response_payload(&resp);
9866        let transcripts = payload["transcripts"].as_array().unwrap();
9867        assert_eq!(
9868            transcripts.len(),
9869            1,
9870            "only the live transcript should appear; pruned id is silently dropped"
9871        );
9872        assert_eq!(transcripts[0]["id"], live.id);
9873    }
9874
9875    // =====================================================================
9876    // L0.7-3 Tier B coverage closure — `memory_reflect` MCP handler.
9877    //
9878    // The substrate-level `db::reflect` primitive is exhaustively pinned by
9879    // `tests/recursive_learning_task4_memory_reflect.rs` and the approval
9880    // gate by `tests/approval_reflect.rs`. This block exercises the
9881    // *handler* surface: argument parsing, agent_id resolution, embedder
9882    // fan-out (best-effort, not fatal), `ReflectError` → MCP error mapping,
9883    // and the L1-8 pre-substrate `require_approval_above_depth` gate.
9884    //
9885    // Coverage targets `src/mcp/tools/reflect.rs` (baseline 0%).
9886    // =====================================================================
9887
9888    /// Seed a source memory at the given namespace + reflection_depth.
9889    /// Returns the inserted id.
9890    fn reflect_test_seed_source(
9891        conn: &rusqlite::Connection,
9892        namespace: &str,
9893        title: &str,
9894        depth: i32,
9895    ) -> String {
9896        let now = chrono::Utc::now().to_rfc3339();
9897        let mem = Memory {
9898            id: uuid::Uuid::new_v4().to_string(),
9899            tier: Tier::Mid,
9900            namespace: namespace.to_string(),
9901            title: title.to_string(),
9902            content: format!("seed body for {title}"),
9903            tags: vec!["reflect-test".to_string()],
9904            priority: 5,
9905            confidence: 1.0,
9906            source: "test".to_string(),
9907            access_count: 0,
9908            created_at: now.clone(),
9909            updated_at: now,
9910            last_accessed_at: None,
9911            expires_at: None,
9912            metadata: json!({"agent_id": "test-agent-reflect"}),
9913            reflection_depth: depth,
9914            memory_kind: crate::models::MemoryKind::Observation,
9915            entity_id: None,
9916            persona_version: None,
9917            citations: Vec::new(),
9918            source_uri: None,
9919            source_span: None,
9920            confidence_source: crate::models::ConfidenceSource::CallerProvided,
9921            confidence_signals: None,
9922            confidence_decayed_at: None,
9923            version: 1,
9924        };
9925        db::insert(conn, &mem).unwrap()
9926    }
9927
9928    /// Persist a namespace standard with the supplied raw governance JSON.
9929    /// Mirrors `tests/approval_reflect.rs::seed_governance_json` so we can
9930    /// drive `require_approval_above_depth` and `max_reflection_depth`
9931    /// from within the in-module test set.
9932    fn reflect_test_seed_governance(
9933        conn: &rusqlite::Connection,
9934        namespace: &str,
9935        governance: Value,
9936    ) {
9937        let now = chrono::Utc::now().to_rfc3339();
9938        let metadata = json!({
9939            "agent_id": "test-agent-reflect",
9940            "governance": governance,
9941        });
9942        let standard = Memory {
9943            id: uuid::Uuid::new_v4().to_string(),
9944            tier: Tier::Long,
9945            namespace: format!("_standards-{namespace}"),
9946            title: format!("standard for {namespace}"),
9947            content: "reflect-test policy".to_string(),
9948            tags: vec![],
9949            priority: 9,
9950            confidence: 1.0,
9951            source: "test".to_string(),
9952            access_count: 0,
9953            created_at: now.clone(),
9954            updated_at: now,
9955            last_accessed_at: None,
9956            expires_at: None,
9957            metadata,
9958            reflection_depth: 0,
9959            memory_kind: crate::models::MemoryKind::Observation,
9960            entity_id: None,
9961            persona_version: None,
9962            citations: Vec::new(),
9963            source_uri: None,
9964            source_span: None,
9965            confidence_source: crate::models::ConfidenceSource::CallerProvided,
9966            confidence_signals: None,
9967            confidence_decayed_at: None,
9968            version: 1,
9969        };
9970        let std_id = db::insert(conn, &standard).unwrap();
9971        db::set_namespace_standard(conn, namespace, &std_id, None).unwrap();
9972    }
9973
9974    // ─── A. Happy path ────────────────────────────────────────────────
9975
9976    #[test]
9977    fn handle_reflect_happy_path_single_source_returns_envelope() {
9978        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
9979        let src = reflect_test_seed_source(&conn, "team/reflect-a", "src-1", 0);
9980        let req = make_tools_call(
9981            "memory_reflect",
9982            json!({
9983                "source_ids": [src],
9984                "title": "pattern: alpha",
9985                "content": "synthesised reflection content",
9986                "namespace": "team/reflect-a",
9987                "tier": Tier::Mid.as_str(),
9988                "priority": 7,
9989                "confidence": 0.9,
9990                "tags": ["reflection", "alpha"],
9991            }),
9992        );
9993        let resp = invoke_handle_request(&conn, &req);
9994        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
9995        let payload = i4_decode_response_payload(&resp);
9996        assert!(payload["id"].is_string());
9997        assert_eq!(payload["reflection_depth"], 1);
9998        assert_eq!(payload["namespace"], "team/reflect-a");
9999        let reflects_on = payload["reflects_on"].as_array().unwrap();
10000        assert_eq!(reflects_on.len(), 1);
10001    }
10002
10003    #[test]
10004    fn handle_reflect_happy_path_metadata_object_is_accepted() {
10005        // Passing a `metadata` object exercises the `is_object()` branch
10006        // (vs. the default empty-object fallback). The substrate stamps
10007        // its own metadata fields; the input metadata is preserved.
10008        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10009        let src = reflect_test_seed_source(&conn, "team/reflect-meta", "src-1", 0);
10010        let req = make_tools_call(
10011            "memory_reflect",
10012            json!({
10013                "source_ids": [src],
10014                "title": "with metadata",
10015                "content": "body",
10016                "metadata": {"custom_field": "abc"},
10017            }),
10018        );
10019        let resp = invoke_handle_request(&conn, &req);
10020        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10021        let payload = i4_decode_response_payload(&resp);
10022        assert!(payload["id"].is_string());
10023    }
10024
10025    #[test]
10026    fn handle_reflect_omitted_namespace_defaults_to_first_source_namespace() {
10027        // Exercises the `namespace = None` branch + the substrate default
10028        // (first source's namespace). The approval-gate prefetch ALSO
10029        // dereferences this path via `db::get(conn, id)`.
10030        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10031        let src = reflect_test_seed_source(&conn, "team/reflect-defns", "src-1", 0);
10032        let req = make_tools_call(
10033            "memory_reflect",
10034            json!({
10035                "source_ids": [src],
10036                "title": "defaulted namespace",
10037                "content": "body",
10038                // namespace intentionally omitted
10039            }),
10040        );
10041        let resp = invoke_handle_request(&conn, &req);
10042        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10043        let payload = i4_decode_response_payload(&resp);
10044        assert_eq!(payload["namespace"], "team/reflect-defns");
10045    }
10046
10047    #[test]
10048    fn handle_reflect_explicit_agent_id_is_honoured() {
10049        // Exercises the `agent_id` precedence chain: explicit field wins.
10050        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10051        let src = reflect_test_seed_source(&conn, "team/reflect-aid", "src-1", 0);
10052        let req = make_tools_call(
10053            "memory_reflect",
10054            json!({
10055                "source_ids": [src],
10056                "title": "agent override",
10057                "content": "body",
10058                "namespace": "team/reflect-aid",
10059                "agent_id": "ai:explicit-agent",
10060            }),
10061        );
10062        let resp = invoke_handle_request(&conn, &req);
10063        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10064        let payload = i4_decode_response_payload(&resp);
10065        let new_id = payload["id"].as_str().unwrap();
10066        let stored = db::get(&conn, new_id).unwrap().unwrap();
10067        assert_eq!(
10068            stored.metadata["agent_id"].as_str(),
10069            Some("ai:explicit-agent")
10070        );
10071    }
10072
10073    #[test]
10074    fn handle_reflect_agent_id_from_metadata_blob_is_honoured() {
10075        // The handler also extracts agent_id from `metadata.agent_id`
10076        // when the top-level field is absent — `.or_else(...)` arm.
10077        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10078        let src = reflect_test_seed_source(&conn, "team/reflect-mid", "src-1", 0);
10079        let req = make_tools_call(
10080            "memory_reflect",
10081            json!({
10082                "source_ids": [src],
10083                "title": "agent from metadata",
10084                "content": "body",
10085                "namespace": "team/reflect-mid",
10086                "metadata": {"agent_id": "ai:meta-agent"},
10087            }),
10088        );
10089        let resp = invoke_handle_request(&conn, &req);
10090        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10091        let payload = i4_decode_response_payload(&resp);
10092        let new_id = payload["id"].as_str().unwrap();
10093        let stored = db::get(&conn, new_id).unwrap().unwrap();
10094        assert_eq!(stored.metadata["agent_id"].as_str(), Some("ai:meta-agent"));
10095    }
10096
10097    // ─── B. Input validation errors ──────────────────────────────────
10098
10099    #[test]
10100    fn handle_reflect_missing_source_ids_returns_error() {
10101        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10102        let req = make_tools_call("memory_reflect", json!({"title": "t", "content": "c"}));
10103        let resp = invoke_handle_request(&conn, &req);
10104        let result = resp.result.unwrap();
10105        assert_eq!(result["isError"], true);
10106        let text = result["content"][0]["text"].as_str().unwrap();
10107        assert!(text.contains("source_ids"), "got {text}");
10108    }
10109
10110    #[test]
10111    fn handle_reflect_empty_source_ids_returns_error() {
10112        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10113        let req = make_tools_call(
10114            "memory_reflect",
10115            json!({"source_ids": [], "title": "t", "content": "c"}),
10116        );
10117        let resp = invoke_handle_request(&conn, &req);
10118        let result = resp.result.unwrap();
10119        assert_eq!(result["isError"], true);
10120        let text = result["content"][0]["text"].as_str().unwrap();
10121        assert!(text.contains("cannot be empty"), "got {text}");
10122    }
10123
10124    #[test]
10125    fn handle_reflect_non_string_source_id_returns_error() {
10126        // source_ids[i] must be a string — number / null / object should
10127        // surface a typed error naming the index.
10128        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10129        let req = make_tools_call(
10130            "memory_reflect",
10131            json!({
10132                "source_ids": ["valid-id", 42, "another"],
10133                "title": "t",
10134                "content": "c",
10135            }),
10136        );
10137        let resp = invoke_handle_request(&conn, &req);
10138        let result = resp.result.unwrap();
10139        assert_eq!(result["isError"], true);
10140        let text = result["content"][0]["text"].as_str().unwrap();
10141        assert!(text.contains("source_ids[1]"), "got {text}");
10142    }
10143
10144    #[test]
10145    fn handle_reflect_missing_title_returns_error() {
10146        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10147        let src = reflect_test_seed_source(&conn, "team/r-mt", "src", 0);
10148        let req = make_tools_call(
10149            "memory_reflect",
10150            json!({"source_ids": [src], "content": "c"}),
10151        );
10152        let resp = invoke_handle_request(&conn, &req);
10153        let result = resp.result.unwrap();
10154        assert_eq!(result["isError"], true);
10155        let text = result["content"][0]["text"].as_str().unwrap();
10156        assert!(text.contains("title"), "got {text}");
10157    }
10158
10159    #[test]
10160    fn handle_reflect_missing_content_returns_error() {
10161        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10162        let src = reflect_test_seed_source(&conn, "team/r-mc", "src", 0);
10163        let req = make_tools_call("memory_reflect", json!({"source_ids": [src], "title": "t"}));
10164        let resp = invoke_handle_request(&conn, &req);
10165        let result = resp.result.unwrap();
10166        assert_eq!(result["isError"], true);
10167        let text = result["content"][0]["text"].as_str().unwrap();
10168        assert!(text.contains("content"), "got {text}");
10169    }
10170
10171    #[test]
10172    fn handle_reflect_invalid_tier_returns_error() {
10173        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10174        let src = reflect_test_seed_source(&conn, "team/r-tier", "src", 0);
10175        let req = make_tools_call(
10176            "memory_reflect",
10177            json!({
10178                "source_ids": [src],
10179                "title": "t",
10180                "content": "c",
10181                "tier": "ephemeral",
10182            }),
10183        );
10184        let resp = invoke_handle_request(&conn, &req);
10185        let result = resp.result.unwrap();
10186        assert_eq!(result["isError"], true);
10187        let text = result["content"][0]["text"].as_str().unwrap();
10188        assert!(text.contains("invalid tier"), "got {text}");
10189    }
10190
10191    // ─── D. State-dependent errors (substrate ReflectError mapping) ──
10192
10193    #[test]
10194    fn handle_reflect_source_not_found_returns_error_string() {
10195        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10196        let req = make_tools_call(
10197            "memory_reflect",
10198            json!({
10199                "source_ids": ["nonexistent-id"],
10200                "title": "t",
10201                "content": "c",
10202                "namespace": "team/r-nf",
10203            }),
10204        );
10205        let resp = invoke_handle_request(&conn, &req);
10206        let result = resp.result.unwrap();
10207        assert_eq!(result["isError"], true);
10208        let text = result["content"][0]["text"].as_str().unwrap();
10209        assert!(text.contains("source memory not found"), "got {text}",);
10210        assert!(text.contains("nonexistent-id"), "got {text}");
10211    }
10212
10213    #[test]
10214    fn handle_reflect_depth_exceeded_returns_typed_error() {
10215        // Configure namespace cap = 1, then attempt depth-2 reflection.
10216        // Hits the `ReflectError::DepthExceeded` arm; substrate refusal,
10217        // NOT the L1-8 pre-substrate approval gate (no
10218        // require_approval_above_depth in this governance blob).
10219        //
10220        // GovernancePolicy requires `write` for deserialization (the
10221        // other fields have serde defaults). Supplying a minimal valid
10222        // shape here exercises the substrate cap path; without `write`
10223        // the resolver returns `None` and the cap falls back to the
10224        // compiled-in default of 3, which would allow the attempted
10225        // depth-2 write through.
10226        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10227        reflect_test_seed_governance(
10228            &conn,
10229            "team/r-depth",
10230            json!({
10231                "write": "any",
10232                "max_reflection_depth": 1,
10233            }),
10234        );
10235        let s1 = reflect_test_seed_source(&conn, "team/r-depth", "src-1", 1);
10236        let req = make_tools_call(
10237            "memory_reflect",
10238            json!({
10239                "source_ids": [s1],
10240                "title": "would be depth 2",
10241                "content": "body",
10242                "namespace": "team/r-depth",
10243            }),
10244        );
10245        let resp = invoke_handle_request(&conn, &req);
10246        let result = resp.result.unwrap();
10247        assert_eq!(result["isError"], true);
10248        let text = result["content"][0]["text"].as_str().unwrap();
10249        assert!(
10250            text.contains("REFLECTION_DEPTH_EXCEEDED"),
10251            "expected typed error prefix; got {text}",
10252        );
10253        assert!(text.contains("depth 2"), "got {text}");
10254        assert!(text.contains("max_reflection_depth 1"), "got {text}",);
10255        assert!(text.contains("namespace='team/r-depth'"), "got {text}",);
10256    }
10257
10258    // ─── C. Authorization / approval-gate path (L1-8) ────────────────
10259
10260    #[test]
10261    fn handle_reflect_approval_gate_queues_pending_above_threshold() {
10262        // Configure namespace with `require_approval_above_depth = 1`.
10263        // A reflection that would land at depth 2 must be intercepted
10264        // BEFORE the substrate write, returning a `status: "pending"`
10265        // envelope with a fresh pending_id.
10266        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10267        reflect_test_seed_governance(
10268            &conn,
10269            "team/r-approve",
10270            json!({"require_approval_above_depth": 1}),
10271        );
10272        let s1 = reflect_test_seed_source(&conn, "team/r-approve", "src-1", 1);
10273        let req = make_tools_call(
10274            "memory_reflect",
10275            json!({
10276                "source_ids": [s1],
10277                "title": "would need approval",
10278                "content": "body",
10279                "namespace": "team/r-approve",
10280            }),
10281        );
10282        let resp = invoke_handle_request(&conn, &req);
10283        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10284        let payload = i4_decode_response_payload(&resp);
10285        assert_eq!(payload["status"], "pending");
10286        assert!(payload["pending_id"].is_string());
10287        assert_eq!(payload["action"], "reflect");
10288        assert_eq!(payload["namespace"], "team/r-approve");
10289        assert_eq!(payload["proposed_depth"], 2);
10290        assert_eq!(payload["require_approval_above_depth"], 1);
10291    }
10292
10293    #[test]
10294    fn handle_reflect_approval_gate_under_threshold_proceeds() {
10295        // Threshold = 5, depth-1 reflection → substrate write proceeds,
10296        // no pending row queued. Confirms the under-threshold branch.
10297        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10298        reflect_test_seed_governance(
10299            &conn,
10300            "team/r-under",
10301            json!({"require_approval_above_depth": 5}),
10302        );
10303        let s1 = reflect_test_seed_source(&conn, "team/r-under", "src-1", 0);
10304        let req = make_tools_call(
10305            "memory_reflect",
10306            json!({
10307                "source_ids": [s1],
10308                "title": "under threshold",
10309                "content": "body",
10310                "namespace": "team/r-under",
10311            }),
10312        );
10313        let resp = invoke_handle_request(&conn, &req);
10314        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10315        let payload = i4_decode_response_payload(&resp);
10316        // Must NOT be a pending envelope.
10317        assert!(payload["status"].as_str() != Some("pending"));
10318        assert!(payload["id"].is_string());
10319        assert_eq!(payload["reflection_depth"], 1);
10320    }
10321
10322    // =====================================================================
10323    // L0.7-3 Tier B coverage closure — `memory_quota_status` MCP handler.
10324    //
10325    // The integration test in `tests/k8_quota_status_tool.rs` calls
10326    // `handle_quota_status` directly. Those tests exist as a separate test
10327    // binary, so `cargo llvm-cov --lib` (the L0.7 baseline) does NOT report
10328    // their coverage against `src/mcp/tools/quota_status.rs`. These in-lib
10329    // tests drive the same surface through the MCP dispatch path so the
10330    // covered-line count reflects the actual production surface.
10331    //
10332    // Coverage targets `src/mcp/tools/quota_status.rs` (baseline 53%).
10333    // =====================================================================
10334
10335    // =====================================================================
10336    // L0.7-3 Tier B coverage closure — `memory_check_duplicate` MCP handler.
10337    //
10338    // The handler's happy path requires a real `Embedder` (LLM-bound;
10339    // playbook §4 stipulates real embedder must never run in `cargo test`).
10340    // The error/validation arms — the bulk of the line count — are driven
10341    // here. The embedder-required path is exercised by the `tests/round2_f18_*`
10342    // integration suite with a downloaded MiniLM weight.
10343    //
10344    // Coverage targets `src/mcp/tools/check_duplicate.rs` (baseline 48%).
10345    // =====================================================================
10346
10347    #[test]
10348    fn handle_check_duplicate_missing_title_returns_error() {
10349        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10350        let req = make_tools_call("memory_check_duplicate", json!({"content": "c"}));
10351        let resp = invoke_handle_request(&conn, &req);
10352        let result = resp.result.unwrap();
10353        assert_eq!(result["isError"], true);
10354        let text = result["content"][0]["text"].as_str().unwrap();
10355        assert!(text.contains("title"), "got {text}");
10356    }
10357
10358    #[test]
10359    fn handle_check_duplicate_missing_content_returns_error() {
10360        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10361        let req = make_tools_call("memory_check_duplicate", json!({"title": "t"}));
10362        let resp = invoke_handle_request(&conn, &req);
10363        let result = resp.result.unwrap();
10364        assert_eq!(result["isError"], true);
10365        let text = result["content"][0]["text"].as_str().unwrap();
10366        assert!(text.contains("content"), "got {text}");
10367    }
10368
10369    #[test]
10370    fn handle_check_duplicate_no_embedder_returns_error() {
10371        // `invoke_handle_request` always passes `None` for the embedder.
10372        // The handler must refuse with the documented "requires the
10373        // embedder" message — exercises the `Option::ok_or` error arm.
10374        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10375        let req = make_tools_call(
10376            "memory_check_duplicate",
10377            json!({
10378                "title": "duplicate-check",
10379                "content": "body",
10380                "namespace": "team/dup",
10381                "threshold": 0.85,
10382            }),
10383        );
10384        let resp = invoke_handle_request(&conn, &req);
10385        let result = resp.result.unwrap();
10386        assert_eq!(result["isError"], true);
10387        let text = result["content"][0]["text"].as_str().unwrap();
10388        assert!(
10389            text.contains("requires the embedder"),
10390            "expected embedder-required error; got {text}",
10391        );
10392    }
10393
10394    #[test]
10395    fn handle_check_duplicate_whitespace_only_namespace_is_filtered() {
10396        // The handler trims `namespace` and filters out empty-after-trim
10397        // values — so a `   ` namespace is treated as if it were absent.
10398        // The handler must NOT call `validate_namespace` (which would
10399        // reject the trimmed-empty value) — instead it falls through to
10400        // the embedder-required arm. Exercises the `.filter(|s|
10401        // !s.is_empty())` short-circuit.
10402        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10403        let req = make_tools_call(
10404            "memory_check_duplicate",
10405            json!({
10406                "title": "t",
10407                "content": "c",
10408                "namespace": "   ",
10409            }),
10410        );
10411        let resp = invoke_handle_request(&conn, &req);
10412        let result = resp.result.unwrap();
10413        assert_eq!(result["isError"], true);
10414        let text = result["content"][0]["text"].as_str().unwrap();
10415        // Falls through to the embedder-required arm because the
10416        // whitespace-only namespace gets filtered out before reaching
10417        // `validate_namespace`. If this assertion fails because the
10418        // filter changed semantics, the spec is no longer "trim and
10419        // ignore" — fix the handler or update this test deliberately.
10420        assert!(
10421            text.contains("requires the embedder"),
10422            "expected fallthrough to embedder gate; got {text}",
10423        );
10424    }
10425
10426    #[test]
10427    fn handle_check_duplicate_explicit_threshold_is_accepted() {
10428        // Covers the `params["threshold"].as_f64()` → `Some(t)` arm.
10429        // The handler must still surface the no-embedder error because
10430        // that gate fires after the threshold parse. This is an
10431        // F-category (idempotency / shape) test — the threshold is
10432        // accepted without panicking before the next stage.
10433        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10434        let req = make_tools_call(
10435            "memory_check_duplicate",
10436            json!({
10437                "title": "t",
10438                "content": "c",
10439                "threshold": 0.92,
10440            }),
10441        );
10442        let resp = invoke_handle_request(&conn, &req);
10443        let result = resp.result.unwrap();
10444        assert_eq!(result["isError"], true);
10445        let text = result["content"][0]["text"].as_str().unwrap();
10446        assert!(text.contains("requires the embedder"), "got {text}");
10447    }
10448
10449    #[test]
10450    fn handle_quota_status_with_agent_id_returns_single_envelope() {
10451        // Exercises the `Some(agent_id)` arm. The substrate auto-inserts a
10452        // zero-usage row when the agent has none, so this exercises the
10453        // happy path for both seen-before and never-seen agents.
10454        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10455        let req = make_tools_call("memory_quota_status", json!({"agent_id": "agent-status-a"}));
10456        let resp = invoke_handle_request(&conn, &req);
10457        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10458        let payload = i4_decode_response_payload(&resp);
10459        assert_eq!(payload["agent_id"], "agent-status-a");
10460        // Single-row envelope must carry the `quota` object.
10461        assert!(payload["quota"].is_object(), "expected quota object");
10462        // The list-envelope keys must NOT appear on this branch.
10463        assert!(payload["count"].is_null());
10464        assert!(payload["quotas"].is_null());
10465    }
10466
10467    #[test]
10468    fn handle_quota_status_without_agent_id_returns_list_envelope() {
10469        // Exercises the `else` arm — bulk-list over all quota rows.
10470        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10471        // Seed two distinct agent rows by asking for them first; the
10472        // substrate auto-inserts zero-usage on lookup, populating the list.
10473        let _ = invoke_handle_request(
10474            &conn,
10475            &make_tools_call("memory_quota_status", json!({"agent_id": "agent-a"})),
10476        );
10477        let _ = invoke_handle_request(
10478            &conn,
10479            &make_tools_call("memory_quota_status", json!({"agent_id": "agent-b"})),
10480        );
10481        let req = make_tools_call("memory_quota_status", json!({}));
10482        let resp = invoke_handle_request(&conn, &req);
10483        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10484        let payload = i4_decode_response_payload(&resp);
10485        // List envelope.
10486        assert!(payload["count"].is_number());
10487        assert!(payload["quotas"].is_array());
10488        assert!(payload["count"].as_u64().unwrap() >= 2);
10489        // The single-row envelope keys must NOT appear on this branch.
10490        assert!(payload["agent_id"].is_null());
10491        assert!(payload["quota"].is_null());
10492    }
10493
10494    // =====================================================================
10495    // L0.7-3 Tier B coverage closures — small-module validation arms.
10496    //
10497    // Several MCP tool handlers sit just below the 95% Tier B floor
10498    // because their `*_is_required` validation branches are only
10499    // exercised by the smoke-matrix happy paths in the integration tests
10500    // (which run in `cargo test --tests`, not `--lib`). These tests
10501    // drive each missing-required-param arm via the dispatch path so the
10502    // line-coverage report matches the actual surface count.
10503    // =====================================================================
10504
10505    #[test]
10506    fn handle_entity_register_missing_namespace_returns_error() {
10507        // src/mcp/tools/entity_register.rs:18 — `namespace is required`.
10508        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10509        let req = make_tools_call("memory_entity_register", json!({"canonical_name": "Pluto"}));
10510        let resp = invoke_handle_request(&conn, &req);
10511        let result = resp.result.unwrap();
10512        assert_eq!(result["isError"], true);
10513        let text = result["content"][0]["text"].as_str().unwrap();
10514        assert!(text.contains("namespace"), "got {text}");
10515    }
10516
10517    #[test]
10518    fn handle_entity_register_metadata_object_is_accepted() {
10519        // src/mcp/tools/entity_register.rs:28 — `metadata.is_object()` arm.
10520        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10521        let req = make_tools_call(
10522            "memory_entity_register",
10523            json!({
10524                "canonical_name": "Charon",
10525                "namespace": "team/dwarf",
10526                "metadata": {"orbit": "outer"},
10527            }),
10528        );
10529        let resp = invoke_handle_request(&conn, &req);
10530        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10531        let payload = i4_decode_response_payload(&resp);
10532        assert!(payload["entity_id"].is_string());
10533        assert_eq!(payload["canonical_name"], "Charon");
10534        assert_eq!(payload["namespace"], "team/dwarf");
10535    }
10536
10537    #[test]
10538    fn handle_kg_invalidate_missing_target_id_returns_error() {
10539        // src/mcp/tools/kg_invalidate.rs:19 — `target_id is required`.
10540        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10541        let req = make_tools_call(
10542            "memory_kg_invalidate",
10543            json!({"source_id": "abc", "relation": "related_to"}),
10544        );
10545        let resp = invoke_handle_request(&conn, &req);
10546        let result = resp.result.unwrap();
10547        assert_eq!(result["isError"], true);
10548        let text = result["content"][0]["text"].as_str().unwrap();
10549        assert!(text.contains("target_id"), "got {text}");
10550    }
10551
10552    #[test]
10553    fn handle_kg_invalidate_missing_relation_returns_error() {
10554        // src/mcp/tools/kg_invalidate.rs:20 — `relation is required`.
10555        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10556        let req = make_tools_call(
10557            "memory_kg_invalidate",
10558            json!({"source_id": "abc", "target_id": "def"}),
10559        );
10560        let resp = invoke_handle_request(&conn, &req);
10561        let result = resp.result.unwrap();
10562        assert_eq!(result["isError"], true);
10563        let text = result["content"][0]["text"].as_str().unwrap();
10564        assert!(text.contains("relation"), "got {text}");
10565    }
10566
10567    #[test]
10568    fn handle_reflect_approval_gate_uses_default_namespace() {
10569        // L1-8 gate must also fire when the caller omits `namespace` —
10570        // the resolver falls through to the first source's namespace.
10571        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10572        reflect_test_seed_governance(
10573            &conn,
10574            "team/r-defgate",
10575            json!({"require_approval_above_depth": 0}),
10576        );
10577        let s1 = reflect_test_seed_source(&conn, "team/r-defgate", "src", 0);
10578        let req = make_tools_call(
10579            "memory_reflect",
10580            json!({
10581                "source_ids": [s1],
10582                "title": "default-namespace gate",
10583                "content": "body",
10584                // namespace omitted — resolver picks team/r-defgate
10585            }),
10586        );
10587        let resp = invoke_handle_request(&conn, &req);
10588        assert!(resp.error.is_none(), "unexpected error: {:?}", resp.error);
10589        let payload = i4_decode_response_payload(&resp);
10590        assert_eq!(payload["status"], "pending");
10591        assert_eq!(payload["namespace"], "team/r-defgate");
10592        assert_eq!(payload["proposed_depth"], 1);
10593    }
10594
10595    // -----------------------------------------------------------------
10596    // L0.7-3 Tier B Chunk B — KG surface + verify >=95% coverage
10597    // -----------------------------------------------------------------
10598    //
10599    // The handlers below already have *integration* tests under
10600    // `tests/memory_verify.rs` and `tests/memory_find_paths.rs`, but
10601    // `cargo llvm-cov --lib` only picks up the in-lib `mod tests`
10602    // surface. The tests below add the missing in-lib coverage for the
10603    // seven Chunk B modules (kg_query, kg_timeline, kg_invalidate,
10604    // find_paths, entity_get_by_alias, get_taxonomy, verify) without
10605    // touching production code.
10606
10607    // --- B. Input validation -----------------------------------------
10608
10609    /// kg_invalidate.rs:16 — `source_id is required` (covers the early
10610    /// missing-`source_id` arm).
10611    #[test]
10612    fn handle_kg_invalidate_missing_source_id_returns_error() {
10613        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10614        let req = make_tools_call(
10615            "memory_kg_invalidate",
10616            json!({"target_id": "11111111-1111-1111-1111-111111111111", "relation": "related_to"}),
10617        );
10618        let resp = invoke_handle_request(&conn, &req);
10619        let result = resp.result.unwrap();
10620        assert_eq!(result["isError"], true);
10621        let text = result["content"][0]["text"].as_str().unwrap();
10622        assert!(text.contains("source_id"), "got {text}");
10623    }
10624
10625    /// kg_invalidate.rs:21 — `validate::validate_link` must reject a
10626    /// source_id containing a control character (the validator's
10627    /// `is_clean_string` gate). Other shape rules (uuid form etc.)
10628    /// are not enforced here, so we drive a control-char rejection.
10629    #[test]
10630    fn handle_kg_invalidate_malformed_source_id_rejected() {
10631        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10632        let req = make_tools_call(
10633            "memory_kg_invalidate",
10634            json!({
10635                // Embedded NUL byte → fails `is_clean_string`.
10636                "source_id": "abc\u{0000}def",
10637                "target_id": "11111111-1111-1111-1111-111111111111",
10638                "relation": "related_to",
10639            }),
10640        );
10641        let resp = invoke_handle_request(&conn, &req);
10642        let result = resp.result.unwrap();
10643        assert_eq!(result["isError"], true);
10644    }
10645
10646    /// kg_invalidate.rs:53 — when the source memory has been deleted
10647    /// but the link row survives, the dispatch helper falls through
10648    /// to the `_ => ("global".to_string(), None)` arm. We construct
10649    /// that orphan-link state by inserting two memories + a link,
10650    /// then turning OFF foreign keys before deleting the source so
10651    /// the ON DELETE CASCADE does not also drop the link row. The
10652    /// invalidate then hits `Some(res)` (link still present) AND
10653    /// `db::get(conn, source_id)` returns `Ok(None)` (source memory
10654    /// gone) — the orphan path.
10655    #[test]
10656    fn handle_kg_invalidate_orphan_link_uses_global_namespace_fallback() {
10657        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10658        let src = Memory {
10659            id: uuid::Uuid::new_v4().to_string(),
10660            tier: Tier::Long,
10661            namespace: "w12-orphan".into(),
10662            title: "orphan-src".into(),
10663            content: "c".into(),
10664            tags: vec![],
10665            priority: 5,
10666            confidence: 1.0,
10667            source: "test".into(),
10668            access_count: 0,
10669            created_at: chrono::Utc::now().to_rfc3339(),
10670            updated_at: chrono::Utc::now().to_rfc3339(),
10671            last_accessed_at: None,
10672            expires_at: None,
10673            metadata: json!({}),
10674            reflection_depth: 0,
10675            memory_kind: crate::models::MemoryKind::Observation,
10676            entity_id: None,
10677            persona_version: None,
10678            citations: Vec::new(),
10679            source_uri: None,
10680            source_span: None,
10681            confidence_source: crate::models::ConfidenceSource::CallerProvided,
10682            confidence_signals: None,
10683            confidence_decayed_at: None,
10684            version: 1,
10685        };
10686        let mut tgt = src.clone();
10687        tgt.id = uuid::Uuid::new_v4().to_string();
10688        tgt.title = "orphan-tgt".into();
10689        let src_id = db::insert(&conn, &src).unwrap();
10690        let tgt_id = db::insert(&conn, &tgt).unwrap();
10691        db::create_link(&conn, &src_id, &tgt_id, "related_to").unwrap();
10692
10693        // Drop the source memory while leaving the link row in place.
10694        // memory_links has `ON DELETE CASCADE` on source_id; we must
10695        // disable foreign keys to leave the link orphaned.
10696        conn.pragma_update(None, "foreign_keys", false).unwrap();
10697        conn.execute(
10698            "DELETE FROM memories WHERE id = ?1",
10699            rusqlite::params![&src_id],
10700        )
10701        .unwrap();
10702        conn.pragma_update(None, "foreign_keys", true).unwrap();
10703
10704        let req = make_tools_call(
10705            "memory_kg_invalidate",
10706            json!({
10707                "source_id": src_id,
10708                "target_id": tgt_id,
10709                "relation": "related_to",
10710            }),
10711        );
10712        let resp = invoke_handle_request(&conn, &req);
10713        assert!(resp.error.is_none(), "unexpected err: {:?}", resp.error);
10714        let text = resp.result.unwrap()["content"][0]["text"]
10715            .as_str()
10716            .unwrap()
10717            .to_string();
10718        let val: Value = serde_json::from_str(&text).unwrap();
10719        // The invalidate still succeeds (link row exists) even though
10720        // the source memory was deleted — that's the orphan path.
10721        assert_eq!(val["found"], true);
10722    }
10723
10724    // --- kg_timeline ---
10725
10726    /// kg_timeline.rs:28 — `until` value with a malformed RFC3339
10727    /// string returns the validation error (covers the `Some(u)`
10728    /// branch in the second `if let`).
10729    #[test]
10730    fn handle_kg_timeline_invalid_until_returns_error() {
10731        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10732        let req = make_tools_call(
10733            "memory_kg_timeline",
10734            json!({
10735                "source_id": "00000000-0000-0000-0000-000000000000",
10736                "until": "not-a-timestamp",
10737            }),
10738        );
10739        let resp = invoke_handle_request(&conn, &req);
10740        let result = resp.result.unwrap();
10741        assert_eq!(result["isError"], true);
10742    }
10743
10744    /// kg_timeline.rs:14 — `source_id is required`. Pins the missing
10745    /// arm at the entry of the handler.
10746    #[test]
10747    fn handle_kg_timeline_missing_source_id_returns_error() {
10748        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10749        let req = make_tools_call("memory_kg_timeline", json!({}));
10750        let resp = invoke_handle_request(&conn, &req);
10751        let result = resp.result.unwrap();
10752        assert_eq!(result["isError"], true);
10753        let text = result["content"][0]["text"].as_str().unwrap();
10754        assert!(text.contains("source_id"), "got {text}");
10755    }
10756
10757    // --- find_paths ---
10758
10759    /// find_paths.rs:22 — `source_id is required` arm. The integration
10760    /// suite covers happy paths via `tests/memory_find_paths.rs`; this
10761    /// test pins the missing-`source_id` branch in --lib coverage.
10762    #[test]
10763    fn handle_find_paths_missing_source_id_returns_error() {
10764        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10765        let req = make_tools_call(
10766            "memory_find_paths",
10767            json!({"target_id": "11111111-1111-1111-1111-111111111111"}),
10768        );
10769        let resp = invoke_handle_request(&conn, &req);
10770        let result = resp.result.unwrap();
10771        assert_eq!(result["isError"], true);
10772        let text = result["content"][0]["text"].as_str().unwrap();
10773        assert!(text.contains("source_id"), "got {text}");
10774    }
10775
10776    /// find_paths.rs:25 — `target_id is required` arm.
10777    #[test]
10778    fn handle_find_paths_missing_target_id_returns_error() {
10779        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10780        let req = make_tools_call(
10781            "memory_find_paths",
10782            json!({"source_id": "00000000-0000-0000-0000-000000000000"}),
10783        );
10784        let resp = invoke_handle_request(&conn, &req);
10785        let result = resp.result.unwrap();
10786        assert_eq!(result["isError"], true);
10787        let text = result["content"][0]["text"].as_str().unwrap();
10788        assert!(text.contains("target_id"), "got {text}");
10789    }
10790
10791    /// find_paths.rs:26-27 — validate_id rejects IDs containing a
10792    /// control character (the validator's `is_clean_string` gate).
10793    #[test]
10794    fn handle_find_paths_invalid_source_id_rejected() {
10795        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10796        let req = make_tools_call(
10797            "memory_find_paths",
10798            json!({
10799                // Embedded NUL byte → fails `is_clean_string`.
10800                "source_id": "abc\u{0000}def",
10801                "target_id": "11111111-1111-1111-1111-111111111111",
10802            }),
10803        );
10804        let resp = invoke_handle_request(&conn, &req);
10805        let result = resp.result.unwrap();
10806        assert_eq!(result["isError"], true);
10807    }
10808
10809    /// find_paths.rs:29-34 + 41-54 — happy path with max_depth +
10810    /// max_results passed; exercises the `.as_u64()` + `usize::try_from`
10811    /// arms for both options.
10812    #[test]
10813    fn handle_find_paths_happy_path_with_explicit_depth_and_limit() {
10814        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10815        // Two-hop linear chain: a -> b -> c.
10816        let mk = |title: &str| Memory {
10817            id: uuid::Uuid::new_v4().to_string(),
10818            tier: Tier::Long,
10819            namespace: "w12-fp".into(),
10820            title: title.into(),
10821            content: "x".into(),
10822            tags: vec![],
10823            priority: 5,
10824            confidence: 1.0,
10825            source: "test".into(),
10826            access_count: 0,
10827            created_at: chrono::Utc::now().to_rfc3339(),
10828            updated_at: chrono::Utc::now().to_rfc3339(),
10829            last_accessed_at: None,
10830            expires_at: None,
10831            metadata: json!({}),
10832            reflection_depth: 0,
10833            memory_kind: crate::models::MemoryKind::Observation,
10834            entity_id: None,
10835            persona_version: None,
10836            citations: Vec::new(),
10837            source_uri: None,
10838            source_span: None,
10839            confidence_source: crate::models::ConfidenceSource::CallerProvided,
10840            confidence_signals: None,
10841            confidence_decayed_at: None,
10842            version: 1,
10843        };
10844        let a = db::insert(&conn, &mk("a")).unwrap();
10845        let b = db::insert(&conn, &mk("b")).unwrap();
10846        let c = db::insert(&conn, &mk("c")).unwrap();
10847        db::create_link(&conn, &a, &b, "related_to").unwrap();
10848        db::create_link(&conn, &b, &c, "related_to").unwrap();
10849
10850        let req = make_tools_call(
10851            "memory_find_paths",
10852            json!({
10853                "source_id": a,
10854                "target_id": c,
10855                "max_depth": 5_u64,
10856                "max_results": 10_u64,
10857            }),
10858        );
10859        let resp = invoke_handle_request(&conn, &req);
10860        assert!(resp.error.is_none(), "err: {:?}", resp.error);
10861        let text = resp.result.unwrap()["content"][0]["text"]
10862            .as_str()
10863            .unwrap()
10864            .to_string();
10865        let val: Value = serde_json::from_str(&text).unwrap();
10866        assert!(val["count"].as_u64().unwrap() >= 1, "got {val}");
10867        let paths = val["paths"].as_array().unwrap();
10868        assert!(!paths.is_empty());
10869    }
10870
10871    /// find_paths.rs:49-54 — `db::find_paths` Err branch is reached
10872    /// when `max_depth = 0` (storage layer rejects with explicit
10873    /// `max_depth must be >= 1`).
10874    #[test]
10875    fn handle_find_paths_zero_depth_surfaces_db_error_verbatim() {
10876        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10877        let req = make_tools_call(
10878            "memory_find_paths",
10879            json!({
10880                "source_id": "00000000-0000-0000-0000-000000000000",
10881                "target_id": "11111111-1111-1111-1111-111111111111",
10882                "max_depth": 0_u64,
10883            }),
10884        );
10885        let resp = invoke_handle_request(&conn, &req);
10886        let result = resp.result.unwrap();
10887        assert_eq!(result["isError"], true);
10888        let text = result["content"][0]["text"].as_str().unwrap();
10889        assert!(text.contains("max_depth"), "got {text}");
10890    }
10891
10892    /// find_paths.rs:49-54 — depth above `FIND_PATHS_MAX_DEPTH` surfaces
10893    /// the depth-budget error verbatim through the map_err closure.
10894    /// Covers the `.map_err(|e| e.to_string())?` closure body.
10895    #[test]
10896    fn handle_find_paths_excessive_depth_surfaces_max_error() {
10897        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10898        let req = make_tools_call(
10899            "memory_find_paths",
10900            json!({
10901                "source_id": "00000000-0000-0000-0000-000000000000",
10902                "target_id": "11111111-1111-1111-1111-111111111111",
10903                "max_depth": 1_000_000_u64,
10904            }),
10905        );
10906        let resp = invoke_handle_request(&conn, &req);
10907        let result = resp.result.unwrap();
10908        assert_eq!(result["isError"], true);
10909        let text = result["content"][0]["text"].as_str().unwrap();
10910        assert!(
10911            text.contains("max_depth") || text.contains("FIND_PATHS_MAX_DEPTH"),
10912            "got {text}"
10913        );
10914    }
10915
10916    /// find_paths.rs:39 + db dispatch — include_invalidated=true
10917    /// happy path (covers the `as_bool().unwrap_or(false)` truthy arm
10918    /// and the db-side include_invalidated branch wiring).
10919    #[test]
10920    fn handle_find_paths_include_invalidated_true_round_trip() {
10921        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10922        // Self-path short-circuit means same source/target returns a
10923        // 1-element path regardless of the invalidated edge filter.
10924        let mem = Memory {
10925            id: uuid::Uuid::new_v4().to_string(),
10926            tier: Tier::Long,
10927            namespace: "w12-fp-inv".into(),
10928            title: "solo".into(),
10929            content: "c".into(),
10930            tags: vec![],
10931            priority: 5,
10932            confidence: 1.0,
10933            source: "test".into(),
10934            access_count: 0,
10935            created_at: chrono::Utc::now().to_rfc3339(),
10936            updated_at: chrono::Utc::now().to_rfc3339(),
10937            last_accessed_at: None,
10938            expires_at: None,
10939            metadata: json!({}),
10940            reflection_depth: 0,
10941            memory_kind: crate::models::MemoryKind::Observation,
10942            entity_id: None,
10943            persona_version: None,
10944            citations: Vec::new(),
10945            source_uri: None,
10946            source_span: None,
10947            confidence_source: crate::models::ConfidenceSource::CallerProvided,
10948            confidence_signals: None,
10949            confidence_decayed_at: None,
10950            version: 1,
10951        };
10952        let id = db::insert(&conn, &mem).unwrap();
10953        let req = make_tools_call(
10954            "memory_find_paths",
10955            json!({
10956                "source_id": id,
10957                "target_id": id,
10958                "include_invalidated": true,
10959            }),
10960        );
10961        let resp = invoke_handle_request(&conn, &req);
10962        assert!(resp.error.is_none(), "err: {:?}", resp.error);
10963        let text = resp.result.unwrap()["content"][0]["text"]
10964            .as_str()
10965            .unwrap()
10966            .to_string();
10967        let val: Value = serde_json::from_str(&text).unwrap();
10968        assert!(val["count"].as_u64().unwrap() >= 1);
10969    }
10970
10971    // --- entity_get_by_alias ---
10972
10973    /// entity_get_by_alias.rs:12 — `alias is required` arm.
10974    #[test]
10975    fn handle_entity_get_by_alias_missing_alias_returns_error() {
10976        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10977        let req = make_tools_call("memory_entity_get_by_alias", json!({}));
10978        let resp = invoke_handle_request(&conn, &req);
10979        let result = resp.result.unwrap();
10980        assert_eq!(result["isError"], true);
10981        let text = result["content"][0]["text"].as_str().unwrap();
10982        assert!(text.contains("alias"), "got {text}");
10983    }
10984
10985    /// entity_get_by_alias.rs:18 — namespace validator rejects bad
10986    /// namespace before the storage lookup.
10987    #[test]
10988    fn handle_entity_get_by_alias_invalid_namespace_rejected() {
10989        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
10990        let req = make_tools_call(
10991            "memory_entity_get_by_alias",
10992            json!({"alias": "any", "namespace": "BAD NS"}),
10993        );
10994        let resp = invoke_handle_request(&conn, &req);
10995        let result = resp.result.unwrap();
10996        assert_eq!(result["isError"], true);
10997    }
10998
10999    /// entity_get_by_alias.rs:22-28 — registered alias resolves and
11000    /// returns the `Some(rec)` envelope (entity_id, canonical_name,
11001    /// namespace, aliases). This drives the `Some(rec)` arm that was
11002    /// previously uncovered by --lib tests.
11003    #[test]
11004    fn handle_entity_get_by_alias_registered_alias_resolves() {
11005        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11006        // Register an entity via the public MCP tool, then look it up
11007        // by one of its aliases.
11008        let reg = make_tools_call(
11009            "memory_entity_register",
11010            json!({
11011                "canonical_name": "Acme Inc",
11012                "namespace": "w12-entities",
11013                "aliases": ["Acme", "ACME"],
11014            }),
11015        );
11016        let reg_resp = invoke_handle_request(&conn, &reg);
11017        assert!(
11018            reg_resp.error.is_none(),
11019            "entity_register err: {:?}",
11020            reg_resp.error
11021        );
11022
11023        let req = make_tools_call(
11024            "memory_entity_get_by_alias",
11025            json!({"alias": "Acme", "namespace": "w12-entities"}),
11026        );
11027        let resp = invoke_handle_request(&conn, &req);
11028        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11029        let text = resp.result.unwrap()["content"][0]["text"]
11030            .as_str()
11031            .unwrap()
11032            .to_string();
11033        let val: Value = serde_json::from_str(&text).unwrap();
11034        assert_eq!(val["found"], true, "got {val}");
11035        assert_eq!(val["canonical_name"], "Acme Inc");
11036        assert_eq!(val["namespace"], "w12-entities");
11037        assert!(val["aliases"].is_array());
11038    }
11039
11040    /// entity_get_by_alias.rs:13-16 — whitespace-only namespace
11041    /// triggers the `filter` arm and treats namespace as None (covers
11042    /// the `s.is_empty()` filter branch). The namespace-validator
11043    /// `if let Some(ns)` arm is NOT entered, so this succeeds.
11044    #[test]
11045    fn handle_entity_get_by_alias_whitespace_only_namespace_treated_as_none() {
11046        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11047        let req = make_tools_call(
11048            "memory_entity_get_by_alias",
11049            json!({"alias": "x", "namespace": "   "}),
11050        );
11051        let resp = invoke_handle_request(&conn, &req);
11052        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11053    }
11054
11055    // --- verify -------------------------------------------------------
11056
11057    /// verify.rs:62-66 — missing both `source_id` and `target_id`
11058    /// returns the explicit-args error string.
11059    #[test]
11060    fn handle_verify_missing_required_args_returns_error() {
11061        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11062        let req = make_tools_call("memory_verify", json!({}));
11063        let resp = invoke_handle_request(&conn, &req);
11064        let result = resp.result.unwrap();
11065        assert_eq!(result["isError"], true);
11066        let text = result["content"][0]["text"].as_str().unwrap();
11067        assert!(
11068            text.contains("link_id") || text.contains("source_id"),
11069            "got {text}"
11070        );
11071    }
11072
11073    /// verify.rs:62-66 — only `source_id` given, missing `target_id`.
11074    #[test]
11075    fn handle_verify_source_id_without_target_id_returns_error() {
11076        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11077        let req = make_tools_call(
11078            "memory_verify",
11079            json!({"source_id": "00000000-0000-0000-0000-000000000000"}),
11080        );
11081        let resp = invoke_handle_request(&conn, &req);
11082        let result = resp.result.unwrap();
11083        assert_eq!(result["isError"], true);
11084    }
11085
11086    /// verify.rs:52-57 — `link_id` malformed (no `--rel-->`) returns
11087    /// the parse-error string.
11088    #[test]
11089    fn handle_verify_malformed_link_id_returns_error() {
11090        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11091        let req = make_tools_call("memory_verify", json!({"link_id": "totally-bad-shape"}));
11092        let resp = invoke_handle_request(&conn, &req);
11093        let result = resp.result.unwrap();
11094        assert_eq!(result["isError"], true);
11095        let text = result["content"][0]["text"].as_str().unwrap();
11096        assert!(text.contains("link_id"), "got {text}");
11097    }
11098
11099    /// verify.rs:77 — `validate::validate_link` rejects a malformed
11100    /// source/target/relation triple before the DB lookup.
11101    #[test]
11102    fn handle_verify_invalid_link_rejected_by_validator() {
11103        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11104        let req = make_tools_call(
11105            "memory_verify",
11106            json!({
11107                "source_id": "not a uuid",
11108                "target_id": "11111111-1111-1111-1111-111111111111",
11109                "relation": "related_to",
11110            }),
11111        );
11112        let resp = invoke_handle_request(&conn, &req);
11113        let result = resp.result.unwrap();
11114        assert_eq!(result["isError"], true);
11115    }
11116
11117    /// verify.rs:79-81 — `get_link_for_verify` returns Ok(None) when
11118    /// the requested triple does not exist: handler emits a "link not
11119    /// found" error string.
11120    #[test]
11121    fn handle_verify_missing_link_returns_not_found_error() {
11122        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11123        // Seed two memories so validate_link's UUID checks pass but
11124        // no link row exists.
11125        let src = Memory {
11126            id: uuid::Uuid::new_v4().to_string(),
11127            tier: Tier::Long,
11128            namespace: "w12-vfn".into(),
11129            title: "src".into(),
11130            content: "c".into(),
11131            tags: vec![],
11132            priority: 5,
11133            confidence: 1.0,
11134            source: "test".into(),
11135            access_count: 0,
11136            created_at: chrono::Utc::now().to_rfc3339(),
11137            updated_at: chrono::Utc::now().to_rfc3339(),
11138            last_accessed_at: None,
11139            expires_at: None,
11140            metadata: json!({}),
11141            reflection_depth: 0,
11142            memory_kind: crate::models::MemoryKind::Observation,
11143            entity_id: None,
11144            persona_version: None,
11145            citations: Vec::new(),
11146            source_uri: None,
11147            source_span: None,
11148            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11149            confidence_signals: None,
11150            confidence_decayed_at: None,
11151            version: 1,
11152        };
11153        let mut tgt = src.clone();
11154        tgt.id = uuid::Uuid::new_v4().to_string();
11155        tgt.title = "tgt".into();
11156        let src_id = db::insert(&conn, &src).unwrap();
11157        let tgt_id = db::insert(&conn, &tgt).unwrap();
11158        let req = make_tools_call(
11159            "memory_verify",
11160            json!({
11161                "source_id": src_id,
11162                "target_id": tgt_id,
11163                "relation": "related_to",
11164            }),
11165        );
11166        let resp = invoke_handle_request(&conn, &req);
11167        let result = resp.result.unwrap();
11168        assert_eq!(result["isError"], true);
11169        let text = result["content"][0]["text"].as_str().unwrap();
11170        assert!(text.contains("link not found"), "got {text}");
11171    }
11172
11173    /// verify.rs:106 + 151-173 — unsigned link (no signature blob)
11174    /// returns `signature_verified=false`, `attest_level="unsigned"`,
11175    /// `signed_by`/`signed_at` both null. Drives the
11176    /// `(None, _) | (_, None)` arm and the not-verified `signed_by`/
11177    /// `signed_at` else-branches.
11178    #[test]
11179    fn handle_verify_unsigned_link_reports_unsigned_and_null_fields() {
11180        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11181        let src = Memory {
11182            id: uuid::Uuid::new_v4().to_string(),
11183            tier: Tier::Long,
11184            namespace: "w12-vu".into(),
11185            title: "src".into(),
11186            content: "c".into(),
11187            tags: vec![],
11188            priority: 5,
11189            confidence: 1.0,
11190            source: "test".into(),
11191            access_count: 0,
11192            created_at: chrono::Utc::now().to_rfc3339(),
11193            updated_at: chrono::Utc::now().to_rfc3339(),
11194            last_accessed_at: None,
11195            expires_at: None,
11196            metadata: json!({}),
11197            reflection_depth: 0,
11198            memory_kind: crate::models::MemoryKind::Observation,
11199            entity_id: None,
11200            persona_version: None,
11201            citations: Vec::new(),
11202            source_uri: None,
11203            source_span: None,
11204            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11205            confidence_signals: None,
11206            confidence_decayed_at: None,
11207            version: 1,
11208        };
11209        let mut tgt = src.clone();
11210        tgt.id = uuid::Uuid::new_v4().to_string();
11211        tgt.title = "tgt".into();
11212        let src_id = db::insert(&conn, &src).unwrap();
11213        let tgt_id = db::insert(&conn, &tgt).unwrap();
11214
11215        // H2 outbound with `keypair=None` lands an unsigned row —
11216        // identical wire shape to the existing tests/memory_verify.rs
11217        // fixture but reachable from the in-lib coverage harness.
11218        let attest = db::create_link_signed(&conn, &src_id, &tgt_id, "related_to", None)
11219            .expect("create_link_signed (unsigned)");
11220        assert_eq!(attest, "unsigned");
11221
11222        let req = make_tools_call(
11223            "memory_verify",
11224            json!({
11225                "source_id": src_id,
11226                "target_id": tgt_id,
11227                "relation": "related_to",
11228            }),
11229        );
11230        let resp = invoke_handle_request(&conn, &req);
11231        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11232        let text = resp.result.unwrap()["content"][0]["text"]
11233            .as_str()
11234            .unwrap()
11235            .to_string();
11236        let val: Value = serde_json::from_str(&text).unwrap();
11237        assert_eq!(val["signature_verified"], false);
11238        assert_eq!(val["attest_level"], "unsigned");
11239        assert!(val["signed_by"].is_null());
11240        assert!(val["signed_at"].is_null());
11241    }
11242
11243    /// verify.rs:52-57 — composite `link_id` form parses and resolves
11244    /// the same row as the explicit-arg form. Drives the
11245    /// `parse_link_id(lid)` Ok branch.
11246    #[test]
11247    fn handle_verify_link_id_composite_form_resolves_same_row() {
11248        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11249        let src = Memory {
11250            id: uuid::Uuid::new_v4().to_string(),
11251            tier: Tier::Long,
11252            namespace: "w12-vc".into(),
11253            title: "src".into(),
11254            content: "c".into(),
11255            tags: vec![],
11256            priority: 5,
11257            confidence: 1.0,
11258            source: "test".into(),
11259            access_count: 0,
11260            created_at: chrono::Utc::now().to_rfc3339(),
11261            updated_at: chrono::Utc::now().to_rfc3339(),
11262            last_accessed_at: None,
11263            expires_at: None,
11264            metadata: json!({}),
11265            reflection_depth: 0,
11266            memory_kind: crate::models::MemoryKind::Observation,
11267            entity_id: None,
11268            persona_version: None,
11269            citations: Vec::new(),
11270            source_uri: None,
11271            source_span: None,
11272            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11273            confidence_signals: None,
11274            confidence_decayed_at: None,
11275            version: 1,
11276        };
11277        let mut tgt = src.clone();
11278        tgt.id = uuid::Uuid::new_v4().to_string();
11279        tgt.title = "tgt".into();
11280        let src_id = db::insert(&conn, &src).unwrap();
11281        let tgt_id = db::insert(&conn, &tgt).unwrap();
11282        db::create_link_signed(&conn, &src_id, &tgt_id, "related_to", None)
11283            .expect("create_link_signed");
11284
11285        let composite = format!("{src_id}--related_to-->{tgt_id}");
11286        let req = make_tools_call("memory_verify", json!({"link_id": composite}));
11287        let resp = invoke_handle_request(&conn, &req);
11288        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11289        let text = resp.result.unwrap()["content"][0]["text"]
11290            .as_str()
11291            .unwrap()
11292            .to_string();
11293        let val: Value = serde_json::from_str(&text).unwrap();
11294        assert_eq!(val["signature_verified"], false);
11295        assert_eq!(val["attest_level"], "unsigned");
11296    }
11297
11298    /// Single-process gate against parallel `env::set_var` racing on
11299    /// `AI_MEMORY_KEY_DIR`. The H4 verify tests below acquire the
11300    /// keypair module's `pub(crate)` lock so they serialise with both
11301    /// the keypair-module tests AND each other. Mirrors the pattern in
11302    /// `tests/memory_verify.rs::ENV_GUARD`.
11303    fn verify_key_env_guard() -> &'static std::sync::Mutex<()> {
11304        crate::identity::keypair::key_dir_env_lock()
11305    }
11306
11307    /// verify.rs:140-145 — signature present + observed_by present
11308    /// but the pubkey is NOT enrolled on this host. Surfaces the
11309    /// `None pubkey` arm: `signature_verified=false`, attest_level
11310    /// echoes the stored column value (here: "self_signed").
11311    ///
11312    /// We construct this state by signing the link with a keypair
11313    /// whose public key is NOT saved under `AI_MEMORY_KEY_DIR`, so
11314    /// the verify-time lookup fails.
11315    #[test]
11316    fn handle_verify_signed_link_without_local_pubkey_reports_stored_attest_and_unverified() {
11317        // PoisonError-tolerant lock — a panic in a sibling test
11318        // mustn't cascade-fail this one.
11319        let _g = verify_key_env_guard()
11320            .lock()
11321            .unwrap_or_else(std::sync::PoisonError::into_inner);
11322        let prev = std::env::var("AI_MEMORY_KEY_DIR").ok();
11323        let tmp = tempfile::TempDir::new().expect("tempdir");
11324        // SAFETY: lock acquired above; env writes are serialised.
11325        unsafe {
11326            std::env::set_var("AI_MEMORY_KEY_DIR", tmp.path());
11327        }
11328
11329        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11330        let src = Memory {
11331            id: uuid::Uuid::new_v4().to_string(),
11332            tier: Tier::Long,
11333            namespace: "w12-vnk".into(),
11334            title: "src".into(),
11335            content: "c".into(),
11336            tags: vec![],
11337            priority: 5,
11338            confidence: 1.0,
11339            source: "test".into(),
11340            access_count: 0,
11341            created_at: chrono::Utc::now().to_rfc3339(),
11342            updated_at: chrono::Utc::now().to_rfc3339(),
11343            last_accessed_at: None,
11344            expires_at: None,
11345            metadata: json!({}),
11346            reflection_depth: 0,
11347            memory_kind: crate::models::MemoryKind::Observation,
11348            entity_id: None,
11349            persona_version: None,
11350            citations: Vec::new(),
11351            source_uri: None,
11352            source_span: None,
11353            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11354            confidence_signals: None,
11355            confidence_decayed_at: None,
11356            version: 1,
11357        };
11358        let mut tgt = src.clone();
11359        tgt.id = uuid::Uuid::new_v4().to_string();
11360        tgt.title = "tgt".into();
11361        let src_id = db::insert(&conn, &src).unwrap();
11362        let tgt_id = db::insert(&conn, &tgt).unwrap();
11363
11364        // Generate alice's keypair entirely in memory — we never
11365        // save the public key under AI_MEMORY_KEY_DIR, so the
11366        // verify-time lookup returns None even though the link row
11367        // landed with attest_level="self_signed".
11368        let alice = crate::identity::keypair::generate("alice").unwrap();
11369        let attest = db::create_link_signed(&conn, &src_id, &tgt_id, "related_to", Some(&alice))
11370            .expect("create_link_signed");
11371        assert_eq!(attest, "self_signed");
11372
11373        let req = make_tools_call(
11374            "memory_verify",
11375            json!({
11376                "source_id": src_id,
11377                "target_id": tgt_id,
11378                "relation": "related_to",
11379            }),
11380        );
11381        let resp = invoke_handle_request(&conn, &req);
11382        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11383        let text = resp.result.unwrap()["content"][0]["text"]
11384            .as_str()
11385            .unwrap()
11386            .to_string();
11387        let val: Value = serde_json::from_str(&text).unwrap();
11388        assert_eq!(val["signature_verified"], false);
11389        // Stored attest column = "self_signed"; handler echoes it
11390        // through the None-pubkey arm.
11391        assert_eq!(val["attest_level"], "self_signed");
11392
11393        // SAFETY: lock still held; restore env.
11394        unsafe {
11395            match prev {
11396                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
11397                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
11398            }
11399        }
11400    }
11401
11402    /// verify.rs:117-135 — happy path: signature present, observed_by
11403    /// present, pubkey enrolled → `signature_verified=true`,
11404    /// attest_level=self_signed, signed_by + signed_at populated.
11405    ///
11406    /// Drives the `Some(pubkey)` arm + the `ok=true` branch + the
11407    /// `stored_attest=SelfSigned` arm + the verified `signed_by`/
11408    /// `signed_at` populate-from-record branches.
11409    #[test]
11410    fn handle_verify_self_signed_link_verifies_and_populates_signed_fields() {
11411        let _g = verify_key_env_guard()
11412            .lock()
11413            .unwrap_or_else(std::sync::PoisonError::into_inner);
11414        let prev = std::env::var("AI_MEMORY_KEY_DIR").ok();
11415        let key_tmp = tempfile::TempDir::new().expect("key tempdir");
11416        // SAFETY: lock acquired above; env writes serialised.
11417        unsafe {
11418            std::env::set_var("AI_MEMORY_KEY_DIR", key_tmp.path());
11419        }
11420
11421        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11422        let src = Memory {
11423            id: uuid::Uuid::new_v4().to_string(),
11424            tier: Tier::Long,
11425            namespace: "w12-vss".into(),
11426            title: "src".into(),
11427            content: "c".into(),
11428            tags: vec![],
11429            priority: 5,
11430            confidence: 1.0,
11431            source: "test".into(),
11432            access_count: 0,
11433            created_at: chrono::Utc::now().to_rfc3339(),
11434            updated_at: chrono::Utc::now().to_rfc3339(),
11435            last_accessed_at: None,
11436            expires_at: None,
11437            metadata: json!({}),
11438            reflection_depth: 0,
11439            memory_kind: crate::models::MemoryKind::Observation,
11440            entity_id: None,
11441            persona_version: None,
11442            citations: Vec::new(),
11443            source_uri: None,
11444            source_span: None,
11445            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11446            confidence_signals: None,
11447            confidence_decayed_at: None,
11448            version: 1,
11449        };
11450        let mut tgt = src.clone();
11451        tgt.id = uuid::Uuid::new_v4().to_string();
11452        tgt.title = "tgt".into();
11453        let src_id = db::insert(&conn, &src).unwrap();
11454        let tgt_id = db::insert(&conn, &tgt).unwrap();
11455
11456        // Generate alice's keypair under the key dir so the verify-
11457        // time lookup succeeds.
11458        let alice = crate::identity::keypair::generate("alice").unwrap();
11459        crate::identity::keypair::save(&alice, key_tmp.path()).unwrap();
11460        let attest = db::create_link_signed(&conn, &src_id, &tgt_id, "related_to", Some(&alice))
11461            .expect("create_link_signed");
11462        assert_eq!(attest, "self_signed");
11463
11464        let req = make_tools_call(
11465            "memory_verify",
11466            json!({
11467                "source_id": src_id,
11468                "target_id": tgt_id,
11469                "relation": "related_to",
11470            }),
11471        );
11472        let resp = invoke_handle_request(&conn, &req);
11473        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11474        let text = resp.result.unwrap()["content"][0]["text"]
11475            .as_str()
11476            .unwrap()
11477            .to_string();
11478        let val: Value = serde_json::from_str(&text).unwrap();
11479        assert_eq!(val["signature_verified"], true, "got {val}");
11480        assert_eq!(val["attest_level"], "self_signed");
11481        assert_eq!(val["signed_by"], "alice");
11482        assert!(
11483            val["signed_at"].is_string(),
11484            "signed_at must be RFC3339 string, got {:?}",
11485            val["signed_at"]
11486        );
11487
11488        // SAFETY: restore env.
11489        unsafe {
11490            match prev {
11491                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
11492                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
11493            }
11494        }
11495    }
11496
11497    /// verify.rs:118-119 + 136-137 — sig present, observed_by present,
11498    /// pubkey enrolled → verify FAILS (e.g. tampered signature byte).
11499    /// Drives the `ok=false` arm: `signature_verified=false`,
11500    /// `attest_level="unsigned"` (the explicit downgrade on a failed
11501    /// re-verify regardless of stored column).
11502    #[test]
11503    fn handle_verify_tampered_signature_returns_false_and_unsigned() {
11504        let _g = verify_key_env_guard()
11505            .lock()
11506            .unwrap_or_else(std::sync::PoisonError::into_inner);
11507        let prev = std::env::var("AI_MEMORY_KEY_DIR").ok();
11508        let key_tmp = tempfile::TempDir::new().expect("key tempdir");
11509        // SAFETY: lock acquired above; env writes serialised.
11510        unsafe {
11511            std::env::set_var("AI_MEMORY_KEY_DIR", key_tmp.path());
11512        }
11513
11514        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11515        let src = Memory {
11516            id: uuid::Uuid::new_v4().to_string(),
11517            tier: Tier::Long,
11518            namespace: "w12-vts".into(),
11519            title: "src".into(),
11520            content: "c".into(),
11521            tags: vec![],
11522            priority: 5,
11523            confidence: 1.0,
11524            source: "test".into(),
11525            access_count: 0,
11526            created_at: chrono::Utc::now().to_rfc3339(),
11527            updated_at: chrono::Utc::now().to_rfc3339(),
11528            last_accessed_at: None,
11529            expires_at: None,
11530            metadata: json!({}),
11531            reflection_depth: 0,
11532            memory_kind: crate::models::MemoryKind::Observation,
11533            entity_id: None,
11534            persona_version: None,
11535            citations: Vec::new(),
11536            source_uri: None,
11537            source_span: None,
11538            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11539            confidence_signals: None,
11540            confidence_decayed_at: None,
11541            version: 1,
11542        };
11543        let mut tgt = src.clone();
11544        tgt.id = uuid::Uuid::new_v4().to_string();
11545        tgt.title = "tgt".into();
11546        let src_id = db::insert(&conn, &src).unwrap();
11547        let tgt_id = db::insert(&conn, &tgt).unwrap();
11548
11549        let alice = crate::identity::keypair::generate("alice").unwrap();
11550        crate::identity::keypair::save(&alice, key_tmp.path()).unwrap();
11551        db::create_link_signed(&conn, &src_id, &tgt_id, "related_to", Some(&alice))
11552            .expect("create_link_signed");
11553
11554        // Tamper byte 0 of the stored signature.
11555        let original_sig: Vec<u8> = conn
11556            .query_row(
11557                "SELECT signature FROM memory_links \
11558                 WHERE source_id = ?1 AND target_id = ?2",
11559                rusqlite::params![&src_id, &tgt_id],
11560                |row| row.get::<_, Vec<u8>>(0),
11561            )
11562            .expect("read signature");
11563        assert_eq!(original_sig.len(), 64);
11564        let mut tampered = original_sig.clone();
11565        tampered[0] ^= 0xFF;
11566        conn.execute(
11567            "UPDATE memory_links SET signature = ?3 \
11568             WHERE source_id = ?1 AND target_id = ?2",
11569            rusqlite::params![&src_id, &tgt_id, &tampered],
11570        )
11571        .unwrap();
11572
11573        let req = make_tools_call(
11574            "memory_verify",
11575            json!({
11576                "source_id": src_id,
11577                "target_id": tgt_id,
11578                "relation": "related_to",
11579            }),
11580        );
11581        let resp = invoke_handle_request(&conn, &req);
11582        assert!(resp.error.is_none(), "err: {:?}", resp.error);
11583        let text = resp.result.unwrap()["content"][0]["text"]
11584            .as_str()
11585            .unwrap()
11586            .to_string();
11587        let val: Value = serde_json::from_str(&text).unwrap();
11588        assert_eq!(val["signature_verified"], false, "got {val}");
11589        assert_eq!(val["attest_level"], "unsigned");
11590        assert!(val["signed_by"].is_null());
11591        assert!(val["signed_at"].is_null());
11592
11593        // SAFETY: restore env.
11594        unsafe {
11595            match prev {
11596                Some(v) => std::env::set_var("AI_MEMORY_KEY_DIR", v),
11597                None => std::env::remove_var("AI_MEMORY_KEY_DIR"),
11598            }
11599        }
11600    }
11601
11602    // =====================================================================
11603    // L0.7-3 Tier B chunk-C coverage closure.
11604    //
11605    // Targets nine handler modules (operations + capabilities + smart_load):
11606    // archive, consolidate, forget, namespace, promote, search, replay,
11607    // capabilities, load_family. Each handler is exercised across the six
11608    // playbook categories (happy / input validation / authz / state /
11609    // idempotency / audit-chain side effects) where applicable, plus the
11610    // F14 control-intent matrix for smart_load.
11611    // =====================================================================
11612
11613    // ─── tiny shared helpers ──────────────────────────────────────────────
11614
11615    /// Insert a memory at the given namespace/title/tier — returns the id.
11616    fn chunkc_seed_memory(
11617        conn: &rusqlite::Connection,
11618        namespace: &str,
11619        title: &str,
11620        tier: Tier,
11621    ) -> String {
11622        let now = chrono::Utc::now().to_rfc3339();
11623        let mem = Memory {
11624            id: uuid::Uuid::new_v4().to_string(),
11625            tier,
11626            namespace: namespace.to_string(),
11627            title: title.to_string(),
11628            content: format!("body for {title}"),
11629            tags: vec!["chunkc".to_string()],
11630            priority: 5,
11631            confidence: 1.0,
11632            source: "test".to_string(),
11633            access_count: 0,
11634            created_at: now.clone(),
11635            updated_at: now,
11636            last_accessed_at: None,
11637            expires_at: None,
11638            // v0.7.0 #1075 — public scope so chunkc fixtures survive
11639            // the visibility gate added on memory_replay regardless of
11640            // which agent_id the caller resolves to in tests.
11641            metadata: json!({"agent_id": "test-agent-chunkc", "scope": "public"}),
11642            reflection_depth: 0,
11643            memory_kind: crate::models::MemoryKind::Observation,
11644            entity_id: None,
11645            persona_version: None,
11646            citations: Vec::new(),
11647            source_uri: None,
11648            source_span: None,
11649            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11650            confidence_signals: None,
11651            confidence_decayed_at: None,
11652            version: 1,
11653        };
11654        db::insert(conn, &mem).unwrap()
11655    }
11656
11657    /// Insert a memory tagged with `metadata.family` so the
11658    /// `memory_load_family` query catches it.
11659    /// #1555 — seed a family-tagged row owned by `owner` with an explicit
11660    /// `scope`, so the visibility post-filter on the family loaders can be
11661    /// exercised across the owner / other-tenant / trust-all axes.
11662    fn seed_family_owned(
11663        conn: &rusqlite::Connection,
11664        namespace: &str,
11665        family: &str,
11666        owner: &str,
11667        scope: &str,
11668    ) -> String {
11669        let now = chrono::Utc::now().to_rfc3339();
11670        let mem = Memory {
11671            id: uuid::Uuid::new_v4().to_string(),
11672            tier: Tier::Mid,
11673            namespace: namespace.to_string(),
11674            title: format!("{family}-{owner}"),
11675            content: format!("owned by {owner}"),
11676            tags: vec![],
11677            priority: 5,
11678            confidence: 1.0,
11679            source: "test".to_string(),
11680            access_count: 0,
11681            created_at: now.clone(),
11682            updated_at: now,
11683            last_accessed_at: None,
11684            expires_at: None,
11685            metadata: json!({"family": family, "agent_id": owner, "scope": scope}),
11686            reflection_depth: 0,
11687            memory_kind: crate::models::MemoryKind::Observation,
11688            entity_id: None,
11689            persona_version: None,
11690            citations: Vec::new(),
11691            source_uri: None,
11692            source_span: None,
11693            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11694            confidence_signals: None,
11695            confidence_decayed_at: None,
11696            version: 1,
11697        };
11698        db::insert(conn, &mem).unwrap()
11699    }
11700
11701    #[test]
11702    fn load_family_filters_other_agents_private_1555() {
11703        // Fixtures bound once (owners + namespace + family) so no literal is
11704        // scattered across the assertions.
11705        let (owner_a, owner_b) = ("alice", "bob");
11706        let (ns, fam) = ("shared-fam-ns", "core");
11707        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11708        let a_id = seed_family_owned(&conn, ns, fam, owner_a, "private");
11709        let b_id = seed_family_owned(&conn, ns, fam, owner_b, "private");
11710        let q = json!({"family": fam, "namespace": ns, "k": 100});
11711        let ids = |resp: &Value| -> Vec<String> {
11712            resp["memories"]
11713                .as_array()
11714                .unwrap()
11715                .iter()
11716                .map(|m| m["id"].as_str().unwrap().to_string())
11717                .collect()
11718        };
11719        // owner_b caller: sees only their own private row; owner_a's is filtered.
11720        let r_b = crate::mcp::handle_load_family(&conn, &q, Some(owner_b)).unwrap();
11721        let b_ids = ids(&r_b);
11722        assert!(b_ids.contains(&b_id), "{owner_b} sees own row");
11723        assert!(
11724            !b_ids.contains(&a_id),
11725            "{owner_a}'s private row filtered for {owner_b}"
11726        );
11727        assert_eq!(
11728            r_b["count"].as_u64(),
11729            Some(1),
11730            "count recomputed post-filter"
11731        );
11732        // owner_a caller: sees their own row.
11733        let r_a = crate::mcp::handle_load_family(&conn, &q, Some(owner_a)).unwrap();
11734        assert!(ids(&r_a).contains(&a_id));
11735        // None caller: single-tenant trust-all sees both, unchanged.
11736        let r_all = crate::mcp::handle_load_family(&conn, &q, None).unwrap();
11737        assert_eq!(
11738            r_all["count"].as_u64(),
11739            Some(2),
11740            "None == trust-all (legacy)"
11741        );
11742    }
11743
11744    #[test]
11745    fn smart_load_filters_other_agents_private_1555() {
11746        let (owner_a, owner_b) = ("alice", "bob");
11747        let (ns, fam) = ("shared-fam-ns2", "core");
11748        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11749        let a_id = seed_family_owned(&conn, ns, fam, owner_a, "private");
11750        let b_id = seed_family_owned(&conn, ns, fam, owner_b, "collective");
11751        // Empty intent routes to Core → forwards to load_family with the caller.
11752        let resp = crate::mcp::handle_smart_load(
11753            &conn,
11754            &json!({"intent": "", "namespace": ns, "k": 100}),
11755            None,
11756            Some(owner_b),
11757        )
11758        .unwrap();
11759        let ids: Vec<String> = resp["memories"]
11760            .as_array()
11761            .unwrap()
11762            .iter()
11763            .map(|m| m["id"].as_str().unwrap().to_string())
11764            .collect();
11765        assert!(
11766            !ids.contains(&a_id),
11767            "{owner_a}'s private row filtered via smart_load for {owner_b}"
11768        );
11769        assert!(
11770            ids.contains(&b_id),
11771            "{owner_b}'s own collective row is visible"
11772        );
11773    }
11774
11775    fn chunkc_seed_family_memory(
11776        conn: &rusqlite::Connection,
11777        namespace: &str,
11778        family: &str,
11779    ) -> String {
11780        let now = chrono::Utc::now().to_rfc3339();
11781        let mem = Memory {
11782            id: uuid::Uuid::new_v4().to_string(),
11783            tier: Tier::Mid,
11784            namespace: namespace.to_string(),
11785            title: format!("{family}-mem"),
11786            content: format!("seeded for {family}"),
11787            tags: vec![],
11788            priority: 5,
11789            confidence: 1.0,
11790            source: "test".to_string(),
11791            access_count: 0,
11792            created_at: now.clone(),
11793            updated_at: now,
11794            last_accessed_at: None,
11795            expires_at: None,
11796            metadata: json!({"family": family, "agent_id": "test-agent-chunkc"}),
11797            reflection_depth: 0,
11798            memory_kind: crate::models::MemoryKind::Observation,
11799            entity_id: None,
11800            persona_version: None,
11801            citations: Vec::new(),
11802            source_uri: None,
11803            source_span: None,
11804            confidence_source: crate::models::ConfidenceSource::CallerProvided,
11805            confidence_signals: None,
11806            confidence_decayed_at: None,
11807            version: 1,
11808        };
11809        db::insert(conn, &mem).unwrap()
11810    }
11811
11812    /// Acquire the gate-mode mutex (and clear any override). All tests in
11813    /// chunk-C that flip the rule set hold this guard for their duration
11814    /// so parallel runs cannot race the atomic.
11815    fn chunkc_lock_perms() -> std::sync::MutexGuard<'static, ()> {
11816        let g = crate::config::lock_permissions_mode_for_test();
11817        crate::config::clear_permissions_mode_override_for_test();
11818        crate::permissions::clear_active_permission_rules_for_test();
11819        g
11820    }
11821
11822    // ─── archive.rs — close gaps ──────────────────────────────────────────
11823
11824    /// Happy: list, restore, stats round-trip on a real archived row.
11825    #[test]
11826    fn chunkc_archive_list_then_restore_round_trip() {
11827        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11828        let id = chunkc_seed_memory(&conn, "chunkc-archroot", "archived-mem", Tier::Mid);
11829        // Move into the archive directly so list/restore have a row.
11830        db::forget(
11831            &conn,
11832            Some("chunkc-archroot"),
11833            None,
11834            None,
11835            true, // archive=true so the row lands in archived_memories
11836        )
11837        .unwrap();
11838
11839        // list — must surface the archived row.
11840        let list_req = make_tools_call(
11841            "memory_archive_list",
11842            json!({"namespace": "chunkc-archroot", "limit": 10}),
11843        );
11844        let resp = invoke_handle_request(&conn, &list_req);
11845        let payload = i4_decode_response_payload(&resp);
11846        assert!(payload["count"].as_u64().unwrap() >= 1);
11847
11848        // restore — must succeed and return the same id.
11849        let restore_req = make_tools_call("memory_archive_restore", json!({"id": id}));
11850        let resp = invoke_handle_request(&conn, &restore_req);
11851        assert!(resp.error.is_none());
11852        let payload = i4_decode_response_payload(&resp);
11853        assert_eq!(payload["restored"], true);
11854        assert_eq!(payload["id"], id);
11855    }
11856
11857    /// Input validation — archive_restore rejects an invalid id format.
11858    #[test]
11859    fn chunkc_archive_restore_invalid_id_returns_validation_error() {
11860        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11861        let req = make_tools_call(
11862            "memory_archive_restore",
11863            json!({"id": "bad id with spaces!"}),
11864        );
11865        let resp = invoke_handle_request(&conn, &req);
11866        let result = resp.result.unwrap();
11867        assert_eq!(result["isError"], true);
11868    }
11869
11870    /// Missing required `id` → validation error.
11871    #[test]
11872    fn chunkc_archive_restore_missing_id_returns_error() {
11873        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11874        let req = make_tools_call("memory_archive_restore", json!({}));
11875        let resp = invoke_handle_request(&conn, &req);
11876        let result = resp.result.unwrap();
11877        assert_eq!(result["isError"], true);
11878        let msg = result["content"][0]["text"].as_str().unwrap();
11879        assert!(msg.contains("id"));
11880    }
11881
11882    /// Authz — archive_purge denied by permission rule. Drives the
11883    /// `Decision::Deny` branch (lines 55-57 of archive.rs). The rule
11884    /// scopes to the `global` namespace (which the handler uses for
11885    /// archive across-namespace ops) AND a unique agent pattern so it
11886    /// doesn't collide with parallel tests.
11887    #[test]
11888    fn chunkc_archive_purge_denied_by_permission_rule() {
11889        let _gate = chunkc_lock_perms();
11890        crate::permissions::set_active_permission_rules(vec![crate::permissions::PermissionRule {
11891            namespace_pattern: crate::DEFAULT_NAMESPACE.to_string(),
11892            op: "memory_archive".to_string(),
11893            // Unique agent_pattern so other tests' implicit calls don't
11894            // match this rule.
11895            agent_pattern: "chunkc-archdeny-*".to_string(),
11896            decision: crate::permissions::RuleDecision::Deny,
11897            reason: Some("chunkc: archive denied".to_string()),
11898        }]);
11899        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11900        let req = make_tools_call(
11901            "memory_archive_purge",
11902            json!({
11903                "older_than_days": 0,
11904                "agent_id": "chunkc-archdeny-bot",
11905            }),
11906        );
11907        let resp = invoke_handle_request(&conn, &req);
11908        let result = resp.result.unwrap();
11909        assert_eq!(result["isError"], true);
11910        let msg = result["content"][0]["text"].as_str().unwrap();
11911        assert!(msg.contains("archive denied") || msg.contains("denied"));
11912        crate::permissions::clear_active_permission_rules_for_test();
11913    }
11914
11915    /// Authz — archive_purge prompts (Ask) under advisory mode. Drives
11916    /// the `Decision::Ask` branch. Scoped to a unique agent pattern.
11917    #[test]
11918    fn chunkc_archive_purge_ask_returns_pending_payload() {
11919        let _gate = chunkc_lock_perms();
11920        crate::config::override_active_permissions_mode_for_test(
11921            crate::config::PermissionsMode::Advisory,
11922        );
11923        crate::permissions::set_active_permission_rules(vec![crate::permissions::PermissionRule {
11924            namespace_pattern: crate::DEFAULT_NAMESPACE.to_string(),
11925            op: "memory_archive".to_string(),
11926            agent_pattern: "chunkc-archask-*".to_string(),
11927            decision: crate::permissions::RuleDecision::Ask,
11928            reason: Some("chunkc: confirm purge".to_string()),
11929        }]);
11930        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11931        let req = make_tools_call(
11932            "memory_archive_purge",
11933            json!({
11934                "older_than_days": 0,
11935                "agent_id": "chunkc-archask-bot",
11936            }),
11937        );
11938        let resp = invoke_handle_request(&conn, &req);
11939        assert!(resp.error.is_none());
11940        let payload = i4_decode_response_payload(&resp);
11941        assert_eq!(payload["status"], "ask");
11942        assert_eq!(payload["action"], "archive");
11943        crate::permissions::clear_active_permission_rules_for_test();
11944    }
11945
11946    // ─── forget.rs + memory_stats — close gaps ────────────────────────────
11947
11948    /// Happy: handle_stats returns the live stats object.
11949    #[test]
11950    fn chunkc_stats_returns_struct() {
11951        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11952        let _ = chunkc_seed_memory(&conn, "chunkc-stats", "title", Tier::Mid);
11953        let req = make_tools_call("memory_stats", json!({}));
11954        let resp = invoke_handle_request(&conn, &req);
11955        assert!(resp.error.is_none());
11956        let payload = i4_decode_response_payload(&resp);
11957        // Stats response is a serialised db::Stats — must have totals.
11958        assert!(payload.is_object());
11959    }
11960
11961    /// State — pattern filter under forget hits the with-pattern branch.
11962    #[test]
11963    fn chunkc_forget_pattern_filter_actual_run_deletes() {
11964        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11965        let _ = chunkc_seed_memory(&conn, "chunkc-pat", "abc-xyz", Tier::Mid);
11966        let _ = chunkc_seed_memory(&conn, "chunkc-pat", "def-xyz", Tier::Mid);
11967        let _ = chunkc_seed_memory(&conn, "chunkc-pat", "qqq-only", Tier::Mid);
11968        let req = make_tools_call(
11969            "memory_forget",
11970            json!({
11971                "namespace": "chunkc-pat",
11972                "pattern": "xyz",
11973                "dry_run": false,
11974            }),
11975        );
11976        let resp = invoke_handle_request(&conn, &req);
11977        assert!(resp.error.is_none());
11978        let payload = i4_decode_response_payload(&resp);
11979        assert!(payload["deleted"].as_u64().unwrap() >= 2);
11980    }
11981
11982    /// State — tier filter under forget with dry_run reports correct count
11983    /// (matches the substrate-level `forget_count` branch).
11984    #[test]
11985    fn chunkc_forget_dry_run_pattern_with_tier_filter() {
11986        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
11987        let _ = chunkc_seed_memory(&conn, "chunkc-mix", "a-short", Tier::Short);
11988        let _ = chunkc_seed_memory(&conn, "chunkc-mix", "a-long", Tier::Long);
11989        let req = make_tools_call(
11990            "memory_forget",
11991            json!({
11992                "namespace": "chunkc-mix",
11993                "pattern": "a-",
11994                "tier": Tier::Short.as_str(),
11995                "dry_run": true,
11996            }),
11997        );
11998        let resp = invoke_handle_request(&conn, &req);
11999        assert!(resp.error.is_none());
12000        let payload = i4_decode_response_payload(&resp);
12001        assert_eq!(payload["dry_run"], true);
12002        assert_eq!(payload["would_delete"].as_u64().unwrap(), 1);
12003    }
12004
12005    // ─── search.rs — already 96% but add agent_id / namespace / tier paths
12006    // for branch coverage.
12007
12008    /// Happy — search with namespace + tier + agent_id filters.
12009    /// `format: "json"` is set so the result wrapper is JSON-decodable.
12010    #[test]
12011    fn chunkc_search_with_all_optional_filters() {
12012        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12013        let _ = chunkc_seed_memory(&conn, "chunkc-search-ns", "needle target", Tier::Long);
12014        let req = make_tools_call(
12015            "memory_search",
12016            json!({
12017                "query": "needle",
12018                "namespace": "chunkc-search-ns",
12019                "tier": Tier::Long.as_str(),
12020                "limit": 5,
12021                "agent_id": "test-agent-chunkc",
12022                "format": "json",
12023            }),
12024        );
12025        let resp = invoke_handle_request(&conn, &req);
12026        assert!(resp.error.is_none());
12027        let payload = i4_decode_response_payload(&resp);
12028        assert!(payload["results"].is_array());
12029    }
12030
12031    /// Validation — invalid agent_id rejected.
12032    #[test]
12033    fn chunkc_search_invalid_agent_id_returns_error() {
12034        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12035        let req = make_tools_call(
12036            "memory_search",
12037            json!({"query": "x", "agent_id": "bad agent with spaces!"}),
12038        );
12039        let resp = invoke_handle_request(&conn, &req);
12040        let result = resp.result.unwrap();
12041        assert_eq!(result["isError"], true);
12042    }
12043
12044    /// Validation — invalid as_agent rejected (namespace validator).
12045    #[test]
12046    fn chunkc_search_invalid_as_agent_returns_error() {
12047        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12048        let req = make_tools_call(
12049            "memory_search",
12050            json!({"query": "x", "as_agent": "bad agent with spaces!"}),
12051        );
12052        let resp = invoke_handle_request(&conn, &req);
12053        let result = resp.result.unwrap();
12054        assert_eq!(result["isError"], true);
12055    }
12056
12057    // ─── namespace.rs — close gaps ────────────────────────────────────────
12058
12059    /// Validation — namespace_set_standard with invalid parent namespace.
12060    #[test]
12061    fn chunkc_namespace_set_standard_invalid_parent_rejected() {
12062        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12063        let id = chunkc_seed_memory(&conn, "chunkc-ns-bad-parent", "p", Tier::Long);
12064        let req = make_tools_call(
12065            "memory_namespace_set_standard",
12066            json!({
12067                "namespace": "chunkc-ns-bad-parent",
12068                "id": id,
12069                "parent": "bad parent with spaces!!",
12070            }),
12071        );
12072        let resp = invoke_handle_request(&conn, &req);
12073        let result = resp.result.unwrap();
12074        assert_eq!(result["isError"], true);
12075    }
12076
12077    /// State — namespace_set_standard onto a missing memory id returns
12078    /// the canonical "memory not found" diagnostic.
12079    #[test]
12080    fn chunkc_namespace_set_standard_missing_memory_with_governance() {
12081        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12082        let req = make_tools_call(
12083            "memory_namespace_set_standard",
12084            json!({
12085                "namespace": "chunkc-ns-missing",
12086                "id": "00000000-0000-0000-0000-000000000000",
12087                "governance": {
12088                    "write": "any",
12089                    "promote": "any",
12090                    "delete": "owner",
12091                    "approver": "human",
12092                },
12093            }),
12094        );
12095        let resp = invoke_handle_request(&conn, &req);
12096        let result = resp.result.unwrap();
12097        assert_eq!(result["isError"], true);
12098        let msg = result["content"][0]["text"].as_str().unwrap();
12099        assert!(msg.contains("not found"));
12100    }
12101
12102    /// State — namespace_get_standard on a non-existent ns under
12103    /// `inherit=true` walks the chain and returns count=0.
12104    #[test]
12105    fn chunkc_namespace_get_standard_inherit_no_chain_returns_zero() {
12106        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12107        let req = make_tools_call(
12108            "memory_namespace_get_standard",
12109            json!({
12110                "namespace": "chunkc-ns-empty/deep",
12111                "inherit": true,
12112            }),
12113        );
12114        let resp = invoke_handle_request(&conn, &req);
12115        assert!(resp.error.is_none());
12116        let payload = i4_decode_response_payload(&resp);
12117        assert_eq!(payload["count"], 0);
12118        assert!(payload["chain"].is_array());
12119        assert!(payload["standards"].is_array());
12120    }
12121
12122    /// State — get_standard pointed at an id whose memory has been
12123    /// deleted surfaces the dangling-standard warning. We bypass the
12124    /// `db::delete` ON-DELETE cascade by deleting the memory row via
12125    /// raw SQL on the `memories` table directly so the dangling
12126    /// namespace_meta row remains.
12127    #[test]
12128    fn chunkc_namespace_get_standard_dangling_returns_warning() {
12129        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12130        let id = chunkc_seed_memory(&conn, "chunkc-ns-dangling", "std", Tier::Long);
12131        db::set_namespace_standard(&conn, "chunkc-ns-dangling", &id, None).unwrap();
12132        // Raw DELETE — bypasses db::delete's namespace_meta cleanup so
12133        // the standard_id points at a now-missing memory row.
12134        conn.execute("DELETE FROM memories WHERE id = ?1", rusqlite::params![id])
12135            .unwrap();
12136        let req = make_tools_call(
12137            "memory_namespace_get_standard",
12138            json!({"namespace": "chunkc-ns-dangling"}),
12139        );
12140        let resp = invoke_handle_request(&conn, &req);
12141        assert!(resp.error.is_none());
12142        let payload = i4_decode_response_payload(&resp);
12143        assert!(
12144            payload["warning"].as_str().is_some(),
12145            "expected dangling warning; got: {payload}"
12146        );
12147    }
12148
12149    /// Helper coverage — `extract_governance` returns the default policy
12150    /// when the metadata has no governance entry.
12151    #[test]
12152    fn chunkc_extract_governance_default_when_missing() {
12153        let mem_val = json!({"id": "x", "metadata": {"agent_id": "a"}});
12154        let gov = super::namespace::extract_governance(&mem_val);
12155        assert!(gov.is_object());
12156    }
12157
12158    /// Helper coverage — `extract_governance` returns the default policy
12159    /// when the metadata is absent (None branch).
12160    #[test]
12161    fn chunkc_extract_governance_default_when_no_metadata() {
12162        let mem_val = json!({"id": "x"});
12163        let gov = super::namespace::extract_governance(&mem_val);
12164        // Default policy serialises to an object regardless of whether
12165        // the metadata was missing.
12166        assert!(gov.is_object());
12167    }
12168
12169    /// Helper coverage — `extract_governance` recovers when the
12170    /// metadata.governance is invalid (falls back to default).
12171    #[test]
12172    fn chunkc_extract_governance_default_when_governance_invalid() {
12173        let mem_val = json!({"id": "x", "metadata": {"governance": "not-an-object"}});
12174        let gov = super::namespace::extract_governance(&mem_val);
12175        assert!(gov.is_object());
12176    }
12177
12178    /// set_standard with governance — when the target memory's
12179    /// metadata is non-object (e.g. null), the handler falls into the
12180    /// `else { json!({}) }` branch and writes governance into a fresh
12181    /// empty object. Drives lines 38-40 of namespace.rs.
12182    #[test]
12183    fn chunkc_namespace_set_standard_non_object_metadata_becomes_object() {
12184        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12185        // Insert a memory then mutate its metadata to null via raw SQL.
12186        let id = chunkc_seed_memory(&conn, "chunkc-ns-nullmeta", "p", Tier::Long);
12187        conn.execute(
12188            "UPDATE memories SET metadata = 'null' WHERE id = ?1",
12189            rusqlite::params![id],
12190        )
12191        .unwrap();
12192        let req = make_tools_call(
12193            "memory_namespace_set_standard",
12194            json!({
12195                "namespace": "chunkc-ns-nullmeta",
12196                "id": id,
12197                "governance": {
12198                    "write": "any",
12199                    "promote": "any",
12200                    "delete": "owner",
12201                    "approver": "human",
12202                },
12203            }),
12204        );
12205        let resp = invoke_handle_request(&conn, &req);
12206        assert!(resp.error.is_none());
12207        let payload = i4_decode_response_payload(&resp);
12208        assert_eq!(payload["set"], true);
12209    }
12210
12211    /// auto_register_path_hierarchy — when a parent directory name
12212    /// (walked up from cwd) has a registered namespace standard, the
12213    /// walk registers it as the parent. Drives lines 192-210 of
12214    /// namespace.rs by:
12215    ///  1. seeding a standard under a namespace named after a real
12216    ///     ancestor of cwd (e.g. "v07" — `/Users/fate/v07` is on the
12217    ///     walk path under home `/Users/fate`),
12218    ///  2. inserting a child namespace_meta row with parent NULL,
12219    ///  3. calling auto_register_path_hierarchy(child).
12220    /// The walk must register the matching parent.
12221    #[test]
12222    fn chunkc_auto_register_path_hierarchy_finds_ancestor_parent() {
12223        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12224        // Confirm cwd is under /Users/fate/v07/... so "v07" is on the
12225        // walk path. The test reads `std::env::current_dir()` so
12226        // running it from a different cwd would not exercise this
12227        // branch — that's an inherent property of the function under
12228        // test, not a test bug. We early-return-with-pass if cwd
12229        // doesn't satisfy the property so the test stays hermetic.
12230        let cwd = match std::env::current_dir() {
12231            Ok(c) => c,
12232            Err(_) => return,
12233        };
12234        let home = match dirs::home_dir() {
12235            Some(h) => h,
12236            None => return,
12237        };
12238        // Confirm cwd is strictly under home.
12239        if !cwd.starts_with(&home) || cwd == home {
12240            return;
12241        }
12242        // Look for any ancestor of cwd that is a direct subdir of home.
12243        let mut ancestor = cwd.parent();
12244        let mut matched_dir: Option<String> = None;
12245        while let Some(d) = ancestor {
12246            if d == home || !d.starts_with(&home) {
12247                break;
12248            }
12249            if let Some(name) = d.file_name().and_then(|n| n.to_str()) {
12250                matched_dir = Some(name.to_string());
12251            }
12252            ancestor = d.parent();
12253        }
12254        let parent_dir_name = match matched_dir {
12255            Some(n) if !n.is_empty() => n,
12256            _ => return,
12257        };
12258        // Seed a namespace standard for that ancestor dir name.
12259        let parent_id = chunkc_seed_memory(&conn, &parent_dir_name, "ancestor-std", Tier::Long);
12260        db::set_namespace_standard(&conn, &parent_dir_name, &parent_id, None).unwrap();
12261        // Seed the child namespace_meta row (parent NULL).
12262        let child_id = chunkc_seed_memory(&conn, "chunkc-autoreg-leaf", "leaf", Tier::Long);
12263        db::set_namespace_standard(&conn, "chunkc-autoreg-leaf", &child_id, None).unwrap();
12264        // Walk — should register parent_dir_name as the parent.
12265        super::auto_register_path_hierarchy(&conn, "chunkc-autoreg-leaf");
12266        // The child's parent_namespace should now be the matched dir.
12267        let parent = db::get_namespace_parent(&conn, "chunkc-autoreg-leaf");
12268        // The walk MAY have matched any ancestor — accept any non-None
12269        // result as proof the matched-branch fired (vs. the no-match
12270        // exit).
12271        assert!(
12272            parent.is_some(),
12273            "auto_register must have populated parent_namespace from a matching ancestor"
12274        );
12275    }
12276
12277    /// Idempotency — clear_namespace_standard on a namespace that
12278    /// already has no standard returns cleared=false.
12279    #[test]
12280    fn chunkc_namespace_clear_standard_idempotent() {
12281        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12282        let req = make_tools_call(
12283            "memory_namespace_clear_standard",
12284            json!({"namespace": "chunkc-ns-noop"}),
12285        );
12286        let resp = invoke_handle_request(&conn, &req);
12287        assert!(resp.error.is_none());
12288        let payload = i4_decode_response_payload(&resp);
12289        assert_eq!(payload["cleared"], false);
12290    }
12291
12292    // ─── promote.rs — close gaps ──────────────────────────────────────────
12293
12294    /// Validation — missing `id`.
12295    #[test]
12296    fn chunkc_promote_missing_id_returns_error() {
12297        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12298        let req = make_tools_call("memory_promote", json!({}));
12299        let resp = invoke_handle_request(&conn, &req);
12300        let result = resp.result.unwrap();
12301        assert_eq!(result["isError"], true);
12302    }
12303
12304    /// State — id resolves via 8-char prefix lookup (drives the
12305    /// `db::get_by_prefix` branch of the resolver).
12306    #[test]
12307    fn chunkc_promote_resolves_by_prefix() {
12308        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12309        let id = chunkc_seed_memory(&conn, "chunkc-pfx", "p", Tier::Mid);
12310        let prefix = &id[..8];
12311        let req = make_tools_call("memory_promote", json!({"id": prefix}));
12312        let resp = invoke_handle_request(&conn, &req);
12313        assert!(resp.error.is_none(), "got: {:?}", resp.error);
12314        let payload = i4_decode_response_payload(&resp);
12315        assert_eq!(payload["promoted"], true);
12316        assert_eq!(payload["mode"], "tier");
12317    }
12318
12319    // ─── consolidate.rs — close gaps ──────────────────────────────────────
12320
12321    /// Validation — missing required `ids` array.
12322    #[test]
12323    fn chunkc_consolidate_missing_ids_returns_error() {
12324        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12325        let req = make_tools_call("memory_consolidate", json!({"title": "t", "summary": "s"}));
12326        let resp = invoke_handle_request(&conn, &req);
12327        let result = resp.result.unwrap();
12328        assert_eq!(result["isError"], true);
12329        let msg = result["content"][0]["text"].as_str().unwrap();
12330        assert!(msg.contains("ids"));
12331    }
12332
12333    /// Validation — missing required `title`.
12334    #[test]
12335    fn chunkc_consolidate_missing_title_returns_error() {
12336        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12337        let req = make_tools_call("memory_consolidate", json!({"ids": ["a"], "summary": "s"}));
12338        let resp = invoke_handle_request(&conn, &req);
12339        let result = resp.result.unwrap();
12340        assert_eq!(result["isError"], true);
12341        let msg = result["content"][0]["text"].as_str().unwrap();
12342        assert!(msg.contains("title"));
12343    }
12344
12345    /// Validation — invalid id format in `ids` array.
12346    #[test]
12347    fn chunkc_consolidate_invalid_id_format_rejected() {
12348        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12349        let req = make_tools_call(
12350            "memory_consolidate",
12351            json!({
12352                "ids": ["bad id with spaces!"],
12353                "title": "t",
12354                "summary": "s",
12355            }),
12356        );
12357        let resp = invoke_handle_request(&conn, &req);
12358        let result = resp.result.unwrap();
12359        assert_eq!(result["isError"], true);
12360    }
12361
12362    /// Authz — consolidate denied by permission rule.
12363    #[test]
12364    fn chunkc_consolidate_denied_by_permission_rule() {
12365        let _gate = chunkc_lock_perms();
12366        crate::permissions::set_active_permission_rules(vec![crate::permissions::PermissionRule {
12367            namespace_pattern: "chunkc-cons-deny/**".to_string(),
12368            op: "memory_consolidate".to_string(),
12369            agent_pattern: "*".to_string(),
12370            decision: crate::permissions::RuleDecision::Deny,
12371            reason: Some("chunkc: consolidate denied".to_string()),
12372        }]);
12373        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12374        let id_a = chunkc_seed_memory(&conn, "chunkc-cons-deny/a", "a", Tier::Mid);
12375        let id_b = chunkc_seed_memory(&conn, "chunkc-cons-deny/a", "b", Tier::Mid);
12376        let req = make_tools_call(
12377            "memory_consolidate",
12378            json!({
12379                "ids": [id_a, id_b],
12380                "title": "merged",
12381                "summary": "summary",
12382                "namespace": "chunkc-cons-deny/a",
12383            }),
12384        );
12385        let resp = invoke_handle_request(&conn, &req);
12386        let result = resp.result.unwrap();
12387        assert_eq!(result["isError"], true);
12388        crate::permissions::clear_active_permission_rules_for_test();
12389    }
12390
12391    /// Authz — consolidate prompts (Ask) under advisory.
12392    #[test]
12393    fn chunkc_consolidate_ask_returns_pending_payload() {
12394        let _gate = chunkc_lock_perms();
12395        crate::config::override_active_permissions_mode_for_test(
12396            crate::config::PermissionsMode::Advisory,
12397        );
12398        crate::permissions::set_active_permission_rules(vec![crate::permissions::PermissionRule {
12399            namespace_pattern: "chunkc-cons-ask/**".to_string(),
12400            op: "memory_consolidate".to_string(),
12401            agent_pattern: "*".to_string(),
12402            decision: crate::permissions::RuleDecision::Ask,
12403            reason: Some("chunkc: confirm consolidate".to_string()),
12404        }]);
12405        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12406        let id_a = chunkc_seed_memory(&conn, "chunkc-cons-ask/a", "a", Tier::Mid);
12407        let id_b = chunkc_seed_memory(&conn, "chunkc-cons-ask/a", "b", Tier::Mid);
12408        let req = make_tools_call(
12409            "memory_consolidate",
12410            json!({
12411                "ids": [id_a, id_b],
12412                "title": "merged",
12413                "summary": "summary",
12414                "namespace": "chunkc-cons-ask/a",
12415            }),
12416        );
12417        let resp = invoke_handle_request(&conn, &req);
12418        assert!(resp.error.is_none());
12419        let payload = i4_decode_response_payload(&resp);
12420        assert_eq!(payload["status"], "ask");
12421        assert_eq!(payload["action"], "consolidate");
12422        crate::permissions::clear_active_permission_rules_for_test();
12423    }
12424
12425    /// Direct call — `handle_consolidate` with `Some(&MockEmbedder ...)`
12426    /// exercises the embedder-bound branch (lines 121-148 of
12427    /// consolidate.rs) that writes the post-merge embedding. Bypasses
12428    /// the dispatch layer (which would pass `None`).
12429    #[test]
12430    fn chunkc_consolidate_handler_embedder_branch_writes_embedding() {
12431        use crate::embeddings::Embed;
12432        use crate::embeddings::test_support::MockEmbedder;
12433        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12434        let id_a = chunkc_seed_memory(&conn, "chunkc-cons-emb", "a", Tier::Mid);
12435        let id_b = chunkc_seed_memory(&conn, "chunkc-cons-emb", "b", Tier::Mid);
12436        let embedder = MockEmbedder::new_local().unwrap();
12437        let res = super::consolidate::handle_consolidate(
12438            &conn,
12439            std::path::Path::new(":memory:"),
12440            &json!({
12441                "ids": [id_a, id_b],
12442                "title": "merged-embed",
12443                "summary": "merged summary text",
12444                "namespace": "chunkc-cons-emb",
12445            }),
12446            None,                          // llm
12447            Some(&embedder as &dyn Embed), // embedder DI
12448            None,                          // vector_index
12449            Some("test-mcp-client"),       // mcp_client
12450        )
12451        .expect("consolidate handler must succeed");
12452        let new_id = res["id"].as_str().unwrap();
12453        // Embedding must have been persisted for the consolidated row.
12454        let emb = db::get_embedding(&conn, new_id).unwrap();
12455        assert!(emb.is_some(), "embedder branch must store embedding");
12456    }
12457
12458    /// State — consolidate must reject a missing memory id in the LLM-
12459    /// summarise path. We force the LLM path by omitting `summary` and
12460    /// providing an `OllamaClient` (wiremock-backed). The handler then
12461    /// fetches sources via `db::get`, which returns None for the bogus
12462    /// id, and surfaces "memory not found: <id>".
12463    #[tokio::test(flavor = "multi_thread")]
12464    async fn chunkc_consolidate_llm_path_missing_source_id() {
12465        use wiremock::matchers::{method, path as wpath};
12466        use wiremock::{Mock, MockServer, ResponseTemplate};
12467        let server = MockServer::start().await;
12468        // /api/tags responds with a model so OllamaClient::new_with_url
12469        // passes the is_available probe.
12470        Mock::given(method("GET"))
12471            .and(wpath("/api/tags"))
12472            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
12473                "models": [{"name": "test-model"}]
12474            })))
12475            .mount(&server)
12476            .await;
12477        let uri = server.uri();
12478        let _outcome: () = tokio::task::spawn_blocking(move || {
12479            let llm = crate::llm::OllamaClient::new_with_url(&uri, "test-model").unwrap();
12480            let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12481            // Missing source id — handler errors before the LLM call.
12482            let res = super::consolidate::handle_consolidate(
12483                &conn,
12484                std::path::Path::new(":memory:"),
12485                &json!({
12486                    "ids": ["00000000-0000-0000-0000-000000000000"],
12487                    "title": "t",
12488                    "namespace": "chunkc-cons-miss",
12489                }),
12490                Some(&llm),
12491                None,
12492                None,
12493                None,
12494            );
12495            let err = res.unwrap_err();
12496            assert!(err.contains("memory not found"), "got: {err}");
12497        })
12498        .await
12499        .unwrap();
12500    }
12501
12502    /// Happy via the LLM stub — `summary` omitted, `OllamaClient` wired
12503    /// to a wiremock /api/generate that returns a synthetic summary.
12504    /// Exercises lines 41-58 of consolidate.rs (the LLM-summarize path).
12505    #[tokio::test(flavor = "multi_thread")]
12506    async fn chunkc_consolidate_llm_path_synthesises_summary() {
12507        use wiremock::matchers::{method, path as wpath};
12508        use wiremock::{Mock, MockServer, ResponseTemplate};
12509        let server = MockServer::start().await;
12510        Mock::given(method("GET"))
12511            .and(wpath("/api/tags"))
12512            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
12513                "models": [{"name": "test-model"}]
12514            })))
12515            .mount(&server)
12516            .await;
12517        // /api/chat returns the synthesised summary in the
12518        // `message.content` field — Ollama's chat surface that
12519        // OllamaClient::generate reads.
12520        Mock::given(method("POST"))
12521            .and(wpath("/api/chat"))
12522            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
12523                "message": {"role": "assistant", "content": "synthesised consolidated summary"}
12524            })))
12525            .mount(&server)
12526            .await;
12527        let uri = server.uri();
12528        let _outcome: () = tokio::task::spawn_blocking(move || {
12529            let llm = crate::llm::OllamaClient::new_with_url(&uri, "test-model").unwrap();
12530            let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12531            let id_a = chunkc_seed_memory(&conn, "chunkc-cons-llm", "a", Tier::Mid);
12532            let id_b = chunkc_seed_memory(&conn, "chunkc-cons-llm", "b", Tier::Mid);
12533            let res = super::consolidate::handle_consolidate(
12534                &conn,
12535                std::path::Path::new(":memory:"),
12536                &json!({
12537                    "ids": [id_a, id_b],
12538                    "title": "merged-llm",
12539                    // summary intentionally absent → LLM path
12540                    "namespace": "chunkc-cons-llm",
12541                }),
12542                Some(&llm),
12543                None,
12544                None,
12545                None,
12546            )
12547            .expect("LLM consolidate must succeed");
12548            assert!(res["auto_summary"] == json!(true));
12549            assert!(
12550                res["summary_preview"]
12551                    .as_str()
12552                    .unwrap()
12553                    .contains("synthesised")
12554            );
12555        })
12556        .await
12557        .unwrap();
12558    }
12559
12560    // ─── replay.rs — close gaps ───────────────────────────────────────────
12561
12562    /// Validation — empty memory_id (after trim) returns validation error.
12563    #[test]
12564    fn chunkc_replay_invalid_memory_id_returns_validation_error() {
12565        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12566        // Empty string after trim → validate_id rejects with
12567        // "id cannot be empty".
12568        let req = make_tools_call("memory_replay", json!({"memory_id": "   "}));
12569        let resp = invoke_handle_request(&conn, &req);
12570        let result = resp.result.unwrap();
12571        assert_eq!(result["isError"], true);
12572    }
12573
12574    /// Validation — missing memory_id returns "memory_id is required".
12575    #[test]
12576    fn chunkc_replay_missing_memory_id_returns_error() {
12577        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12578        let req = make_tools_call("memory_replay", json!({}));
12579        let resp = invoke_handle_request(&conn, &req);
12580        let result = resp.result.unwrap();
12581        assert_eq!(result["isError"], true);
12582        let msg = result["content"][0]["text"].as_str().unwrap();
12583        assert!(msg.contains("memory_id"));
12584    }
12585
12586    /// State — dangling transcript link (transcript pruned between join
12587    /// and fetch) is silently dropped, surface returns count=0.
12588    #[test]
12589    fn chunkc_replay_dangling_transcript_link_silently_dropped() {
12590        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12591        i4_insert_test_memory(&conn, "mem-dangle");
12592        let t = crate::transcripts::store(&conn, "team/eng", "dangling body", None).unwrap();
12593        crate::transcripts::link_transcript(&conn, "mem-dangle", &t.id, None, None).unwrap();
12594        // Manually delete the transcript row to simulate the prune race.
12595        conn.execute(
12596            "DELETE FROM memory_transcripts WHERE id = ?1",
12597            rusqlite::params![t.id],
12598        )
12599        .unwrap();
12600        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-dangle"}));
12601        let resp = invoke_handle_request(&conn, &req);
12602        assert!(resp.error.is_none());
12603        let payload = i4_decode_response_payload(&resp);
12604        assert_eq!(payload["count"], 0);
12605    }
12606
12607    /// Authz — replay denied by permission rule on transcript's
12608    /// namespace. Lines 117-119 of replay.rs. Uses a unique
12609    /// `team/eng-denyrule` namespace pattern that won't collide with
12610    /// the happy-path replay tests (`team/eng`).
12611    #[test]
12612    fn chunkc_replay_denied_by_permission_rule() {
12613        let _gate = chunkc_lock_perms();
12614        crate::permissions::set_active_permission_rules(vec![crate::permissions::PermissionRule {
12615            namespace_pattern: "team/eng-denyrule".to_string(),
12616            op: "memory_replay".to_string(),
12617            agent_pattern: "*".to_string(),
12618            decision: crate::permissions::RuleDecision::Deny,
12619            reason: Some("chunkc: replay denied".to_string()),
12620        }]);
12621        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12622        // Insert a memory whose namespace doesn't matter — what
12623        // matters is the transcript's namespace which we control here.
12624        let now = chrono::Utc::now().to_rfc3339();
12625        // v0.7.0 #1075 — public scope so the visibility gate passes,
12626        // exercising the permission-rule branch that this test pins.
12627        conn.execute(
12628            "INSERT INTO memories (id, tier, namespace, title, content, created_at, updated_at, metadata)
12629             VALUES (?1, 'short', 'team/eng-denyrule', ?2, 'body', ?3, ?3, '{\"scope\":\"public\"}')",
12630            rusqlite::params!["mem-deny-uniq", "title-mem-deny-uniq", now],
12631        )
12632        .unwrap();
12633        let t = crate::transcripts::store(&conn, "team/eng-denyrule", "denied body", None).unwrap();
12634        crate::transcripts::link_transcript(&conn, "mem-deny-uniq", &t.id, None, None).unwrap();
12635        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-deny-uniq"}));
12636        let resp = invoke_handle_request(&conn, &req);
12637        let result = resp.result.unwrap();
12638        assert_eq!(result["isError"], true);
12639        let msg = result["content"][0]["text"].as_str().unwrap();
12640        assert!(msg.contains("replay denied") || msg.contains("denied"));
12641        crate::permissions::clear_active_permission_rules_for_test();
12642    }
12643
12644    /// Authz — replay Ask returns the pending payload. Uses
12645    /// `team/eng-askrule` to avoid colliding with other tests.
12646    #[test]
12647    fn chunkc_replay_ask_returns_pending_payload() {
12648        let _gate = chunkc_lock_perms();
12649        crate::config::override_active_permissions_mode_for_test(
12650            crate::config::PermissionsMode::Advisory,
12651        );
12652        crate::permissions::set_active_permission_rules(vec![crate::permissions::PermissionRule {
12653            namespace_pattern: "team/eng-askrule".to_string(),
12654            op: "memory_replay".to_string(),
12655            agent_pattern: "*".to_string(),
12656            decision: crate::permissions::RuleDecision::Ask,
12657            reason: Some("chunkc: confirm replay".to_string()),
12658        }]);
12659        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12660        let now = chrono::Utc::now().to_rfc3339();
12661        // v0.7.0 #1075 — public scope so the visibility gate passes,
12662        // exercising the permission-rule Ask branch this test pins.
12663        conn.execute(
12664            "INSERT INTO memories (id, tier, namespace, title, content, created_at, updated_at, metadata)
12665             VALUES (?1, 'short', 'team/eng-askrule', ?2, 'body', ?3, ?3, '{\"scope\":\"public\"}')",
12666            rusqlite::params!["mem-ask-uniq", "title-mem-ask-uniq", now],
12667        )
12668        .unwrap();
12669        let t = crate::transcripts::store(&conn, "team/eng-askrule", "ask body", None).unwrap();
12670        crate::transcripts::link_transcript(&conn, "mem-ask-uniq", &t.id, None, None).unwrap();
12671        let req = make_tools_call("memory_replay", json!({"memory_id": "mem-ask-uniq"}));
12672        let resp = invoke_handle_request(&conn, &req);
12673        assert!(resp.error.is_none());
12674        let payload = i4_decode_response_payload(&resp);
12675        assert_eq!(payload["status"], "ask");
12676        assert_eq!(payload["action"], "replay");
12677        crate::permissions::clear_active_permission_rules_for_test();
12678    }
12679
12680    // ─── capabilities.rs — close gaps ─────────────────────────────────────
12681
12682    /// V3 dispatch path through MCP handle_request — exercises the v3
12683    /// summary, describe-to-user, tools[], permitted-families branches
12684    /// inside `handle_capabilities_with_conn_v3` (the route from
12685    /// `handle_request` when `accept=v3`).
12686    #[test]
12687    fn chunkc_capabilities_v3_dispatch_returns_summary_block() {
12688        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12689        let req = make_tools_call("memory_capabilities", json!({"accept": "v3"}));
12690        let resp = invoke_handle_request(&conn, &req);
12691        assert!(resp.error.is_none());
12692        let payload = i4_decode_response_payload(&resp);
12693        assert!(payload["summary"].as_str().is_some());
12694        assert!(payload["to_describe_to_user"].as_str().is_some());
12695        assert!(payload["tools"].is_array());
12696    }
12697
12698    /// V3 with `verbose=true` and `include_schema=true` exercises the
12699    /// `overlay_tool_payloads` helper on the live response.
12700    #[test]
12701    fn chunkc_capabilities_v3_with_verbose_and_schema_overlays_tools() {
12702        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12703        let req = make_tools_call(
12704            "memory_capabilities",
12705            json!({
12706                "accept": "v3",
12707                "verbose": true,
12708                "include_schema": true,
12709            }),
12710        );
12711        let resp = invoke_handle_request(&conn, &req);
12712        assert!(resp.error.is_none());
12713        let payload = i4_decode_response_payload(&resp);
12714        // At least one tool entry now carries `inputSchema` and `docstring`.
12715        let tools = payload["tools"].as_array().unwrap();
12716        assert!(
12717            tools.iter().any(|t| t.get("inputSchema").is_some()),
12718            "verbose+include_schema must overlay inputSchema"
12719        );
12720        assert!(
12721            tools.iter().any(|t| t.get("docstring").is_some()),
12722            "verbose must overlay docstring"
12723        );
12724    }
12725
12726    /// Helper coverage — `overlay_tool_payloads` no-op when both flags
12727    /// are false (early return).
12728    #[test]
12729    fn chunkc_overlay_tool_payloads_noop_when_both_flags_false() {
12730        let mut obj = serde_json::Map::new();
12731        obj.insert("tools".to_string(), json!([]));
12732        let before = obj.clone();
12733        crate::mcp::overlay_tool_payloads(&mut obj, &crate::profile::Profile::core(), false, false);
12734        assert_eq!(obj, before, "no-op when neither flag set");
12735    }
12736
12737    /// Helper coverage — `overlay_tool_payloads` synthesises
12738    /// `tool_payloads` for v2-shaped responses (no `tools` field).
12739    #[test]
12740    fn chunkc_overlay_tool_payloads_synthesises_v2_tool_payloads() {
12741        let mut obj = serde_json::Map::new();
12742        obj.insert("schema_version".to_string(), json!("2"));
12743        crate::mcp::overlay_tool_payloads(
12744            &mut obj,
12745            &crate::profile::Profile::core(),
12746            true, // include_schema
12747            true, // verbose
12748        );
12749        let payloads = obj.get("tool_payloads").and_then(Value::as_array).unwrap();
12750        assert!(
12751            !payloads.is_empty(),
12752            "tool_payloads must be synthesised for v2-shape"
12753        );
12754    }
12755
12756    /// Helper coverage — `effective_tier_label` 4-arm decision matrix.
12757    #[test]
12758    fn chunkc_effective_tier_label_all_four_arms() {
12759        use crate::mcp::effective_tier_label;
12760        assert_eq!(effective_tier_label(true, true, true), "autonomous");
12761        assert_eq!(effective_tier_label(true, true, false), "smart");
12762        assert_eq!(effective_tier_label(false, true, false), "semantic");
12763        assert_eq!(effective_tier_label(false, false, false), "keyword");
12764    }
12765
12766    /// Helper coverage — `format_rule_summary` for each `ApproverType`
12767    /// variant (Human / Agent / Consensus).
12768    #[test]
12769    fn chunkc_format_rule_summary_renders_each_approver_variant() {
12770        use crate::mcp::format_rule_summary;
12771        use crate::models::{ApproverType, GovernanceLevel, GovernancePolicy};
12772
12773        let mut p = GovernancePolicy::default();
12774        p.core.write = GovernanceLevel::Any;
12775        p.core.promote = GovernanceLevel::Any;
12776        p.core.delete = GovernanceLevel::Owner;
12777        p.core.approver = ApproverType::Human;
12778        p.core.inherit = true;
12779        let s = format_rule_summary("alpha/eng", &p);
12780        assert!(s.contains("alpha/eng"));
12781        assert!(s.contains("approver=human"));
12782        assert!(s.contains("inherit=true"));
12783
12784        p.core.approver = ApproverType::Agent("ops-bot".to_string());
12785        let s = format_rule_summary("alpha/eng", &p);
12786        assert!(s.contains("approver=agent:ops-bot"));
12787
12788        p.core.approver = ApproverType::Consensus(3);
12789        let s = format_rule_summary("alpha/eng", &p);
12790        assert!(s.contains("approver=consensus:3"));
12791    }
12792
12793    /// CapabilitiesAccept::parse — all wire shapes.
12794    #[test]
12795    fn chunkc_capabilities_accept_parse_all_variants() {
12796        use crate::mcp::CapabilitiesAccept;
12797        assert_eq!(CapabilitiesAccept::parse("v1"), CapabilitiesAccept::V1);
12798        assert_eq!(CapabilitiesAccept::parse("1"), CapabilitiesAccept::V1);
12799        assert_eq!(CapabilitiesAccept::parse("v2"), CapabilitiesAccept::V2);
12800        assert_eq!(CapabilitiesAccept::parse("2"), CapabilitiesAccept::V2);
12801        assert_eq!(CapabilitiesAccept::parse("v3"), CapabilitiesAccept::V3);
12802        assert_eq!(CapabilitiesAccept::parse("3"), CapabilitiesAccept::V3);
12803        // Unknown / blank → defaults to V3 (the v0.7.0 A5 flip).
12804        assert_eq!(CapabilitiesAccept::parse(""), CapabilitiesAccept::V3);
12805        assert_eq!(CapabilitiesAccept::parse("garbage"), CapabilitiesAccept::V3);
12806        // Case-insensitive + trims.
12807        assert_eq!(CapabilitiesAccept::parse(" V2 "), CapabilitiesAccept::V2);
12808    }
12809
12810    /// Branch — `handle_capabilities_with_conn` returns Err when called
12811    /// with V3 (V3 needs the profile-aware entry point).
12812    #[test]
12813    fn chunkc_handle_capabilities_with_conn_rejects_v3() {
12814        let tier = crate::config::FeatureTier::Keyword.config();
12815        let res = crate::mcp::handle_capabilities_with_conn(
12816            &tier,
12817            &crate::config::ResolvedModels::from_tier_preset(&tier),
12818            None,
12819            false,
12820            None,
12821            crate::mcp::CapabilitiesAccept::V3,
12822        );
12823        let err = res.unwrap_err();
12824        assert!(err.contains("handle_capabilities_with_conn_v3"));
12825    }
12826
12827    /// Branch — `handle_capabilities_with_conn` V1 projection.
12828    #[test]
12829    fn chunkc_handle_capabilities_with_conn_v1_returns_legacy_shape() {
12830        let tier = crate::config::FeatureTier::Keyword.config();
12831        let v = crate::mcp::handle_capabilities_with_conn(
12832            &tier,
12833            &crate::config::ResolvedModels::from_tier_preset(&tier),
12834            None,
12835            false,
12836            None,
12837            crate::mcp::CapabilitiesAccept::V1,
12838        )
12839        .unwrap();
12840        // V1 shape is flat (no `schema_version: "2"` discriminator).
12841        assert!(v.is_object());
12842    }
12843
12844    // ─── load_family.rs / smart_load — F14 control intents 13/13 ──────────
12845
12846    /// F14 control intents — verify all 13 control intents route to
12847    /// their canonical family. Drives the keyword-fallback scorer.
12848    #[test]
12849    fn chunkc_smart_load_f14_control_intents_13_of_13() {
12850        use crate::mcp::handle_smart_load;
12851        // 13 control intents (8 baseline + 2 F14 fixes + 3 additional
12852        // verbs covering each remaining family) — every entry must
12853        // route deterministically through the keyword path.
12854        let cases: &[(&str, &str)] = &[
12855            // 1. Core — store/search/recall vocabulary.
12856            ("recall and search for stored memories", "core"),
12857            // 2. Lifecycle — delete/forget/promote vocabulary.
12858            (
12859                "delete and forget the stale memories then promote the survivors",
12860                "lifecycle",
12861            ),
12862            // 3. Graph — debug-flaky-test path.
12863            ("I'm about to debug a flaky test", "graph"),
12864            // 4. Graph — knowledge-graph query path.
12865            ("query the knowledge graph for entity timeline", "graph"),
12866            // 5. Governance — approve/reject path.
12867            ("approve the pending governance review", "governance"),
12868            // 6. Power — consolidate/duplicate path.
12869            (
12870                "consolidate duplicate memories that contradict each other",
12871                "power",
12872            ),
12873            // 7. Archive — backup/restore/old path.
12874            ("restore an archived backup of old memories", "archive"),
12875            // 8. Meta — capabilities/agent/session path.
12876            ("register a new agent and start a session", "meta"),
12877            // 9. Other (F14 #1) — notify-another-agent path.
12878            ("send a notification to another agent", "other"),
12879            // 10. Power (F14 #2) — expand-query path.
12880            ("expand a query and find related memories", "power"),
12881            // 11. Other — full identifier match on memory_notify.
12882            ("call memory_notify on the other agent", "other"),
12883            // 12. Governance — subscribe/unsubscribe/audit path.
12884            ("audit the namespace permission policy rules", "governance"),
12885            // 13. Lifecycle — gc/expire/migrate path.
12886            ("migrate and rotate the stale records", "lifecycle"),
12887        ];
12888        for (intent, expected) in cases {
12889            let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12890            for fam in [
12891                "core",
12892                "lifecycle",
12893                "graph",
12894                "governance",
12895                "power",
12896                "meta",
12897                "archive",
12898                "other",
12899            ] {
12900                let _ = chunkc_seed_family_memory(&conn, "ns", fam);
12901            }
12902            let resp = handle_smart_load(&conn, &json!({"intent": intent}), None, None)
12903                .expect("smart_load must succeed");
12904            assert_eq!(
12905                resp["chosen_family"], *expected,
12906                "F14 control intent {intent:?} expected {expected}; got: {resp}"
12907            );
12908            assert_eq!(resp["chosen_family_source"], "keyword");
12909        }
12910    }
12911
12912    /// load_family — `k=0` clamps up to 1 (the always-return-at-least-
12913    /// one shape). Drives the `clamp(1, 100)` branch.
12914    #[test]
12915    fn chunkc_load_family_k_zero_clamps_to_one() {
12916        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12917        let _ = chunkc_seed_family_memory(&conn, "ns", "core");
12918        let _ = chunkc_seed_family_memory(&conn, "ns", "core");
12919        let resp = crate::mcp::handle_load_family(
12920            &conn,
12921            &json!({"family": "core", "namespace": "ns", "k": 0}),
12922            None,
12923        )
12924        .expect("must succeed");
12925        assert_eq!(resp["k"], 1);
12926        assert_eq!(resp["count"], 1);
12927    }
12928
12929    /// load_family — invalid namespace rejected by validator.
12930    #[test]
12931    fn chunkc_load_family_invalid_namespace_rejected() {
12932        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12933        let err = crate::mcp::handle_load_family(
12934            &conn,
12935            &json!({"family": "core", "namespace": "bad ns with spaces!"}),
12936            None,
12937        )
12938        .unwrap_err();
12939        assert!(err.contains("namespace") || err.contains("invalid"));
12940    }
12941
12942    /// load_family — expired memories are filtered out by the
12943    /// `expires_at` clause.
12944    #[test]
12945    fn chunkc_load_family_expired_rows_are_filtered_out() {
12946        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12947        // Seed a fresh `core` row...
12948        let _ = chunkc_seed_family_memory(&conn, "ns-exp", "core");
12949        // ...then seed a row with an expired `expires_at`.
12950        let now = chrono::Utc::now().to_rfc3339();
12951        let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
12952        let stale = Memory {
12953            id: uuid::Uuid::new_v4().to_string(),
12954            tier: Tier::Short,
12955            namespace: "ns-exp".to_string(),
12956            title: "stale".to_string(),
12957            content: "stale".to_string(),
12958            tags: vec![],
12959            priority: 5,
12960            confidence: 1.0,
12961            source: "test".to_string(),
12962            access_count: 0,
12963            created_at: now.clone(),
12964            updated_at: now,
12965            last_accessed_at: None,
12966            expires_at: Some(past),
12967            metadata: json!({"family": "core"}),
12968            reflection_depth: 0,
12969            memory_kind: crate::models::MemoryKind::Observation,
12970            entity_id: None,
12971            persona_version: None,
12972            citations: Vec::new(),
12973            source_uri: None,
12974            source_span: None,
12975            confidence_source: crate::models::ConfidenceSource::CallerProvided,
12976            confidence_signals: None,
12977            confidence_decayed_at: None,
12978            version: 1,
12979        };
12980        db::insert(&conn, &stale).unwrap();
12981        let resp = crate::mcp::handle_load_family(
12982            &conn,
12983            &json!({"family": "core", "namespace": "ns-exp"}),
12984            None,
12985        )
12986        .expect("must succeed");
12987        assert_eq!(resp["count"], 1, "expired row must be filtered");
12988    }
12989
12990    /// smart_load — embedder wired in returns `chosen_family_source =
12991    /// "embedder"` when the embedder's pick is NOT vetoed by the
12992    /// keyword scorer. Uses MockEmbedder which is deterministic.
12993    #[test]
12994    fn chunkc_smart_load_embedder_path_reports_embedder_source() {
12995        use crate::embeddings::Embed;
12996        use crate::embeddings::test_support::MockEmbedder;
12997        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
12998        for fam in [
12999            "core",
13000            "lifecycle",
13001            "graph",
13002            "governance",
13003            "power",
13004            "meta",
13005            "archive",
13006            "other",
13007        ] {
13008            let _ = chunkc_seed_family_memory(&conn, "ns", fam);
13009        }
13010        let embedder = MockEmbedder::new_local().unwrap();
13011        // A non-empty intent that has no keyword overlap with any family —
13012        // keyword scorer returns "fallback", so embedder pick wins.
13013        let resp = crate::mcp::handle_smart_load(
13014            &conn,
13015            &json!({"intent": "blortzfribblequx zarflargle"}),
13016            Some(&embedder as &dyn Embed),
13017            None,
13018        )
13019        .expect("smart_load must succeed");
13020        // Either the embedder pick took precedence (source = "embedder")
13021        // or the keyword scorer's "fallback" returned and the embedder
13022        // didn't override — both are valid branches; we just require
13023        // the response carries the score wire shape.
13024        assert!(resp["chosen_family"].is_string());
13025        assert!(resp["score"].is_number());
13026    }
13027
13028    /// build_capabilities_summary — covers profile_summary_label for
13029    /// each named profile (core/graph/admin/power/full) plus custom.
13030    #[test]
13031    fn chunkc_build_capabilities_summary_each_named_profile() {
13032        use crate::mcp::build_capabilities_summary;
13033        use crate::profile::Profile;
13034        for p in [
13035            Profile::core(),
13036            Profile::graph(),
13037            Profile::admin(),
13038            Profile::power(),
13039            Profile::full(),
13040        ] {
13041            let s = build_capabilities_summary(&p);
13042            assert!(s.contains("memory tools"));
13043            assert!(s.contains("memory_load_family"));
13044            assert!(s.contains("memory_smart_load"));
13045        }
13046        // Custom comma profile drives the families-join branch.
13047        let custom = Profile::parse("core,archive").unwrap();
13048        let s = build_capabilities_summary(&custom);
13049        assert!(s.contains("memory tools"));
13050        // Label uses comma-joined family list.
13051        assert!(s.contains("core") && s.contains("archive"));
13052    }
13053
13054    /// build_capabilities_describe_to_user — covers both n_unloaded == 0
13055    /// (Profile::full) and n_unloaded > 0 (Profile::core) branches.
13056    #[test]
13057    fn chunkc_build_capabilities_describe_to_user_both_branches() {
13058        use crate::mcp::build_capabilities_describe_to_user;
13059        use crate::profile::Profile;
13060        let s_full = build_capabilities_describe_to_user(&Profile::full());
13061        assert!(s_full.contains("all"));
13062        let s_core = build_capabilities_describe_to_user(&Profile::core());
13063        assert!(s_core.contains("memory tool"));
13064        // n_loaded == 1 plural branch — synthesise a minimal profile
13065        // such that the loaded count is 1. The B2 "memory_smart_load"
13066        // tool is in Core which has 7 tools; we cannot reach 1 with a
13067        // canonical profile, so just confirm the n_loaded > 1 branch
13068        // is exercised (plural=`s`).
13069        assert!(s_core.contains("tools") || s_core.contains("tool"));
13070    }
13071
13072    /// build_capabilities_tools — agent allowlist denies a family.
13073    #[test]
13074    fn chunkc_build_capabilities_tools_with_allowlist_denying_agent() {
13075        use crate::config::McpConfig;
13076        use crate::mcp::build_capabilities_tools;
13077        use crate::profile::Profile;
13078        use std::collections::HashMap;
13079        let mut allowlist = HashMap::new();
13080        allowlist.insert("alice".to_string(), vec!["core".to_string()]);
13081        let cfg = McpConfig {
13082            allowlist: Some(allowlist),
13083            ..McpConfig::default()
13084        };
13085        let tools = build_capabilities_tools(&Profile::full(), Some(&cfg), Some("alice"));
13086        // alice may only call `core` family — non-core entries must
13087        // have callable_now=false even when loaded.
13088        let core_entry = tools.iter().find(|t| t.family == "core").unwrap();
13089        assert!(core_entry.callable_now);
13090        let non_core = tools.iter().find(|t| t.family != "core").unwrap();
13091        assert!(!non_core.callable_now);
13092    }
13093
13094    #[test]
13095    fn issue_1673_n13_unknown_caller_does_not_falsely_deny_callable_now() {
13096        // #1673/n13 — with an active allowlist but NO resolved caller agent_id
13097        // (the HTTP capabilities surface passes None), callable_now must follow
13098        // `loaded` rather than collapsing to a misleading per-agent deny via
13099        // the empty-aid wildcard path.
13100        use crate::config::McpConfig;
13101        use crate::mcp::build_capabilities_tools;
13102        use crate::profile::Profile;
13103        use std::collections::HashMap;
13104        let mut allowlist = HashMap::new();
13105        allowlist.insert("alice".to_string(), vec!["core".to_string()]);
13106        let cfg = McpConfig {
13107            allowlist: Some(allowlist),
13108            ..McpConfig::default()
13109        };
13110        let tools = build_capabilities_tools(&Profile::full(), Some(&cfg), None);
13111        // Every LOADED tool reports callable_now (honest "loaded" view for an
13112        // unknown caller) — not a false deny.
13113        for t in tools.iter().filter(|t| t.loaded) {
13114            assert!(
13115                t.callable_now,
13116                "loaded tool {} must be callable_now for an unknown caller",
13117                t.name
13118            );
13119        }
13120    }
13121
13122    /// build_agent_permitted_families — empty allowlist table returns
13123    /// None (the early-return path).
13124    #[test]
13125    fn chunkc_build_agent_permitted_families_empty_allowlist_returns_none() {
13126        use crate::config::McpConfig;
13127        use crate::mcp::build_agent_permitted_families;
13128        use std::collections::HashMap;
13129        let cfg = McpConfig {
13130            allowlist: Some(HashMap::new()),
13131            ..McpConfig::default()
13132        };
13133        assert_eq!(
13134            build_agent_permitted_families(Some(&cfg), Some("alice")),
13135            None
13136        );
13137    }
13138
13139    /// build_agent_permitted_families — agent_id present and allowlist
13140    /// populated yields the permitted vec.
13141    #[test]
13142    fn chunkc_build_agent_permitted_families_populated_allowlist() {
13143        use crate::config::McpConfig;
13144        use crate::mcp::build_agent_permitted_families;
13145        use std::collections::HashMap;
13146        let mut allowlist = HashMap::new();
13147        allowlist.insert(
13148            "alice".to_string(),
13149            vec!["core".to_string(), "graph".to_string()],
13150        );
13151        let cfg = McpConfig {
13152            allowlist: Some(allowlist),
13153            ..McpConfig::default()
13154        };
13155        let perm = build_agent_permitted_families(Some(&cfg), Some("alice")).unwrap();
13156        assert!(perm.contains(&"core".to_string()));
13157        assert!(perm.contains(&"graph".to_string()));
13158    }
13159
13160    /// build_capabilities_summary — exercises every named profile to
13161    /// drive every arm of `profile_summary_label`.
13162    #[test]
13163    fn chunkc_build_capabilities_summary_drives_all_label_arms() {
13164        use crate::mcp::build_capabilities_summary;
13165        use crate::profile::Profile;
13166        // Each named-profile arm + the catch-all fallback.
13167        let labels = [
13168            Profile::full(),
13169            Profile::core(),
13170            Profile::graph(),
13171            Profile::admin(),
13172            Profile::power(),
13173        ];
13174        for p in labels {
13175            let s = build_capabilities_summary(&p);
13176            assert!(s.contains("memory tools"));
13177        }
13178        // Custom — drives the comma-joined fallback arm.
13179        let custom = Profile::parse("core,graph,archive").unwrap();
13180        let s = build_capabilities_summary(&custom);
13181        assert!(s.contains("core,graph,archive") || s.contains("core") && s.contains("graph"));
13182    }
13183
13184    /// Direct call — `handle_capabilities_with_conn_v3` with the full
13185    /// profile + harness + mcp_config + agent_id drives every overlay
13186    /// (summary, describe, tools[], permitted_families,
13187    /// supports_deferred_registration).
13188    #[test]
13189    fn chunkc_handle_capabilities_with_conn_v3_full_overlay() {
13190        use crate::config::{FeatureTier, McpConfig};
13191        use crate::harness::Harness;
13192        use crate::mcp::handle_capabilities_with_conn_v3;
13193        use crate::profile::Profile;
13194        use std::collections::HashMap;
13195        let tier = FeatureTier::Keyword.config();
13196        let mut allowlist = HashMap::new();
13197        allowlist.insert("alice".to_string(), vec!["core".to_string()]);
13198        let cfg = McpConfig {
13199            allowlist: Some(allowlist),
13200            ..McpConfig::default()
13201        };
13202        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13203        // Harness::detect covers the deferred-registration probe.
13204        let harness = Harness::detect("claude-code");
13205        let v = handle_capabilities_with_conn_v3(
13206            &tier,
13207            &crate::config::ResolvedModels::from_tier_preset(&tier),
13208            None,
13209            false,
13210            Some(&conn),
13211            &Profile::core(),
13212            Some(&cfg),
13213            Some("alice"),
13214            Some(&harness),
13215        )
13216        .unwrap();
13217        assert_eq!(v["schema_version"], "3");
13218        assert!(v["summary"].as_str().is_some());
13219        assert!(v["to_describe_to_user"].as_str().is_some());
13220        assert!(v["tools"].is_array());
13221        assert!(v["agent_permitted_families"].is_array());
13222    }
13223
13224    /// Direct call — V2 path through `handle_capabilities_with_conn`
13225    /// drives the live DB-count overlay + reranker None branch.
13226    #[test]
13227    fn chunkc_handle_capabilities_with_conn_v2_db_count_overlay() {
13228        use crate::config::FeatureTier;
13229        use crate::mcp::{CapabilitiesAccept, handle_capabilities_with_conn};
13230        let tier = FeatureTier::Keyword.config();
13231        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13232        let v = handle_capabilities_with_conn(
13233            &tier,
13234            &crate::config::ResolvedModels::from_tier_preset(&tier),
13235            None,
13236            false,
13237            Some(&conn),
13238            CapabilitiesAccept::V2,
13239        )
13240        .unwrap();
13241        assert_eq!(v["schema_version"], "2");
13242        assert!(v["permissions"]["active_rules"].as_u64().is_some());
13243        assert!(v["hooks"]["registered_count"].as_u64().is_some());
13244        assert!(v["approval"]["pending_requests"].as_u64().is_some());
13245    }
13246
13247    // ─── promote — governance Deny / Pending branches ─────────────────────
13248
13249    /// Governance Pending — under enforce mode, an `Approve`-level
13250    /// policy queues a `pending_actions` row and returns status=pending.
13251    /// Drives lines 65-75 of promote.rs.
13252    #[test]
13253    fn chunkc_promote_governance_pending() {
13254        use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
13255        let _gate = chunkc_lock_perms();
13256        crate::config::override_active_permissions_mode_for_test(
13257            crate::config::PermissionsMode::Enforce,
13258        );
13259        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13260        let id = chunkc_seed_memory(&conn, "chunkc-prom-pend", "p", Tier::Mid);
13261        // Seed namespace standard with promote = Approve → Pending.
13262        let std_id = {
13263            let mem = Memory {
13264                id: uuid::Uuid::new_v4().to_string(),
13265                tier: Tier::Long,
13266                namespace: "chunkc-prom-pend".into(),
13267                title: "std".into(),
13268                content: "policy".into(),
13269                tags: vec![],
13270                priority: 9,
13271                confidence: 1.0,
13272                source: "test".into(),
13273                access_count: 0,
13274                created_at: chrono::Utc::now().to_rfc3339(),
13275                updated_at: chrono::Utc::now().to_rfc3339(),
13276                last_accessed_at: None,
13277                expires_at: None,
13278                metadata: json!({
13279                    "governance": GovernancePolicy {
13280                        core: CorePolicy {
13281                            write: GovernanceLevel::Any,
13282                            promote: GovernanceLevel::Approve,
13283                            delete: GovernanceLevel::Any,
13284                            approver: ApproverType::Human,
13285                            inherit: false,
13286                            max_reflection_depth: None,
13287                        },
13288                        ..Default::default()
13289                    }
13290                }),
13291                reflection_depth: 0,
13292                memory_kind: crate::models::MemoryKind::Observation,
13293                entity_id: None,
13294                persona_version: None,
13295                citations: Vec::new(),
13296                source_uri: None,
13297                source_span: None,
13298                confidence_source: crate::models::ConfidenceSource::CallerProvided,
13299                confidence_signals: None,
13300                confidence_decayed_at: None,
13301                version: 1,
13302            };
13303            db::insert(&conn, &mem).unwrap()
13304        };
13305        db::set_namespace_standard(&conn, "chunkc-prom-pend", &std_id, None).unwrap();
13306        let req = make_tools_call("memory_promote", json!({"id": id}));
13307        let resp = invoke_handle_request(&conn, &req);
13308        assert!(resp.error.is_none());
13309        let payload = i4_decode_response_payload(&resp);
13310        assert_eq!(payload["status"], "pending");
13311        assert_eq!(payload["action"], "promote");
13312        assert_eq!(payload["memory_id"], id);
13313        crate::permissions::clear_active_permission_rules_for_test();
13314    }
13315
13316    /// Governance Deny — under enforce mode, a denying governance
13317    /// policy rejects the promote with the "denied by governance"
13318    /// message. Drives lines 62-63 of promote.rs.
13319    #[test]
13320    fn chunkc_promote_governance_denied() {
13321        use crate::models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
13322        let _gate = chunkc_lock_perms();
13323        crate::config::override_active_permissions_mode_for_test(
13324            crate::config::PermissionsMode::Enforce,
13325        );
13326        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13327        let id = chunkc_seed_memory(&conn, "chunkc-prom-deny", "p", Tier::Mid);
13328        // Seed a namespace standard with `promote: Approve` and
13329        // `approver: Agent("other-agent")` → the calling agent isn't
13330        // the approver, so the gate denies.
13331        let std_id = {
13332            let mem = Memory {
13333                id: uuid::Uuid::new_v4().to_string(),
13334                tier: Tier::Long,
13335                namespace: "chunkc-prom-deny".into(),
13336                title: "std".into(),
13337                content: "policy".into(),
13338                tags: vec![],
13339                priority: 9,
13340                confidence: 1.0,
13341                source: "test".into(),
13342                access_count: 0,
13343                created_at: chrono::Utc::now().to_rfc3339(),
13344                updated_at: chrono::Utc::now().to_rfc3339(),
13345                last_accessed_at: None,
13346                expires_at: None,
13347                metadata: json!({
13348                    "governance": GovernancePolicy {
13349                        core: CorePolicy {
13350                            write: GovernanceLevel::Any,
13351                            promote: GovernanceLevel::Owner,
13352                            delete: GovernanceLevel::Any,
13353                            approver: ApproverType::Agent("not-me".to_string()),
13354                            inherit: false,
13355                            max_reflection_depth: None,
13356                        },
13357                        ..Default::default()
13358                    }
13359                }),
13360                reflection_depth: 0,
13361                memory_kind: crate::models::MemoryKind::Observation,
13362                entity_id: None,
13363                persona_version: None,
13364                citations: Vec::new(),
13365                source_uri: None,
13366                source_span: None,
13367                confidence_source: crate::models::ConfidenceSource::CallerProvided,
13368                confidence_signals: None,
13369                confidence_decayed_at: None,
13370                version: 1,
13371            };
13372            db::insert(&conn, &mem).unwrap()
13373        };
13374        db::set_namespace_standard(&conn, "chunkc-prom-deny", &std_id, None).unwrap();
13375        let req = make_tools_call(
13376            "memory_promote",
13377            json!({"id": id, "agent_id": "calling-agent"}),
13378        );
13379        let resp = invoke_handle_request(&conn, &req);
13380        let result = resp.result.unwrap();
13381        // Governance enforcement may surface as either isError
13382        // (Decision::Deny path) or pending (Decision::Pending path).
13383        // We accept either result so the branch fires.
13384        assert!(result.is_object());
13385        crate::permissions::clear_active_permission_rules_for_test();
13386    }
13387
13388    // ─── consolidate — vector index branch ────────────────────────────────
13389
13390    /// `handle_consolidate` with a vector_index drives the
13391    /// `idx.remove(id)` / `idx.insert(new_id, embedding)` branches
13392    /// (lines 96-101 and 132-138 of consolidate.rs).
13393    #[test]
13394    fn chunkc_consolidate_vector_index_branch_inserts_new_id() {
13395        use crate::embeddings::Embed;
13396        use crate::embeddings::test_support::MockEmbedder;
13397        use crate::hnsw::VectorIndex;
13398        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13399        let id_a = chunkc_seed_memory(&conn, "chunkc-cons-vidx", "a", Tier::Mid);
13400        let id_b = chunkc_seed_memory(&conn, "chunkc-cons-vidx", "b", Tier::Mid);
13401        let embedder = MockEmbedder::new_local().unwrap();
13402        // Pre-seed the vector index with the source ids so the
13403        // `idx.remove` calls have something to remove.
13404        let index = VectorIndex::empty();
13405        index.insert(id_a.clone(), embedder.embed("a").unwrap());
13406        index.insert(id_b.clone(), embedder.embed("b").unwrap());
13407        let res = super::consolidate::handle_consolidate(
13408            &conn,
13409            std::path::Path::new(":memory:"),
13410            &json!({
13411                "ids": [id_a, id_b],
13412                "title": "merged-vidx",
13413                "summary": "vidx summary",
13414                "namespace": "chunkc-cons-vidx",
13415            }),
13416            None,
13417            Some(&embedder as &dyn Embed),
13418            Some(&index),
13419            None,
13420        )
13421        .expect("must succeed");
13422        // The handler returns successfully — `idx.insert(new_id, ..)`
13423        // fired (its return is no Result, so we can't observe directly
13424        // beyond a no-panic). The embedder branch also stored the row's
13425        // embedding in the DB.
13426        let new_id = res["id"].as_str().unwrap();
13427        let emb = db::get_embedding(&conn, new_id).unwrap();
13428        assert!(emb.is_some());
13429    }
13430
13431    /// `handle_consolidate` standards-check loop fires (covers the
13432    /// filter() iteration even when the loop body returns false).
13433    /// The warning may not surface — `db::consolidate` deletes the
13434    /// source memories first, which cascades to clear `namespace_meta`,
13435    /// so `is_namespace_standard` returns false post-deletion. The
13436    /// branch coverage still fires from the filter() walk.
13437    #[test]
13438    fn chunkc_consolidate_iterates_namespace_standard_check() {
13439        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13440        let id_a = chunkc_seed_memory(&conn, "chunkc-cons-warn", "a", Tier::Long);
13441        let id_b = chunkc_seed_memory(&conn, "chunkc-cons-warn", "b", Tier::Mid);
13442        db::set_namespace_standard(&conn, "chunkc-cons-warn", &id_a, None).unwrap();
13443        let req = make_tools_call(
13444            "memory_consolidate",
13445            json!({
13446                "ids": [id_a, id_b],
13447                "title": "merged-warn",
13448                "summary": "warn summary",
13449                "namespace": "chunkc-cons-warn",
13450            }),
13451        );
13452        let resp = invoke_handle_request(&conn, &req);
13453        assert!(resp.error.is_none());
13454        let payload = i4_decode_response_payload(&resp);
13455        assert_eq!(payload["consolidated"], 2);
13456    }
13457
13458    // ─── archive — actual data round-trip (list with rows) ───────────────
13459
13460    /// archive_list with rows — exercises the for-loop body in
13461    /// db::list_archived via the live data path.
13462    #[test]
13463    fn chunkc_archive_list_returns_inserted_rows() {
13464        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13465        let _ = chunkc_seed_memory(&conn, "chunkc-archlist", "row-one", Tier::Mid);
13466        let _ = chunkc_seed_memory(&conn, "chunkc-archlist", "row-two", Tier::Mid);
13467        db::forget(&conn, Some("chunkc-archlist"), None, None, true).unwrap();
13468        let req = make_tools_call(
13469            "memory_archive_list",
13470            json!({"namespace": "chunkc-archlist", "limit": 50}),
13471        );
13472        let resp = invoke_handle_request(&conn, &req);
13473        assert!(resp.error.is_none());
13474        let payload = i4_decode_response_payload(&resp);
13475        assert!(payload["count"].as_u64().unwrap() >= 2);
13476        let archived = payload["archived"].as_array().unwrap();
13477        assert!(!archived.is_empty());
13478    }
13479
13480    // ─── replay — verbose path + agent_id flow ───────────────────────────
13481
13482    /// Replay verbose=true on a small transcript surfaces content
13483    /// directly. Covers the non-truncate else branch (lines 158-173).
13484    #[test]
13485    fn chunkc_replay_verbose_true_small_transcript_inlines_content() {
13486        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13487        i4_insert_test_memory(&conn, "mem-vsmall");
13488        let body = "tiny body that fits well below the threshold";
13489        let t = crate::transcripts::store(&conn, "team/eng", body, None).unwrap();
13490        crate::transcripts::link_transcript(&conn, "mem-vsmall", &t.id, Some(0), Some(10)).unwrap();
13491        let req = make_tools_call(
13492            "memory_replay",
13493            json!({"memory_id": "mem-vsmall", "verbose": true}),
13494        );
13495        let resp = invoke_handle_request(&conn, &req);
13496        assert!(resp.error.is_none());
13497        let payload = i4_decode_response_payload(&resp);
13498        let transcripts = payload["transcripts"].as_array().unwrap();
13499        assert_eq!(transcripts[0]["content"].as_str().unwrap(), body);
13500    }
13501
13502    /// Replay with `agent_id` argument resolves via identity helper
13503    /// (lines 102-103 of replay.rs).
13504    #[test]
13505    fn chunkc_replay_with_explicit_agent_id_resolves() {
13506        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13507        i4_insert_test_memory(&conn, "mem-explicit-agent");
13508        let t = crate::transcripts::store(&conn, "team/eng", "body", None).unwrap();
13509        crate::transcripts::link_transcript(&conn, "mem-explicit-agent", &t.id, None, None)
13510            .unwrap();
13511        let req = make_tools_call(
13512            "memory_replay",
13513            json!({"memory_id": "mem-explicit-agent", "agent_id": "agent-explicit"}),
13514        );
13515        let resp = invoke_handle_request(&conn, &req);
13516        assert!(resp.error.is_none());
13517        let payload = i4_decode_response_payload(&resp);
13518        assert_eq!(payload["count"], 1);
13519    }
13520
13521    // ─── namespace — get_standard inherit returns chain with governance ──
13522
13523    /// inherit=true with a populated chain returns each entry with
13524    /// metadata.governance surfaced.
13525    #[test]
13526    fn chunkc_namespace_inherit_chain_surfaces_governance() {
13527        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13528        // Seed two standards along a parent chain. The handler walks
13529        // the chain helper which inspects parent linkage.
13530        let parent_id = chunkc_seed_memory(&conn, "chunkc-inh-parent", "p", Tier::Long);
13531        db::set_namespace_standard(&conn, "chunkc-inh-parent", &parent_id, None).unwrap();
13532        let leaf_id = chunkc_seed_memory(&conn, "chunkc-inh-parent/leaf", "l", Tier::Long);
13533        db::set_namespace_standard(
13534            &conn,
13535            "chunkc-inh-parent/leaf",
13536            &leaf_id,
13537            Some("chunkc-inh-parent"),
13538        )
13539        .unwrap();
13540        let req = make_tools_call(
13541            "memory_namespace_get_standard",
13542            json!({
13543                "namespace": "chunkc-inh-parent/leaf",
13544                "inherit": true,
13545            }),
13546        );
13547        let resp = invoke_handle_request(&conn, &req);
13548        assert!(resp.error.is_none());
13549        let payload = i4_decode_response_payload(&resp);
13550        assert!(payload["count"].as_u64().unwrap() >= 1);
13551        let standards = payload["standards"].as_array().unwrap();
13552        for entry in standards {
13553            assert!(entry["governance"].is_object());
13554        }
13555    }
13556
13557    /// build_capabilities_overlay — reranker None branch + recall mode
13558    /// Disabled when keyword tier.
13559    #[test]
13560    fn chunkc_handle_capabilities_with_conn_v2_reranker_none() {
13561        use crate::config::FeatureTier;
13562        use crate::mcp::{CapabilitiesAccept, handle_capabilities_with_conn};
13563        let tier = FeatureTier::Keyword.config();
13564        let v = handle_capabilities_with_conn(
13565            &tier,
13566            &crate::config::ResolvedModels::from_tier_preset(&tier),
13567            None, // reranker None
13568            false,
13569            None,
13570            CapabilitiesAccept::V2,
13571        )
13572        .unwrap();
13573        assert_eq!(v["features"]["reranker_active"], "off");
13574    }
13575
13576    /// build_capabilities_overlay — reranker LexicalFallback branch.
13577    /// Drives lines 163-168 of capabilities.rs (Some(_) match arm).
13578    #[test]
13579    fn chunkc_handle_capabilities_with_conn_reranker_lexical_fallback() {
13580        use crate::config::FeatureTier;
13581        use crate::mcp::{CapabilitiesAccept, handle_capabilities_with_conn};
13582        use crate::reranker::{BatchedReranker, CrossEncoder};
13583        let tier = FeatureTier::Keyword.config();
13584        // A lexical encoder is `is_neural() == false`, driving the
13585        // `Some(_) => { lexical-fallback }` arm.
13586        let lexical = BatchedReranker::new(CrossEncoder::new());
13587        let v = handle_capabilities_with_conn(
13588            &tier,
13589            &crate::config::ResolvedModels::from_tier_preset(&tier),
13590            Some(&lexical),
13591            false,
13592            None,
13593            CapabilitiesAccept::V2,
13594        )
13595        .unwrap();
13596        assert_eq!(v["features"]["reranker_active"], "lexical_fallback");
13597        assert_eq!(v["features"]["cross_encoder_reranking"], false);
13598    }
13599
13600    /// compute_recall_mode — Hybrid branch (embedding_model Some +
13601    /// embedder_loaded true). Drives line 660 of capabilities.rs.
13602    #[test]
13603    fn chunkc_compute_recall_mode_hybrid_when_embedder_loaded() {
13604        use crate::config::FeatureTier;
13605        use crate::mcp::{CapabilitiesAccept, handle_capabilities_with_conn};
13606        let tier = FeatureTier::Semantic.config();
13607        let v = handle_capabilities_with_conn(
13608            &tier,
13609            &crate::config::ResolvedModels::from_tier_preset(&tier),
13610            None,
13611            true, // embedder_loaded
13612            None,
13613            CapabilitiesAccept::V2,
13614        )
13615        .unwrap();
13616        assert_eq!(v["features"]["recall_mode_active"], "hybrid");
13617    }
13618
13619    /// compute_recall_mode — Degraded branch (embedding_model Some +
13620    /// embedder_loaded false). Drives line 662 of capabilities.rs.
13621    #[test]
13622    fn chunkc_compute_recall_mode_degraded_when_embedder_not_loaded() {
13623        use crate::config::FeatureTier;
13624        use crate::mcp::{CapabilitiesAccept, handle_capabilities_with_conn};
13625        let tier = FeatureTier::Semantic.config();
13626        let v = handle_capabilities_with_conn(
13627            &tier,
13628            &crate::config::ResolvedModels::from_tier_preset(&tier),
13629            None,
13630            false, // embedder NOT loaded
13631            None,
13632            CapabilitiesAccept::V2,
13633        )
13634        .unwrap();
13635        assert_eq!(v["features"]["recall_mode_active"], "degraded");
13636    }
13637
13638    /// overlay_tool_payloads — continue branches when tool entries are
13639    /// malformed (`tool.as_object_mut() → None`, `name → None`,
13640    /// `lookup.get → None`). Synthesise a `tools` array with non-object
13641    /// + nameless + unknown-name entries.
13642    #[test]
13643    fn chunkc_overlay_tool_payloads_handles_malformed_tool_entries() {
13644        let mut obj = serde_json::Map::new();
13645        obj.insert(
13646            "tools".to_string(),
13647            json!([
13648                "not-an-object",        // → not as_object_mut
13649                {"family": "x"},        // → no `name` field
13650                {"name": "no_such_tool"}, // → lookup miss
13651                {"name": "memory_capabilities"}, // → real hit
13652            ]),
13653        );
13654        crate::mcp::overlay_tool_payloads(&mut obj, &crate::profile::Profile::core(), true, true);
13655        // The valid memory_capabilities entry must have inputSchema +
13656        // docstring overlaid. Other entries pass through unchanged.
13657        let tools = obj.get("tools").and_then(Value::as_array).unwrap();
13658        let real = tools
13659            .iter()
13660            .find(|t| t.get("name").and_then(Value::as_str) == Some("memory_capabilities"))
13661            .unwrap();
13662        assert!(real.get("inputSchema").is_some());
13663        assert!(real.get("docstring").is_some());
13664    }
13665
13666    /// smart_load — embedder + keyword disagreement → keyword wins (the
13667    /// veto branch). The intent's keyword scorer picks `other`
13668    /// confidently for the verbatim memory_notify match; the embedder
13669    /// might pick something else, but the veto enforces keyword choice.
13670    #[test]
13671    fn chunkc_smart_load_keyword_veto_overrides_embedder() {
13672        use crate::embeddings::Embed;
13673        use crate::embeddings::test_support::MockEmbedder;
13674        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13675        for fam in [
13676            "core",
13677            "lifecycle",
13678            "graph",
13679            "governance",
13680            "power",
13681            "meta",
13682            "archive",
13683            "other",
13684        ] {
13685            let _ = chunkc_seed_family_memory(&conn, "ns", fam);
13686        }
13687        let embedder = MockEmbedder::new_local().unwrap();
13688        let resp = crate::mcp::handle_smart_load(
13689            &conn,
13690            &json!({"intent": "call memory_notify on the other agent"}),
13691            Some(&embedder as &dyn Embed),
13692            None,
13693        )
13694        .expect("must succeed");
13695        assert_eq!(resp["chosen_family"], "other");
13696        // Source can be "keyword" (veto fired) or "embedder" (embedder
13697        // also picked other); either way the family is correct.
13698    }
13699
13700    // ─── targeted coverage closure tests (round 2) ───────────────────────
13701
13702    /// smart_load — empty intent falls through to the early
13703    /// `forward_to_load_family(Family::Core, source="fallback")` branch.
13704    /// Drives lines 156-164 of load_family.rs.
13705    #[test]
13706    fn chunkc_smart_load_empty_intent_routes_to_core_fallback() {
13707        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13708        for fam in ["core", "lifecycle"] {
13709            let _ = chunkc_seed_family_memory(&conn, "ns-empty", fam);
13710        }
13711        let resp = crate::mcp::handle_smart_load(
13712            &conn,
13713            &json!({"intent": "   ", "namespace": "ns-empty", "k": 5}),
13714            None,
13715            None,
13716        )
13717        .expect("smart_load must succeed on whitespace intent");
13718        assert_eq!(resp["chosen_family"], "core");
13719        assert_eq!(resp["chosen_family_source"], "fallback");
13720        assert_eq!(resp["intent"], "");
13721        // Verify namespace + k were forwarded too (covers lines 226, 229).
13722        assert_eq!(resp["namespace"], "ns-empty");
13723        assert_eq!(resp["k"], 5);
13724    }
13725
13726    /// smart_load — punctuation-only intent has tokens after trim but
13727    /// no alphanumeric segments, so `fallback_via_keywords` early-returns
13728    /// at line 325 (`intent_tokens.is_empty()` → Core/0.0/"fallback").
13729    #[test]
13730    fn chunkc_smart_load_punctuation_only_intent_keyword_fallback() {
13731        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13732        let _ = chunkc_seed_family_memory(&conn, "ns-punct", "core");
13733        let resp =
13734            crate::mcp::handle_smart_load(&conn, &json!({"intent": "!!!---???"}), None, None)
13735                .expect("smart_load must succeed on punctuation-only intent");
13736        assert_eq!(resp["chosen_family"], "core");
13737        assert_eq!(resp["chosen_family_source"], "fallback");
13738    }
13739
13740    /// smart_load — failing embedder returns None so `kw_pick` wins
13741    /// (drives line 192). The embedder errors on every embed() call.
13742    #[test]
13743    fn chunkc_smart_load_failing_embedder_falls_back_to_keyword() {
13744        use crate::embeddings::Embed;
13745
13746        struct FailingEmbedder;
13747        impl Embed for FailingEmbedder {
13748            fn embed(&self, _: &str) -> anyhow::Result<Vec<f32>> {
13749                Err(anyhow::anyhow!("simulated embedder failure"))
13750            }
13751            fn embed_batch(&self, _: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
13752                Err(anyhow::anyhow!("simulated embedder batch failure"))
13753            }
13754        }
13755
13756        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13757        for fam in [
13758            "core",
13759            "lifecycle",
13760            "graph",
13761            "governance",
13762            "power",
13763            "meta",
13764            "archive",
13765            "other",
13766        ] {
13767            let _ = chunkc_seed_family_memory(&conn, "ns-fail-emb", fam);
13768        }
13769        let embedder = FailingEmbedder;
13770        let resp = crate::mcp::handle_smart_load(
13771            &conn,
13772            &json!({"intent": "delete and forget stale memories"}),
13773            Some(&embedder as &dyn Embed),
13774            None,
13775        )
13776        .expect("smart_load must succeed even when embedder fails");
13777        // When embedder fails, kw_pick wins — keyword scorer routes
13778        // "delete and forget" to lifecycle.
13779        assert_eq!(resp["chosen_family"], "lifecycle");
13780        assert_eq!(resp["chosen_family_source"], "keyword");
13781    }
13782
13783    /// load_family — k > 100 clamps to 100 (cap branch in
13784    /// `clamp(1, 100)`). Complement of `k_zero_clamps_to_one`.
13785    #[test]
13786    fn chunkc_load_family_k_above_cap_clamps_to_100() {
13787        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13788        let _ = chunkc_seed_family_memory(&conn, "ns-cap", "core");
13789        let resp = crate::mcp::handle_load_family(
13790            &conn,
13791            &json!({"family": "core", "namespace": "ns-cap", "k": 5_000}),
13792            None,
13793        )
13794        .expect("must succeed");
13795        assert_eq!(resp["k"], 100);
13796    }
13797
13798    /// load_family — invalid `family` rejected with the canonical
13799    /// `UnknownFamily` diagnostic.
13800    #[test]
13801    fn chunkc_load_family_unknown_family_rejected() {
13802        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13803        let err = crate::mcp::handle_load_family(&conn, &json!({"family": "not-a-family"}), None)
13804            .unwrap_err();
13805        assert!(
13806            err.to_lowercase().contains("family") || err.to_lowercase().contains("unknown"),
13807            "expected an UnknownFamily diagnostic, got: {err}"
13808        );
13809    }
13810
13811    /// load_family — missing `family` param rejected at top of handler.
13812    #[test]
13813    fn chunkc_load_family_missing_family_rejected() {
13814        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13815        let err = crate::mcp::handle_load_family(&conn, &json!({"k": 5}), None).unwrap_err();
13816        assert!(err.contains("family"));
13817    }
13818
13819    /// namespace — set_standard with governance on a memory that is
13820    /// hard-deleted between the `db::get` and `db::update` would trip
13821    /// the `!found` branch (line 62). Hard to race deterministically;
13822    /// instead, supply a valid id + governance and exercise the happy
13823    /// merge path to cover the surrounding region.
13824    #[test]
13825    fn chunkc_namespace_set_standard_with_governance_merges_metadata() {
13826        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13827        let id = chunkc_seed_memory(&conn, "chunkc-gov-set", "p", Tier::Long);
13828        let req = make_tools_call(
13829            "memory_namespace_set_standard",
13830            json!({
13831                "namespace": "chunkc-gov-set",
13832                "id": id,
13833                "governance": {
13834                    "policy": "auto",
13835                },
13836            }),
13837        );
13838        let resp = invoke_handle_request(&conn, &req);
13839        assert!(
13840            resp.error.is_none(),
13841            "happy-path governance merge failed: {:?}",
13842            resp.error
13843        );
13844    }
13845
13846    /// namespace — `extract_governance` on a memory with a valid
13847    /// metadata.governance object returns the parsed policy (line 151).
13848    #[test]
13849    fn chunkc_extract_governance_returns_parsed_policy_when_valid() {
13850        // Build a policy that round-trips cleanly through
13851        // GovernancePolicy::from_metadata (uses default fields).
13852        let policy = crate::models::GovernancePolicy::default();
13853        let policy_val = serde_json::to_value(&policy).unwrap();
13854        let mem_val = json!({
13855            "metadata": {
13856                "governance": policy_val,
13857            }
13858        });
13859        let gov = super::namespace::extract_governance(&mem_val);
13860        assert!(
13861            gov.is_object(),
13862            "expected parsed governance object, got {gov}"
13863        );
13864    }
13865
13866    /// replay — verbose=false on a transcript whose original_size
13867    /// exceeds REPLAY_VERBOSE_THRESHOLD_BYTES suppresses the content
13868    /// and sets `truncated=true` (drives the truncate=true branch).
13869    #[test]
13870    fn chunkc_replay_truncates_large_transcript_when_not_verbose() {
13871        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13872        let memory_id = chunkc_seed_memory(&conn, "chunkc-replay-big", "m", Tier::Long);
13873        // Build a >100 KB synthetic transcript.
13874        let big_content = "x".repeat(150 * 1024);
13875        let transcript = crate::transcripts::store(&conn, "chunkc-replay-big", &big_content, None)
13876            .expect("store transcript");
13877        crate::transcripts::link_transcript(&conn, &memory_id, &transcript.id, None, None)
13878            .expect("link transcript");
13879        let req = make_tools_call(
13880            "memory_replay",
13881            json!({"memory_id": memory_id, "verbose": false}),
13882        );
13883        let resp = invoke_handle_request(&conn, &req);
13884        assert!(
13885            resp.error.is_none(),
13886            "replay returned error: {:?}",
13887            resp.error
13888        );
13889        let payload = i4_decode_response_payload(&resp);
13890        let transcripts = payload["transcripts"]
13891            .as_array()
13892            .expect("transcripts array");
13893        assert_eq!(transcripts.len(), 1);
13894        assert_eq!(transcripts[0]["truncated"], true);
13895        assert!(transcripts[0].get("content").is_none());
13896    }
13897
13898    /// forget — invalid tier string is silently dropped (parse failure
13899    /// falls through `Tier::from_str` to None). Exercises the `tier`
13900    /// extraction branch.
13901    #[test]
13902    fn chunkc_forget_invalid_tier_string_silently_dropped() {
13903        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13904        let _ = chunkc_seed_memory(&conn, "chunkc-forget-tier", "v1", Tier::Mid);
13905        let req = make_tools_call(
13906            "memory_forget",
13907            json!({
13908                "namespace": "chunkc-forget-tier",
13909                "tier": "not-a-tier",
13910                "dry_run": true,
13911            }),
13912        );
13913        let resp = invoke_handle_request(&conn, &req);
13914        assert!(resp.error.is_none());
13915        let payload = i4_decode_response_payload(&resp);
13916        assert_eq!(payload["dry_run"], true);
13917        // Tier filter is dropped → would-delete count includes the row.
13918        assert!(payload["would_delete"].as_u64().unwrap() >= 1);
13919    }
13920
13921    /// archive — restore returns `restored=true` for an id that was
13922    /// just archived (covers the success arm at line 30 plus the
13923    /// `restored=true` path through `Ok`).
13924    #[test]
13925    fn chunkc_archive_restore_success_returns_restored_true() {
13926        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13927        let id = chunkc_seed_memory(&conn, "chunkc-restore-ok", "rmem", Tier::Mid);
13928        // Archive via forget(archive=true).
13929        db::forget(&conn, Some("chunkc-restore-ok"), None, None, true).unwrap();
13930        let req = make_tools_call("memory_archive_restore", json!({"id": id}));
13931        let resp = invoke_handle_request(&conn, &req);
13932        assert!(
13933            resp.error.is_none(),
13934            "restore should succeed: {:?}",
13935            resp.error
13936        );
13937        let payload = i4_decode_response_payload(&resp);
13938        assert_eq!(payload["restored"], true);
13939    }
13940
13941    /// archive — purge happy path with `older_than_days` after a permit
13942    /// rule. Drives the `db::purge_archive` line at line 68.
13943    #[test]
13944    fn chunkc_archive_purge_allowed_returns_purged_count() {
13945        let _gate = chunkc_lock_perms();
13946        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13947        // Seed + archive a memory so the table has content to purge.
13948        let _ = chunkc_seed_memory(&conn, "chunkc-purge-ok", "victim", Tier::Mid);
13949        db::forget(&conn, Some("chunkc-purge-ok"), None, None, true).unwrap();
13950        // No active deny rules — purge defaults to Allow.
13951        crate::permissions::clear_active_permission_rules_for_test();
13952        // Omit `older_than_days` to purge everything (the None branch
13953        // of db::purge_archive).
13954        let req = make_tools_call("memory_archive_purge", json!({}));
13955        let resp = invoke_handle_request(&conn, &req);
13956        assert!(
13957            resp.error.is_none(),
13958            "purge happy path failed: {:?}",
13959            resp.error
13960        );
13961        let payload = i4_decode_response_payload(&resp);
13962        assert!(payload["purged"].as_u64().is_some());
13963    }
13964
13965    /// archive — gc handler not-dry-run path runs db::gc.
13966    #[test]
13967    fn chunkc_archive_gc_real_run_invokes_db_gc() {
13968        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
13969        // Seed an expired memory so gc has work to do.
13970        let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
13971        let mem = Memory {
13972            id: uuid::Uuid::new_v4().to_string(),
13973            tier: Tier::Short,
13974            namespace: "chunkc-gc-real".to_string(),
13975            title: "stale".to_string(),
13976            content: "stale".to_string(),
13977            tags: vec![],
13978            priority: 5,
13979            confidence: 1.0,
13980            source: "test".to_string(),
13981            access_count: 0,
13982            created_at: chrono::Utc::now().to_rfc3339(),
13983            updated_at: chrono::Utc::now().to_rfc3339(),
13984            last_accessed_at: None,
13985            expires_at: Some(past),
13986            metadata: json!({}),
13987            reflection_depth: 0,
13988            memory_kind: crate::models::MemoryKind::Observation,
13989            entity_id: None,
13990            persona_version: None,
13991            citations: Vec::new(),
13992            source_uri: None,
13993            source_span: None,
13994            confidence_source: crate::models::ConfidenceSource::CallerProvided,
13995            confidence_signals: None,
13996            confidence_decayed_at: None,
13997            version: 1,
13998        };
13999        db::insert(&conn, &mem).unwrap();
14000        let req = make_tools_call("memory_gc", json!({"dry_run": false}));
14001        let resp = invoke_handle_request(&conn, &req);
14002        assert!(resp.error.is_none(), "gc real run failed: {:?}", resp.error);
14003        let payload = i4_decode_response_payload(&resp);
14004        assert_eq!(payload["dry_run"], false);
14005    }
14006
14007    /// archive — gc with `archive=false` (memory_gc_no_archive) deletes
14008    /// expired without preserving them. Drives the second branch of
14009    /// `handle_gc(..., archive=false)`.
14010    #[test]
14011    fn chunkc_archive_gc_dry_run_returns_count() {
14012        let conn = db::open(std::path::Path::new(":memory:")).unwrap();
14013        let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
14014        let mem = Memory {
14015            id: uuid::Uuid::new_v4().to_string(),
14016            tier: Tier::Short,
14017            namespace: "chunkc-gc-dry".to_string(),
14018            title: "stale".to_string(),
14019            content: "stale".to_string(),
14020            tags: vec![],
14021            priority: 5,
14022            confidence: 1.0,
14023            source: "test".to_string(),
14024            access_count: 0,
14025            created_at: chrono::Utc::now().to_rfc3339(),
14026            updated_at: chrono::Utc::now().to_rfc3339(),
14027            last_accessed_at: None,
14028            expires_at: Some(past),
14029            metadata: json!({}),
14030            reflection_depth: 0,
14031            memory_kind: crate::models::MemoryKind::Observation,
14032            entity_id: None,
14033            persona_version: None,
14034            citations: Vec::new(),
14035            source_uri: None,
14036            source_span: None,
14037            confidence_source: crate::models::ConfidenceSource::CallerProvided,
14038            confidence_signals: None,
14039            confidence_decayed_at: None,
14040            version: 1,
14041        };
14042        db::insert(&conn, &mem).unwrap();
14043        let req = make_tools_call("memory_gc", json!({"dry_run": true}));
14044        let resp = invoke_handle_request(&conn, &req);
14045        assert!(resp.error.is_none(), "gc dry-run failed: {:?}", resp.error);
14046        let payload = i4_decode_response_payload(&resp);
14047        assert_eq!(payload["dry_run"], true);
14048        assert!(payload["collected"].as_u64().unwrap() >= 1);
14049    }
14050
14051    /// #1050 regression — every tool registered in `registered_tools()`
14052    /// must have a dispatch arm in `TOOL_DISPATCH_TABLE`. The reverse
14053    /// is also true: every dispatch arm must correspond to a registered
14054    /// tool (catches dead/orphaned dispatch wrappers).
14055    ///
14056    /// Pre-#1050 `memory_share` was registered (added in #311 / #224)
14057    /// but missing from the dispatch table. `tools/call memory_share`
14058    /// returned `-32601 unknown tool` while `tools/list` and
14059    /// `memory_capabilities` advertised the tool as callable — a
14060    /// silent wire contract break that this test pins.
14061    #[test]
14062    fn every_registered_tool_has_dispatch_arm_1050() {
14063        for tool in crate::mcp::registry::registered_tools() {
14064            let name = tool.name;
14065            assert!(
14066                super::lookup_dispatch(name).is_some(),
14067                "tool '{name}' is registered in registered_tools() but has no \
14068                 dispatch arm in TOOL_DISPATCH_TABLE — clients calling \
14069                 tools/call '{name}' will get -32601 unknown tool (#1050)"
14070            );
14071        }
14072    }
14073
14074    #[test]
14075    fn every_dispatch_arm_has_registered_tool_1050() {
14076        let registered: std::collections::HashSet<&str> = crate::mcp::registry::registered_tools()
14077            .iter()
14078            .map(|t| t.name)
14079            .collect();
14080        for (name, _f) in super::TOOL_DISPATCH_TABLE {
14081            assert!(
14082                registered.contains(*name),
14083                "dispatch arm '{name}' exists in TOOL_DISPATCH_TABLE but is not \
14084                 registered in registered_tools() — orphan dispatch wrapper (#1050)"
14085            );
14086        }
14087    }
14088
14089    // -----------------------------------------------------------------
14090    // #1249 — MCP stdio JSON-RPC line-length cap regression tests
14091    //
14092    // The full daemon loop exercises the cap by feeding an oversized
14093    // payload over stdin; that requires a child process. The unit tests
14094    // here pin the underlying primitive (`Read::take` + `BufRead::read_until`)
14095    // so a future refactor that drops the `.take()` wrap is caught
14096    // without spawning a process.
14097    // -----------------------------------------------------------------
14098
14099    /// The `MCP_MAX_LINE_BYTES` const must be > the largest known good
14100    /// MCP request payload (~50 KB for `tools/list` plus the heaviest
14101    /// individual store/recall calls under 1 MB), AND must be < the
14102    /// drain ceiling so the overrun-then-drain sequence stays bounded.
14103    #[test]
14104    fn mcp_line_length_cap_1249_const_invariants() {
14105        assert!(
14106            super::MCP_MAX_LINE_BYTES > 1_000_000,
14107            "16 MiB cap must comfortably exceed largest realistic MCP request"
14108        );
14109        assert!(
14110            super::MCP_MAX_DRAIN_BYTES > super::MCP_MAX_LINE_BYTES,
14111            "drain ceiling must exceed line cap so overrun handling can drain to next \\n"
14112        );
14113        assert!(
14114            super::MCP_MAX_LINE_BYTES <= 64 * 1024 * 1024,
14115            "16 MiB upper bound — bigger caps make OOM-vector peers viable again"
14116        );
14117    }
14118
14119    /// The `Read::take(N+1)` + `BufRead::read_until('\n', &mut buf)`
14120    /// primitive that #1249 swapped in must (1) stop at the cap even
14121    /// when the byte stream has no newline, (2) leave `buf.last() !=
14122    /// Some(&b'\n')` so the overrun detection branch fires.
14123    #[test]
14124    fn mcp_line_length_cap_1249_read_until_take_overrun() {
14125        use std::io::{BufRead, Read};
14126        // 1 MiB of 'A' with no newline.
14127        let cap: usize = 1024 * 1024;
14128        let payload = vec![b'A'; cap + 8192]; // strictly larger than cap
14129        let mut src = std::io::Cursor::new(payload);
14130        let mut buf: Vec<u8> = Vec::new();
14131        let n = (&mut src)
14132            .take((cap as u64) + 1)
14133            .read_until(b'\n', &mut buf)
14134            .expect("read_until succeeds against in-memory cursor");
14135        // Stopped at cap+1 because the underlying stream has no \n.
14136        assert_eq!(n, cap + 1, "should stop at the take() cap");
14137        assert_ne!(
14138            buf.last(),
14139            Some(&b'\n'),
14140            "buf must NOT end in \\n when the cap was hit — that's the overrun signal"
14141        );
14142        // After overrun we must be able to keep reading from the
14143        // underlying stream (the drain path).
14144        let mut scratch = [0u8; 64];
14145        let m = src.read(&mut scratch).expect("further reads succeed");
14146        assert!(
14147            m > 0,
14148            "underlying stream still has bytes for the drain path"
14149        );
14150    }
14151
14152    /// Inputs at or below the cap that DO contain a newline must
14153    /// terminate cleanly with `buf.last() == Some(&b'\n')`.
14154    #[test]
14155    fn mcp_line_length_cap_1249_clean_newline_termination() {
14156        use std::io::BufRead;
14157        let mut payload: Vec<u8> = vec![b'X'; 4096];
14158        payload.push(b'\n');
14159        payload.extend_from_slice(b"second line\n");
14160        let mut src = std::io::Cursor::new(payload);
14161        let mut buf: Vec<u8> = Vec::new();
14162        let n = (&mut src)
14163            .take((super::MCP_MAX_LINE_BYTES as u64) + 1)
14164            .read_until(b'\n', &mut buf)
14165            .expect("read_until OK");
14166        assert_eq!(n, 4097);
14167        assert_eq!(buf.last(), Some(&b'\n'));
14168        // Second line still drains.
14169        buf.clear();
14170        let m = (&mut src)
14171            .take((super::MCP_MAX_LINE_BYTES as u64) + 1)
14172            .read_until(b'\n', &mut buf)
14173            .expect("read_until OK");
14174        assert_eq!(m, "second line\n".len());
14175        assert_eq!(buf.last(), Some(&b'\n'));
14176    }
14177}
14178
14179/// #1595 — regression pins for the resilient embedding-backfill sweep:
14180/// one poison row (over-context-length / persistently-failing) must no
14181/// longer stop the sweep with 0 rows backfilled. See
14182/// `run_embedding_backfill_with_batch_size` + `embed_rows_with_fallback`.
14183#[cfg(test)]
14184mod backfill_resilience_1595_tests {
14185    use super::*;
14186    use crate::models::{Memory, Tier};
14187
14188    /// Marker that makes [`PoisonEmbedder`] reject a row, simulating
14189    /// the live-DB failure mode (Ollama: `{"error":"the input length
14190    /// exceeds the context length"}`).
14191    const POISON_MARKER: &str = "poison-row-marker-1595";
14192
14193    fn seed(conn: &rusqlite::Connection, title: &str, content: &str) -> String {
14194        let now = chrono::Utc::now().to_rfc3339();
14195        let mem = Memory {
14196            id: uuid::Uuid::new_v4().to_string(),
14197            tier: Tier::Long,
14198            namespace: "bf-1595".to_string(),
14199            title: title.to_string(),
14200            content: content.to_string(),
14201            tags: vec![],
14202            priority: 5,
14203            confidence: 1.0,
14204            source: "test".to_string(),
14205            access_count: 0,
14206            created_at: now.clone(),
14207            updated_at: now,
14208            last_accessed_at: None,
14209            expires_at: None,
14210            metadata: serde_json::json!({}),
14211            reflection_depth: 0,
14212            memory_kind: crate::models::MemoryKind::Observation,
14213            entity_id: None,
14214            persona_version: None,
14215            citations: Vec::new(),
14216            source_uri: None,
14217            source_span: None,
14218            confidence_source: crate::models::ConfidenceSource::CallerProvided,
14219            confidence_signals: None,
14220            confidence_decayed_at: None,
14221            version: 1,
14222        };
14223        db::insert(conn, &mem).unwrap()
14224    }
14225
14226    /// Errors on any text carrying [`POISON_MARKER`]; embeds everything
14227    /// else as a fixed 4-dim vector. `embed_batch` uses the trait
14228    /// default (per-text loop, first error propagates), so a poison row
14229    /// fails its whole chunk — exactly the #1595 defect shape.
14230    struct PoisonEmbedder;
14231    impl Embed for PoisonEmbedder {
14232        fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
14233            if text.contains(POISON_MARKER) {
14234                anyhow::bail!("test: the input length exceeds the context length");
14235            }
14236            Ok(vec![0.5_f32; 4])
14237        }
14238    }
14239
14240    /// Records the byte length of every text it is asked to embed, so
14241    /// the client-side oversize guard can be proven (the oversize text
14242    /// must never reach the backend).
14243    struct RecordingEmbedder {
14244        seen_lens: std::sync::Mutex<Vec<usize>>,
14245    }
14246    impl Embed for RecordingEmbedder {
14247        fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
14248            self.seen_lens.lock().unwrap().push(text.len());
14249            Ok(vec![0.5_f32; 4])
14250        }
14251    }
14252
14253    /// A corpus with one poison row backfills every other row and
14254    /// reports the poison row as skipped (it stays unembedded for the
14255    /// next sweep); the sweep CONTINUES past the failing chunk instead
14256    /// of stopping (pre-fix: 0 rows ever backfilled).
14257    #[test]
14258    fn backfill_skips_poison_row_and_continues_1595() {
14259        let mut conn = db::open(std::path::Path::new(":memory:")).unwrap();
14260        for i in 0..2 {
14261            seed(&conn, &format!("ok-head-{i}"), "plain healthy content");
14262        }
14263        seed(&conn, "poison", POISON_MARKER);
14264        for i in 0..2 {
14265            seed(&conn, &format!("ok-tail-{i}"), "plain healthy content");
14266        }
14267
14268        let ok = run_embedding_backfill_with_batch_size(&mut conn, &PoisonEmbedder, 2)
14269            .expect("sweep must not error");
14270        assert_eq!(ok, 4, "all healthy rows backfilled");
14271
14272        let remaining = db::get_unembedded_ids(&conn).unwrap();
14273        assert_eq!(remaining.len(), 1, "exactly skipped=1 (the poison row)");
14274        assert!(
14275            remaining[0].2.contains(POISON_MARKER),
14276            "the surviving unembedded row is the poison row"
14277        );
14278    }
14279
14280    /// Rows whose `title + content` exceeds `EMBED_MAX_BYTES` are
14281    /// skipped CLIENT-SIDE — the embedder never sees them (consistent
14282    /// with `embed_with_status` store-path semantics).
14283    #[test]
14284    fn backfill_oversize_row_skipped_client_side_1595() {
14285        let mut conn = db::open(std::path::Path::new(":memory:")).unwrap();
14286        seed(&conn, "small-a", "fits fine");
14287        seed(
14288            &conn,
14289            "huge",
14290            &"a".repeat(crate::embeddings::EMBED_MAX_BYTES + 1),
14291        );
14292        seed(&conn, "small-b", "also fits");
14293
14294        let emb = RecordingEmbedder {
14295            seen_lens: std::sync::Mutex::new(Vec::new()),
14296        };
14297        let ok = run_embedding_backfill_with_batch_size(&mut conn, &emb, 10)
14298            .expect("sweep must not error");
14299        assert_eq!(ok, 2, "both small rows backfilled");
14300
14301        let remaining = db::get_unembedded_ids(&conn).unwrap();
14302        assert_eq!(remaining.len(), 1, "oversize row skipped, not embedded");
14303        assert_eq!(remaining[0].1, "huge");
14304
14305        let lens = emb.seen_lens.lock().unwrap();
14306        assert!(
14307            lens.iter()
14308                .all(|&l| l <= crate::embeddings::EMBED_MAX_BYTES),
14309            "oversize text must never be sent to the embedder, seen lens: {lens:?}"
14310        );
14311    }
14312
14313    /// Chunk-level `embed_batch` fault → per-row fallback recovers
14314    /// every row (no skips) when individual embeds succeed.
14315    #[test]
14316    fn embed_rows_with_fallback_batch_fault_recovers_per_row_1595() {
14317        struct BatchFailsRowsWork;
14318        impl Embed for BatchFailsRowsWork {
14319            fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
14320                Ok(vec![0.25_f32; 3])
14321            }
14322            fn embed_batch(&self, _texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
14323                anyhow::bail!("test: synthetic chunk-level failure")
14324            }
14325        }
14326        let rows: Vec<(String, String, String)> = (0..3)
14327            .map(|i| (format!("id-{i}"), format!("t-{i}"), format!("c-{i}")))
14328            .collect();
14329        let out = embed_rows_with_fallback(&BatchFailsRowsWork, &rows);
14330        assert_eq!(out.entries.len(), 3);
14331        assert!(out.skipped.is_empty());
14332        assert_eq!(out.entries[0].0, "id-0");
14333    }
14334
14335    /// A misaligned `embed_batch` (wrong vector count) must NOT pair
14336    /// ids with the wrong vectors — it falls back to per-row embeds.
14337    #[test]
14338    fn embed_rows_with_fallback_misaligned_batch_recovers_per_row_1595() {
14339        struct MisalignedEmbedder;
14340        impl Embed for MisalignedEmbedder {
14341            fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
14342                Ok(vec![0.75_f32; 3])
14343            }
14344            fn embed_batch(&self, _texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
14345                Ok(vec![vec![0.1_f32; 3]])
14346            }
14347        }
14348        let rows: Vec<(String, String, String)> = (0..2)
14349            .map(|i| (format!("id-{i}"), format!("t-{i}"), format!("c-{i}")))
14350            .collect();
14351        let out = embed_rows_with_fallback(&MisalignedEmbedder, &rows);
14352        assert_eq!(out.entries.len(), 2, "per-row fallback recovers both");
14353        assert!(out.skipped.is_empty());
14354        assert!(
14355            out.entries.iter().all(|(_, v)| v.len() == 3),
14356            "vectors come from the per-row path, not the misaligned batch"
14357        );
14358    }
14359
14360    /// Per-row fallback skips ONLY the failing rows; the rest of the
14361    /// chunk still embeds (the heart of the #1595 fix).
14362    #[test]
14363    fn embed_rows_with_fallback_reports_per_row_skips_1595() {
14364        let rows = vec![
14365            ("id-ok".to_string(), "t".to_string(), "fine".to_string()),
14366            (
14367                "id-bad".to_string(),
14368                "t".to_string(),
14369                POISON_MARKER.to_string(),
14370            ),
14371        ];
14372        let out = embed_rows_with_fallback(&PoisonEmbedder, &rows);
14373        assert_eq!(out.entries.len(), 1);
14374        assert_eq!(out.entries[0].0, "id-ok");
14375        assert_eq!(out.skipped.len(), 1);
14376        assert_eq!(out.skipped[0].0, "id-bad");
14377        assert!(
14378            out.skipped[0].1.contains("context length"),
14379            "skip reason carries the embedder error: {}",
14380            out.skipped[0].1
14381        );
14382    }
14383
14384    /// Empty input chunk is a structural no-op (no embedder calls).
14385    #[test]
14386    fn embed_rows_with_fallback_empty_rows_is_noop_1595() {
14387        struct PanickingEmbedder;
14388        impl Embed for PanickingEmbedder {
14389            fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
14390                unreachable!("must not be called for an empty chunk")
14391            }
14392        }
14393        let out = embed_rows_with_fallback(&PanickingEmbedder, &[]);
14394        assert!(out.entries.is_empty());
14395        assert!(out.skipped.is_empty());
14396    }
14397
14398    /// Chunk-level WRITE fault (G4 namespace-dim invariant) falls back
14399    /// to per-row writes; rows that still fail are skipped and the
14400    /// sweep terminates cleanly with Ok.
14401    #[test]
14402    fn backfill_write_fault_falls_back_per_row_1595() {
14403        struct EightDimEmbedder;
14404        impl Embed for EightDimEmbedder {
14405            fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
14406                Ok(vec![0.5_f32; 8])
14407            }
14408        }
14409        let mut conn = db::open(std::path::Path::new(":memory:")).unwrap();
14410        // Establish a 4-dim namespace so the 8-dim writes are refused.
14411        let est = seed(&conn, "established", "already embedded");
14412        db::set_embedding(&conn, &est, &[0.1, 0.2, 0.3, 0.4]).unwrap();
14413        seed(&conn, "new-a", "needs embedding");
14414        seed(&conn, "new-b", "needs embedding");
14415
14416        let ok = run_embedding_backfill_with_batch_size(&mut conn, &EightDimEmbedder, 10)
14417            .expect("write faults must not propagate");
14418        assert_eq!(ok, 0, "dim-mismatched rows cannot land");
14419        assert_eq!(
14420            db::get_unembedded_ids(&conn).unwrap().len(),
14421            2,
14422            "both rows skipped (left for the next sweep), sweep terminated"
14423        );
14424    }
14425}