Skip to main content

memstead_mcp/
server.rs

1//! MCP server — McpServer struct with Engine, tool router, and ServerHandler.
2
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex, OnceLock};
6
7use rmcp::handler::server::wrapper::Parameters;
8use rmcp::model::{
9    CallToolRequestParams, CallToolResult, InitializeRequestParams, InitializeResult,
10    ListToolsResult, PaginatedRequestParams, Tool,
11};
12use rmcp::service::RequestContext;
13use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, tool, tool_handler, tool_router};
14
15// `envelope` is the typed-error helper used across the file.
16use memstead_git_branch::ops::envelope;
17
18// Backend-neutral types live in memstead-base.
19use memstead_base::chunking::{apply_chunking, estimate_tokens};
20use memstead_base::render;
21use memstead_base::vcs::{Actor, ClientId};
22use memstead_base::{EntityId, SearchScope, ops::MemChangedNotice, ops::WarningHint};
23
24use crate::error_envelope::{tool_error, tool_error_with_payload};
25use crate::tools::admin::{ChangesSinceParams, DiffParams, HealthParams, ReloadParams};
26use crate::tools::graph::{EntityParams, OverviewParams, SchemaParams, SearchParams};
27use crate::tools::mutation::{
28    CheckParams, CreateParams, DeleteParams, RelateParams, RenameParams, UpdateParams,
29};
30
31/// The MCP server wrapping the Engine.
32#[derive(Clone)]
33pub struct McpServer {
34    /// Unified engine handle. Held under `Arc<Mutex<>>` because the
35    /// single-client stdio transport still produces sequential tool
36    /// calls per server clone, but the rmcp router clones the server
37    /// per request.
38    unified_engine: Arc<Mutex<memstead_base::Engine>>,
39    /// Per-response chunking budget in tokens. Plumbed from
40    /// `EffectiveSettings::token_budget` (config file `[mcp] token_budget`,
41    /// or `DEFAULT_TOKEN_BUDGET` when absent).
42    token_budget: usize,
43    /// Session-level default role (agent-trust plan 13), from the
44    /// binary's `--role` flag. Per-call `role` params win; when both
45    /// are absent, mutations record unspecified.
46    default_role: memstead_base::vcs::Role,
47    /// Effective set of tool names hidden from this server. Resolved once
48    /// from `[mcp].disabled_tools` at construction. Unknown names from
49    /// the raw config are filtered out by `validate_disabled_tools` in
50    /// `main.rs` before reaching this field — every entry here matches a
51    /// compiled-in tool.
52    ///
53    /// Empty set is the "no filter" case and `list_tools` /
54    /// `call_tool` / `get_tool` behave byte-identically to the macro's
55    /// default implementation.
56    disabled_tools: Arc<HashSet<String>>,
57    /// Canonical path of the `.memstead/workspace.toml` that sourced the
58    /// filter, for attribution in the `TOOL_DISABLED` error envelope.
59    /// `None` when the server was constructed in a test or when no
60    /// file was loaded — the envelope omits `details.config_source`
61    /// in that case.
62    config_source: Option<Arc<PathBuf>>,
63    /// Resolved `[mutations]` section from the workspace's
64    /// `.memstead/workspace.toml`. Surfaced under `memstead_health {
65    /// include_config: true }` so plugins can read the configured
66    /// posture without a trial-write round-trip. Default (section
67    /// absent) is `MutationsSection { require_notes: None }`, which
68    /// the mutation pipeline treats as `false`.
69    mutations: Arc<crate::config::MutationsSection>,
70    /// Resolved `[plugin.*]` namespace from the workspace's
71    /// `.memstead/workspace.toml`. Surfaced verbatim under `memstead_health
72    /// { include_config: true }`. Each value is an opaque
73    /// `toml::Table`; the engine never inspects the contents — named
74    /// plugins read their own sub-table (e.g. `[plugin.claude_code]`).
75    plugin: Arc<HashMap<String, toml::Table>>,
76    /// Process-scoped operator-mode posture. When `true`, the
77    /// `memstead_mem_create` / `memstead_mem_delete` orchestrators bypass
78    /// the workspace `[[mem_management.create]]` /
79    /// `[[mem_management.delete]]` allowlists and the
80    /// `MEM_REFERENCED_BY_POLICY` safeguard. Set only by the
81    /// `memstead-mcp --operator-mode` boot path; agent-spawned servers
82    /// (Claude Code plugin, macOS chat subprocess) always boot with
83    /// this `false` and have no in-band channel to flip it. Surfaced
84    /// in `memstead_overview`'s `## Lifecycle Namespaces` section so the
85    /// posture is observable to anyone reading the engine's outputs.
86    operator_mode: bool,
87    // SAFETY: single-client assumption — valid under stdio (one `memstead-mcp`
88    // process per client). A future HTTP transport with concurrent clients
89    // would require threading `RequestContext` through every handler
90    // instead. `OnceLock` encodes write-once + lock-free reads; a second
91    // `initialize` hits `set` → `Err` and the override logs the breach.
92    client: Arc<OnceLock<ClientId>>,
93}
94
95impl McpServer {
96    pub fn new(engine: memstead_base::Engine, token_budget: usize) -> Self {
97        Self::new_with_filter(engine, token_budget, HashSet::new(), None)
98    }
99
100    /// Construct with an explicit disabled-tool filter. `disabled_tools`
101    /// must already be validated against the compile-time tool-name
102    /// registry (see `config::validate_disabled_tools`). `config_source`
103    /// is the `.memstead/workspace.toml` path attributed in the
104    /// `TOOL_DISABLED` envelope; pass `None` when there is no
105    /// file-backed config.
106    pub fn new_with_filter(
107        engine: memstead_base::Engine,
108        token_budget: usize,
109        disabled_tools: HashSet<String>,
110        config_source: Option<PathBuf>,
111    ) -> Self {
112        Self::new_with_config(
113            engine,
114            token_budget,
115            disabled_tools,
116            config_source,
117            crate::config::MutationsSection::default(),
118            HashMap::new(),
119        )
120    }
121
122    /// Full-surface constructor. `mutations` and `plugin` come from
123    /// `EffectiveSettings` and are surfaced verbatim under `memstead_health
124    /// { include_config: true }`. The operator-mode posture defaults
125    /// to `false` (agent-mode); flip it via [`Self::with_operator_mode`]
126    /// before serving when boot established operator intent.
127    pub fn new_with_config(
128        mut engine: memstead_base::Engine,
129        token_budget: usize,
130        disabled_tools: HashSet<String>,
131        config_source: Option<PathBuf>,
132        mutations: crate::config::MutationsSection,
133        plugin: HashMap<String, toml::Table>,
134    ) -> Self {
135        // `require_notes` is enforced once, inside the engine mutation
136        // pipeline (every surface inherits the `NOTE_MISSING` warning
137        // from the engine response). Mirror an explicitly-resolved
138        // posture into the engine's settings so the engine and this
139        // server can't disagree — but only when the caller passed an
140        // explicit value. `new` / `new_with_filter` pass the default
141        // (`None`), meaning "unspecified — keep whatever the engine
142        // loaded from `.memstead/workspace.toml`"; clobbering that with
143        // `None` would erase a policy the engine already knows.
144        // Idempotent in production: both this `mutations` and
145        // `engine.settings()` came from the same workspace.toml.
146        if let Some(require_notes) = mutations.require_notes {
147            let mut settings = engine.settings().clone();
148            settings.mutations.require_notes = Some(require_notes);
149            engine.set_settings(settings);
150        }
151        Self {
152            unified_engine: Arc::new(Mutex::new(engine)),
153            token_budget,
154            default_role: memstead_base::vcs::Role::Unspecified,
155            disabled_tools: Arc::new(disabled_tools),
156            config_source: config_source.map(Arc::new),
157            client: Arc::new(OnceLock::new()),
158            mutations: Arc::new(mutations),
159            plugin: Arc::new(plugin),
160            operator_mode: false,
161        }
162    }
163
164    /// Builder-style setter for the operator-mode posture. Returns
165    /// `self` for fluent chaining at the boot site. Only the
166    /// `memstead-mcp --operator-mode` boot path is authorised to call
167    /// this with `true`; agent-spawned servers leave it on the
168    /// default `false`.
169    /// Set the session-level default role (the binary's `--role`
170    /// flag). Per-call `role` parameters win over this default.
171    pub fn with_default_role(mut self, role: memstead_base::vcs::Role) -> Self {
172        self.default_role = role;
173        self
174    }
175
176    /// Resolve a per-call `role` parameter against the session
177    /// default. An unknown value refuses typed with the declarable
178    /// vocabulary named (agent-trust plan 13).
179    fn resolve_role(
180        &self,
181        raw: Option<&str>,
182    ) -> Result<memstead_base::vcs::Role, Box<CallToolResult>> {
183        match raw {
184            None => Ok(self.default_role),
185            Some(s) => memstead_base::vcs::Role::from_wire(s).ok_or_else(|| {
186                let msg = format!(
187                    "unknown role {s:?} — declarable roles: {}",
188                    memstead_base::vcs::Role::DECLARABLE.join(", ")
189                );
190                Box::new(tool_error_with_payload(
191                    "INVALID_ROLE",
192                    &msg,
193                    envelope(
194                        "INVALID_ROLE",
195                        msg.clone(),
196                        serde_json::json!({
197                            "role": s,
198                            "allowed": memstead_base::vcs::Role::DECLARABLE,
199                        }),
200                    ),
201                ))
202            }),
203        }
204    }
205
206    pub fn with_operator_mode(mut self, operator_mode: bool) -> Self {
207        self.operator_mode = operator_mode;
208        self
209    }
210
211    /// `true` when this server was booted with operator-mode bypass.
212    /// Exposed so callers (tests, the overview surface) can observe
213    /// the posture without reaching into private state.
214    pub fn is_operator_mode(&self) -> bool {
215        self.operator_mode
216    }
217
218    /// Borrow of the unified engine handle.
219    pub fn unified_engine(&self) -> &Arc<Mutex<memstead_base::Engine>> {
220        &self.unified_engine
221    }
222
223    /// Tool list filtered by the workspace's `disabled_tools` set. Used
224    /// by the `list_tools` handler and directly by tests (which cannot
225    /// easily synthesize a `RequestContext`).
226    pub fn filtered_tool_list(&self) -> Vec<Tool> {
227        let mut tools: Vec<Tool> = Self::tool_router().list_all();
228        if !self.disabled_tools.is_empty() {
229            tools.retain(|t| !self.disabled_tools.contains(t.name.as_ref()));
230        }
231        tools
232    }
233
234    /// `true` if the given tool name is disabled in this server's
235    /// workspace. Public so tests can reason about the filter without
236    /// reaching into private state.
237    pub fn is_tool_disabled(&self, name: &str) -> bool {
238        self.disabled_tools.contains(name)
239    }
240
241    /// `TOOL_DISABLED` error envelope. `details.config_source` is
242    /// included only when a file-backed config was loaded — matches the
243    /// crate's `serde(skip_serializing_if)` conventions.
244    pub(crate) fn tool_disabled_response(&self, name: &str) -> CallToolResult {
245        let msg = format!("Tool '{name}' is disabled in this workspace's MCP configuration.");
246        let mut details = serde_json::Map::new();
247        details.insert("tool".to_string(), serde_json::json!(name));
248        if let Some(path) = &self.config_source {
249            details.insert(
250                "config_source".to_string(),
251                serde_json::json!(path.display().to_string()),
252            );
253        }
254        tool_error_with_payload(
255            "TOOL_DISABLED",
256            &msg,
257            envelope(
258                "TOOL_DISABLED",
259                msg.clone(),
260                serde_json::Value::Object(details),
261            ),
262        )
263    }
264
265    /// Get the default writable mem name from the unified engine.
266    /// Returns `None` when the engine has no writable mems.
267    ///
268    /// Delegates to [`Engine::default_writable_mem`] — the first
269    /// writable mount in declaration order (the stable seed mem), NOT
270    /// `writable_mems().iter().next()` off an unordered set. Creating a
271    /// second mem no longer silently retargets omitted-`mem` writes.
272    fn primary_mem(&self) -> Option<String> {
273        let engine = self.unified_engine.lock().ok()?;
274        engine.default_writable_mem().map(|s| s.to_string())
275    }
276
277    /// Resolve a mem name, defaulting to primary.
278    fn resolve_mem(&self, mem: Option<&str>) -> String {
279        mem.map(|v| v.to_string())
280            .or_else(|| self.primary_mem())
281            .unwrap_or_else(|| "default".to_string())
282    }
283}
284
285/// Build a `_meta` map carrying `anthropic/alwaysLoad: true` so Claude
286/// Code excludes the tagged tool from its `ToolSearch`-deferred set.
287/// Applied to `memstead_overview` — the cold-start entry point that the
288/// server `instructions` direct agents to call first. Without this,
289/// agents pay an extra `ToolSearch` round-trip before they can reach
290/// overview.
291fn always_load_meta() -> rmcp::model::Meta {
292    let mut m = rmcp::model::Meta::new();
293    m.0.insert(
294        "anthropic/alwaysLoad".to_string(),
295        serde_json::Value::Bool(true),
296    );
297    m
298}
299
300/// Validate an entity ID before using it. Returns an error result if
301/// invalid. Routes through [`tool_error_with_payload`] so the text
302/// channel carries the `ERROR [INVALID_ENTITY_ID]: …` prefix and
303/// `structured_content` carries the typed envelope — consistent with
304/// the engine's `EngineError::InvalidEntityId` path for downstream
305/// grammar violations.
306fn validate_entity_id(id: &str) -> Option<CallToolResult> {
307    if id.is_empty() {
308        let msg = "Entity ID must not be empty.".to_string();
309        return Some(tool_error_with_payload(
310            "INVALID_ENTITY_ID",
311            &msg,
312            envelope(
313                "INVALID_ENTITY_ID",
314                msg.clone(),
315                serde_json::json!({ "id": id, "reason": "empty" }),
316            ),
317        ));
318    }
319    if id.chars().count() > memstead_base::ENTITY_ID_MAX_LEN {
320        let msg = format!(
321            "Entity ID too long (max {} characters).",
322            memstead_base::ENTITY_ID_MAX_LEN
323        );
324        return Some(tool_error_with_payload(
325            "INVALID_ENTITY_ID",
326            &msg,
327            envelope(
328                "INVALID_ENTITY_ID",
329                msg.clone(),
330                serde_json::json!({
331                    "id": id,
332                    "reason": "too_long",
333                    "length": id.chars().count(),
334                    "max": memstead_base::ENTITY_ID_MAX_LEN,
335                }),
336            ),
337        ));
338    }
339    None
340}
341
342/// Validate the optional agent-authored `note` field on a mutation call.
343/// Returns an `INVALID_INPUT` envelope when the note exceeds
344/// `memstead_engine::mem_management::NOTE_MAX_LEN` Unicode scalar values, matching the
345/// mem-lifecycle orchestrators. Empty / absent values succeed.
346/// Whitespace-only notes are allowed at the edge (the engine side
347/// collapses them to "no body line" during commit-message assembly).
348fn validate_note(note: Option<&str>) -> Option<CallToolResult> {
349    let n = note?;
350    if n.chars().count() > memstead_engine::mem_management::NOTE_MAX_LEN {
351        let max = memstead_engine::mem_management::NOTE_MAX_LEN;
352        let msg = format!(
353            "note exceeds {max} characters — shorten the agent-authored \
354             provenance line to one sentence."
355        );
356        let details = serde_json::json!({
357            "max_chars": max,
358            "got_chars": n.chars().count(),
359        });
360        return Some(tool_error_with_payload(
361            "INVALID_INPUT",
362            &msg,
363            envelope("INVALID_INPUT", msg.clone(), details),
364        ));
365    }
366    None
367}
368
369/// Helper: create a JSON tool response for mutation tools. Emits
370/// pretty-printed JSON on the text channel and mirrors the typed value
371/// onto `structured_content` so agents can branch on fields without
372/// parsing the text. Read tools render pure Markdown without a JSON
373/// sidecar; mutation tools keep the JSON envelope so agents can decode
374/// the response shape deterministically. Serialization failures fall
375/// back to an error body on the text channel and leave
376/// `structured_content` empty.
377fn json_response<T: serde::Serialize>(data: &T) -> CallToolResult {
378    let text =
379        serde_json::to_string_pretty(data).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
380    let mut r = CallToolResult::success(vec![rmcp::model::ContentBlock::text(text)]);
381    r.structured_content = serde_json::to_value(data).ok();
382    r
383}
384
385/// #57: bound a health response's text channel so a multi-include report
386/// can't overflow the response cap. `structured_content` always ships whole
387/// (machine consumers read it). The text channel stays the pretty JSON when
388/// the report fits the budget and no chunk was requested — byte-identical to
389/// before, so a small call is unchanged; only when it would overflow (or a
390/// chunk is explicitly requested) does the text become chunkable markdown
391/// rendered from the structured payload, paged by `chunk`. A chunk index
392/// past the end returns the chunker's `INVALID_INPUT` error verbatim.
393/// Called last, after the post-processing that mutates `structured_content`.
394fn finalize_health_text(
395    mut res: CallToolResult,
396    budget: usize,
397    chunk: Option<usize>,
398) -> CallToolResult {
399    let Some(sc) = res.structured_content.as_ref() else {
400        return res;
401    };
402    // The text channel currently holds the pretty JSON (from `json_response`
403    // + the anchor/notice post-processing). Keep it verbatim while it fits.
404    let json_text = serde_json::to_string_pretty(sc).unwrap_or_default();
405    if chunk.is_none() && estimate_tokens(&json_text) <= budget {
406        return res;
407    }
408    let md = memstead_engine::health::render_health_markdown(sc);
409    match apply_chunking(&md, budget, chunk, &[]) {
410        Ok(text) => {
411            res.content = vec![rmcp::model::ContentBlock::text(text)];
412            res
413        }
414        Err(e) => tool_error("INVALID_INPUT", &e),
415    }
416}
417
418/// Append a `WarningHint` envelope to the `warnings` array of an
419/// already-serialized mutation response. Used by the `require_notes`
420/// pipeline to attach a `NOTE_MISSING` warning without teaching every
421/// `*Result` struct about the mutation-policy surface — the wire shape
422/// already lists `warnings`, so we lift there and keep the engine
423/// ignorant of the workspace policy.
424///
425/// Preserves the text channel (re-serialises the updated structured
426/// value to pretty JSON). Silent no-op when the response has no
427/// structured content or the structured content is not a JSON object
428/// (e.g. serialization failed upstream) — the caller's original
429/// response survives unchanged.
430fn append_warning_hint(mut res: CallToolResult, warning: &WarningHint) -> CallToolResult {
431    let Some(sc) = res.structured_content.as_mut() else {
432        return res;
433    };
434    let Some(obj) = sc.as_object_mut() else {
435        return res;
436    };
437    let entry = serde_json::to_value(warning).unwrap_or_else(
438        |_| serde_json::json!({"code": warning.code(), "message": warning.message()}),
439    );
440    let warnings = obj
441        .entry("warnings".to_string())
442        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
443    if let Some(arr) = warnings.as_array_mut() {
444        arr.push(entry);
445    } else {
446        // Existing value was something other than an array — we can't
447        // merge into it safely; leave the response untouched rather
448        // than corrupting the wire shape.
449        return res;
450    }
451    // Refresh the text channel so the JSON body lines up with the
452    // updated structured content. On serialization failure leave the
453    // existing text — a stale-but-readable mismatch beats an empty
454    // body.
455    if let Ok(text) = serde_json::to_string_pretty(&*sc) {
456        res.content = vec![rmcp::model::ContentBlock::text(text)];
457    }
458    res
459}
460
461/// Helper: create a markdown tool response.
462fn md_response(markdown: String) -> CallToolResult {
463    CallToolResult::success(vec![rmcp::model::ContentBlock::text(markdown)])
464}
465
466/// Tool response that pairs rendered markdown on the text channel with
467/// a structured envelope on `structured_content`. Tools whose response
468/// has a
469/// canonical human-readable form (entity, search) ship the markdown to
470/// terminal/inline consumers and the typed JSON to branching agents in
471/// one call, with no extra round-trip. The agent contract: branch on
472/// `structured_content`; read the text channel for prose.
473fn md_with_structured(markdown: String, structured: serde_json::Value) -> CallToolResult {
474    let mut r = CallToolResult::success(vec![rmcp::model::ContentBlock::text(markdown)]);
475    r.structured_content = Some(structured);
476    r
477}
478
479/// Prepend a `> [!warning]` admonition block describing drift events
480/// (`MemReloaded` warnings from [`Engine::reload_if_stale`])
481/// to a markdown response body. Visible to agents inline at the top
482/// of the rendered output so a reasoning loop reading e.g.
483/// `memstead_entity` notices the snapshot shifted under it without
484/// having to inspect a sidecar field.
485///
486/// No-op when `drift_warnings` is empty so the common (single-engine)
487/// path produces byte-identical markdown to pre-multi-engine-coherence.
488/// Attach the structured `mem_changed` notices a just-completed
489/// operation accumulated (reload-before-operation) to a JSON response
490/// body under the `mem_changed` key. No-op when `notices` is empty,
491/// so the common single-engine path leaves the body byte-identical.
492fn attach_mem_changed(body: &mut serde_json::Value, notices: Vec<MemChangedNotice>) {
493    if notices.is_empty() {
494        return;
495    }
496    body["mem_changed"] = serde_json::to_value(&notices).unwrap_or(serde_json::Value::Null);
497}
498
499/// Attach the target mem's durability marker to a mutation response.
500/// `durable: false` means the mem's storage is volatile (in-memory) —
501/// the accompanying `commit_sha` is shaped like a git SHA but denotes
502/// nothing that survives process restart or session-TTL eviction. This is
503/// the per-write echo of the same per-mem marker `overview` / `health`
504/// carry, derived from the same `MountStorage::is_durable()`; it is
505/// orthogonal to `commit_sha.is_empty()` (which says only whether a commit
506/// happened), so an agent never has to conflate "no commit" with "commit
507/// in RAM". Defaults to `false` for an unresolvable mem — the engine
508/// never claims a durability it cannot vouch for.
509fn attach_durability(body: &mut serde_json::Value, engine: &memstead_base::Engine, mem: &str) {
510    let durable = engine
511        .mounts()
512        .iter()
513        .find(|m| m.mem == mem)
514        .map(|m| m.storage.is_durable())
515        .unwrap_or(false);
516    if let Some(obj) = body.as_object_mut() {
517        obj.insert("durable".into(), serde_json::json!(durable));
518    }
519}
520
521/// Attach `mem_changed` notices to a response's `structured_content`
522/// envelope (success or error — anything whose `structured_content` is
523/// a JSON object). A mutation that reloaded then refused (e.g.
524/// `HASH_MISMATCH`) carries the notice alongside the refusal; a read
525/// whose structured envelope is built separately gets it the same way.
526/// Draining here also keeps the engine stash from leaking into the next
527/// operation. No-op when `notices` is empty or the envelope is not a
528/// JSON object.
529fn attach_mem_changed_to_result(
530    mut res: CallToolResult,
531    notices: Vec<MemChangedNotice>,
532) -> CallToolResult {
533    if notices.is_empty() {
534        return res;
535    }
536    if let Some(obj) = res
537        .structured_content
538        .as_mut()
539        .and_then(|sc| sc.as_object_mut())
540    {
541        obj.insert(
542            "mem_changed".to_string(),
543            serde_json::to_value(&notices).unwrap_or(serde_json::Value::Null),
544        );
545    }
546    res
547}
548
549/// Reconstruct one `MemReloaded` warning per stashed notice. Used on
550/// error paths that hold only the drained `mem_changed` notices and
551/// no `drift_warnings` Vec — mutation handlers, whose reload happens
552/// inside the engine and surfaces solely as a stashed notice. The
553/// `entities_loaded` count comes from the notice's own delta size so
554/// the synthesised warning Display matches what a read handler's
555/// `WarningHint::MemReloaded` would render for the same reload.
556fn notices_as_reload_warnings(notices: &[MemChangedNotice]) -> Vec<WarningHint> {
557    notices
558        .iter()
559        .map(|n| WarningHint::MemReloaded {
560            mem: n.mem.clone(),
561            old_head: n.from_head.clone(),
562            new_head: n.to_head.clone(),
563            entities_loaded: n.entity_count(),
564        })
565        .collect()
566}
567
568/// Attach reload drift to an *error* response on the same channel split
569/// a successful response uses: the full per-entity `mem_changed`
570/// notice on `structured_content`, plus the `MEM_RELOADED` admonition
571/// the read success path prepends, on the text channel. Never carries
572/// the serialised notice on the text channel — that would make an error
573/// response richer than the matching success (a new asymmetry); the
574/// text channel gets only the warning line.
575///
576/// Every error early-return reachable *after* a
577/// `take_mem_changed_notices()` drain routes through this so a reload
578/// that happened during the operation reaches the agent whether the
579/// operation then succeeded or failed. No-op when both inputs are empty,
580/// so the common no-drift path stays byte-identical to pre-fix.
581fn attach_drift_to_error(
582    res: CallToolResult,
583    drift_warnings: &[WarningHint],
584    notices: Vec<MemChangedNotice>,
585) -> CallToolResult {
586    let res = prepend_drift_warnings_to_result_text(res, drift_warnings);
587    attach_mem_changed_to_result(res, notices)
588}
589
590/// Prepend the drift admonition (the same `> [!warning]` block the read
591/// success path renders) to the first text content of an already-built
592/// response — typically an `ERROR [<CODE>]: …` error envelope. No-op
593/// when `drift_warnings` is empty or the response has no text content,
594/// so the no-drift path is byte-identical.
595fn prepend_drift_warnings_to_result_text(
596    mut res: CallToolResult,
597    drift_warnings: &[WarningHint],
598) -> CallToolResult {
599    if drift_warnings.is_empty() {
600        return res;
601    }
602    let Some(existing) = res
603        .content
604        .first()
605        .and_then(|c| c.as_text())
606        .map(|t| t.text.clone())
607    else {
608        return res;
609    };
610    let prefixed = prepend_drift_warnings_md(existing, drift_warnings);
611    res.content[0] = rmcp::model::ContentBlock::text(prefixed);
612    res
613}
614
615fn prepend_drift_warnings_md(md: String, drift_warnings: &[WarningHint]) -> String {
616    if drift_warnings.is_empty() {
617        return md;
618    }
619    let mut prefix = String::new();
620    prefix.push_str(
621        "> [!warning] Engine snapshot reloaded — sibling writer advanced an on-disk mem HEAD\n",
622    );
623    for w in drift_warnings {
624        prefix.push_str(&format!(">\n> - **{}**: {}\n", w.code(), w));
625    }
626    prefix.push('\n');
627    prefix.push_str(&md);
628    prefix
629}
630
631// Per-mem schema pin / workspace policy / cross-catalogue schema
632// lookup helpers live in `memstead_engine::overview` so the shared
633// composer (this MCP tool + the full CLI) and the rest of this server
634// reach the same canonical implementation. The wrappers below stay so
635// the existing ~14 call sites in this file continue to compile
636// unchanged; their bodies just forward.
637
638/// Format the canonical schema pin for a known mem as `name@version`,
639/// or `None` if the mem is not registered. Source of truth is the
640/// engine's `MemState.schema_ref`, which always reflects the schema
641/// actually loaded — never a stale on-disk pin.
642fn mem_schema_ref_unified(engine: &memstead_base::Engine, mem_name: &str) -> Option<String> {
643    memstead_engine::overview::mem_schema_ref(engine, mem_name)
644}
645
646fn find_schema_unified<'a>(
647    engine: &'a memstead_base::Engine,
648    sref: &memstead_schema::SchemaRef,
649) -> Option<&'a std::sync::Arc<memstead_schema::Schema>> {
650    memstead_engine::overview::find_schema(engine, sref)
651}
652
653/// Resolve a schema by bare name across mem-pinned, workspace, and
654/// built-in catalogues. Mirrors `find_schema_unified`'s precedence:
655/// mem-pinned first, then workspace, then built-ins. Used by
656/// `memstead_schema(name="<bare>")` for the path that doesn't carry a
657/// `@<version>` pin — picks the first matching schema by name.
658fn find_schema_by_name<'a>(
659    engine: &'a memstead_base::Engine,
660    name: &str,
661) -> Option<&'a std::sync::Arc<memstead_schema::Schema>> {
662    if let Some(s) = engine.schemas().values().find(|s| s.manifest.name == name) {
663        return Some(s);
664    }
665    if let Some(s) = engine
666        .workspace_schemas()
667        .iter()
668        .find(|s| s.manifest.name == name)
669    {
670        return Some(s);
671    }
672    engine
673        .builtin_schemas()
674        .iter()
675        .find(|s| s.manifest.name == name)
676}
677
678/// Inject `_mem_schema: <ref>` as the first line inside the YAML
679/// frontmatter block of a rendered markdown response. No-op when the
680/// markdown does not start with `---\n` (defensive — a future renderer
681/// without frontmatter must not get a malformed prefix). Idempotent: if
682/// `_mem_schema:` is already present at the top of the frontmatter the
683/// second call is a silent no-op so chunked responses do not double-stamp.
684fn inject_md_mem_schema(md: &mut String, schema_ref: &str) {
685    if !md.starts_with("---\n") {
686        return;
687    }
688    if md[4..].starts_with("_mem_schema:") {
689        return;
690    }
691    md.insert_str(4, &format!("_mem_schema: {schema_ref}\n"));
692}
693
694/// Insert `_mem_schema: <ref>` at the top level of a JSON-shaped tool
695/// response built via [`json_response`]. Refreshes the text channel so
696/// pretty-printed JSON stays in lockstep with `structured_content`. Silent
697/// no-op when the response has no structured content or the structured
698/// content is not a JSON object — the original response survives unchanged.
699fn with_mem_schema_anchor(mut res: CallToolResult, schema_ref: &str) -> CallToolResult {
700    let Some(sc) = res.structured_content.as_mut() else {
701        return res;
702    };
703    let Some(obj) = sc.as_object_mut() else {
704        return res;
705    };
706    obj.insert(
707        "_mem_schema".to_string(),
708        serde_json::Value::String(schema_ref.to_string()),
709    );
710    if let Ok(text) = serde_json::to_string_pretty(&*sc) {
711        res.content = vec![rmcp::model::ContentBlock::text(text)];
712    }
713    res
714}
715
716/// Convert an `EngineError` to a tool error response. Exhaustive over every
717/// variant — each case produces a `{code, message, details}` envelope on
718/// `structured_content` so agents can branch on the stable `UPPER_SNAKE_CASE`
719/// Typed-envelope translator for the unified `memstead_base::EngineError`.
720/// Mutation handlers' unified branches return this when the engine
721/// surfaces an error so the wire shape carries a stable `code` and
722/// (where applicable) the recovery `details` payload full callers
723/// already branch on.
724///
725/// The match is exhaustive — no wildcard arm. Every `EngineError`
726/// variant maps to a typed envelope; adding a new variant fails
727/// compilation here until an arm picks a code (and, when applicable,
728/// a structured details payload). The compiler is the forcing
729/// function. An earlier shape carried
730/// a `_ => INTERNAL` arm that silently swallowed `DescriptionNotPermitted`,
731/// `WikiLinkWithoutRelation`, `MissingRequiredDescription`, and several
732/// rename/parse-path variants — trained agents to ignore `INTERNAL`
733/// even when the underlying error was user-recoverable. The exhaustive
734/// match makes that regression class structurally impossible: every
735/// future `EngineError` variant must declare its wire shape here
736/// before it can land.
737fn engine_err_unified(
738    e: memstead_base::EngineError,
739    engine: &memstead_base::Engine,
740) -> CallToolResult {
741    use memstead_base::EngineError as E;
742    // The text-channel message uses the rich-prose renderer so an agent
743    // reading only `result.content[0].text` sees the full recovery
744    // payload inline rather than a "+N more — see details.X" pointer
745    // to a structured channel the MCP client doesn't surface. The
746    // structured payload below is unchanged.
747    let message = e.prose_render();
748    match &e {
749        E::NotFound { id } => {
750            // #55: attach `suggestions` so this generic mapper carries the
751            // same recovery detail as the dedicated `not_found_error`, no
752            // matter which internal path raised the error.
753            let suggestions = suggest_similar(engine.store(), id);
754            tool_error_with_payload(
755                "ENTITY_NOT_FOUND",
756                &message,
757                envelope(
758                    "ENTITY_NOT_FOUND",
759                    message.clone(),
760                    serde_json::json!({ "id": id, "suggestions": suggestions }),
761                ),
762            )
763        }
764        E::AlreadyExists { .. } => tool_error_with_payload(
765            "ENTITY_ALREADY_EXISTS",
766            &message,
767            // Payload comes from `details()` so the occupying title
768            // cannot drift between the CLI and MCP envelopes.
769            envelope("ENTITY_ALREADY_EXISTS", message.clone(), e.details()),
770        ),
771        // Block-tier declared-constraint refusals — code and recovery
772        // payload come from the error itself (`code()` / `details()`),
773        // so the envelope stays aligned with the CLI `--json` shape.
774        E::ConstraintUnsatisfied { .. }
775        | E::RequiredOutgoingUnsatisfied { .. }
776        | E::SectionFormatRefused { .. } => tool_error_with_payload(
777            e.code(),
778            &message,
779            envelope(e.code(), message.clone(), e.details()),
780        ),
781        E::HashMismatch {
782            id,
783            current,
784            is_stub,
785        } => tool_error_with_payload(
786            "HASH_MISMATCH",
787            &message,
788            envelope(
789                "HASH_MISMATCH",
790                message.clone(),
791                serde_json::json!({
792                    "id": id,
793                    "current": current,
794                    "is_stub": is_stub,
795                }),
796            ),
797        ),
798        E::UnknownMem(name) => {
799            // #55: attach `known_mems` so this generic mapper matches the
800            // dedicated mem-not-found handler regardless of which path
801            // raised the error.
802            let known_mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
803            tool_error_with_payload(
804                "UNKNOWN_MEM",
805                &message,
806                envelope(
807                    "UNKNOWN_MEM",
808                    message.clone(),
809                    serde_json::json!({ "name": name, "known_mems": known_mems }),
810                ),
811            )
812        }
813        E::MemQuarantined {
814            mem,
815            reason_code,
816            reason_message,
817        } => tool_error_with_payload(
818            "MEM_QUARANTINED",
819            &message,
820            envelope(
821                "MEM_QUARANTINED",
822                message.clone(),
823                serde_json::json!({
824                    "mem": mem,
825                    "reason_code": reason_code,
826                    "reason_message": reason_message,
827                }),
828            ),
829        ),
830        E::UnknownRef(raw) => tool_error_with_payload(
831            "UNKNOWN_REF",
832            &message,
833            envelope(
834                "UNKNOWN_REF",
835                message.clone(),
836                serde_json::json!({ "ref": raw }),
837            ),
838        ),
839        E::BranchResetHeadMoved {
840            mem,
841            expected,
842            current,
843        } => tool_error_with_payload(
844            "BRANCH_RESET_HEAD_MOVED",
845            &message,
846            envelope(
847                "BRANCH_RESET_HEAD_MOVED",
848                message.clone(),
849                serde_json::json!({
850                    "mem": mem,
851                    "expected": expected,
852                    "current": current,
853                }),
854            ),
855        ),
856        E::PushedCommitsProtected {
857            mem,
858            target_sha,
859            pushed_shas,
860        } => tool_error_with_payload(
861            "PUSHED_COMMITS_PROTECTED",
862            &message,
863            envelope(
864                "PUSHED_COMMITS_PROTECTED",
865                message.clone(),
866                serde_json::json!({
867                    "mem": mem,
868                    "target_sha": target_sha,
869                    "pushed_shas": pushed_shas,
870                }),
871            ),
872        ),
873        E::UnknownRemote(name) => tool_error_with_payload(
874            "UNKNOWN_REMOTE",
875            &message,
876            envelope(
877                "UNKNOWN_REMOTE",
878                message.clone(),
879                serde_json::json!({ "remote": name }),
880            ),
881        ),
882        E::LocalDivergence { mem, remote_ref } => tool_error_with_payload(
883            "LOCAL_DIVERGENCE",
884            &message,
885            envelope(
886                "LOCAL_DIVERGENCE",
887                message.clone(),
888                serde_json::json!({ "mem": mem, "remote_ref": remote_ref }),
889            ),
890        ),
891        E::NonFastForward { mem, remote } => tool_error_with_payload(
892            "NON_FAST_FORWARD",
893            &message,
894            envelope(
895                "NON_FAST_FORWARD",
896                message.clone(),
897                serde_json::json!({ "mem": mem, "remote": remote }),
898            ),
899        ),
900        E::LocalInvalidState {
901            mem,
902            remote,
903            detail,
904        } => tool_error_with_payload(
905            "LOCAL_INVALID_STATE",
906            &message,
907            envelope(
908                "LOCAL_INVALID_STATE",
909                message.clone(),
910                serde_json::json!({
911                    "mem": mem,
912                    "remote": remote,
913                    "detail": detail,
914                }),
915            ),
916        ),
917        E::SchemaViolationInFetch {
918            mem,
919            ref_name,
920            violations,
921        } => tool_error_with_payload(
922            "SCHEMA_VIOLATION_IN_FETCH",
923            &message,
924            envelope(
925                "SCHEMA_VIOLATION_IN_FETCH",
926                message.clone(),
927                serde_json::json!({
928                    "mem": mem,
929                    "ref": ref_name,
930                    "violations": violations,
931                }),
932            ),
933        ),
934        E::ReadOnlyMount(mem) => tool_error_with_payload(
935            "READ_ONLY_MOUNT",
936            &message,
937            envelope(
938                "READ_ONLY_MOUNT",
939                message.clone(),
940                serde_json::json!({ "mem": mem }),
941            ),
942        ),
943        E::CheckNotRecorded { reason } => tool_error_with_payload(
944            "CHECK_NOT_RECORDED",
945            &message,
946            envelope(
947                "CHECK_NOT_RECORDED",
948                message.clone(),
949                serde_json::json!({ "reason": reason }),
950            ),
951        ),
952        E::UnknownType {
953            name,
954            schema_ref,
955            declared,
956            suggestion,
957        } => tool_error_with_payload(
958            "UNKNOWN_ENTITY_TYPE",
959            &message,
960            envelope(
961                "UNKNOWN_ENTITY_TYPE",
962                message.clone(),
963                serde_json::json!({
964                    "name": name,
965                    "schema_ref": schema_ref,
966                    "declared": declared,
967                    "suggestion": suggestion,
968                }),
969            ),
970        ),
971        E::HasIncomingRefs { id, referrers } => {
972            // Project each ReferrerInfo into the wire shape the
973            // memstead_delete description advertises: `{ from_id,
974            // rel_types, mem, capability: "write" }`. The capability
975            // is constant on this path — only Write-Mem referrers
976            // ever surface here (ReadOnly referrers ride the
977            // residual-stub demotion path). Per-source dedup happens
978            // upstream: `rel_types` carries every edge
979            // type from this source to the deletion target.
980            let referrers_json: Vec<_> = referrers
981                .iter()
982                .map(|r| {
983                    serde_json::json!({
984                        "from_id": r.from_id,
985                        "rel_types": r.rel_types,
986                        "mem": r.mem,
987                        "capability": "write",
988                    })
989                })
990                .collect();
991            tool_error_with_payload(
992                e.code(),
993                &message,
994                envelope(
995                    e.code(),
996                    message.clone(),
997                    serde_json::json!({ "id": id, "referrers": referrers_json }),
998                ),
999            )
1000        }
1001        E::MemHasIncomingRefs { mem, referrers } => {
1002            // Mem-level mirror of HasIncomingRefs (F15 / CLI F8): the
1003            // mem-delete edge-graph check. Same `{from_id,
1004            // rel_types, mem}` projection; capability is omitted
1005            // because the mem-level check already filtered to
1006            // Write-Mem sources upstream.
1007            let referrers_json: Vec<_> = referrers
1008                .iter()
1009                .map(|r| {
1010                    serde_json::json!({
1011                        "from_id": r.from_id,
1012                        "rel_types": r.rel_types,
1013                        "mem": r.mem,
1014                    })
1015                })
1016                .collect();
1017            tool_error_with_payload(
1018                e.code(),
1019                &message,
1020                envelope(
1021                    e.code(),
1022                    message.clone(),
1023                    serde_json::json!({ "mem": mem, "referrers": referrers_json }),
1024                ),
1025            )
1026        }
1027        E::CrossMemLinkNotAllowed { from_mem, to_mem } => tool_error_with_payload(
1028            e.code(),
1029            &message,
1030            envelope(
1031                e.code(),
1032                message.clone(),
1033                serde_json::json!({
1034                    "from_mem": from_mem,
1035                    "to_mem": to_mem,
1036                }),
1037            ),
1038        ),
1039        E::CrossMemTargetNotFound {
1040            target_id,
1041            target_mem,
1042        } => tool_error_with_payload(
1043            e.code(),
1044            &message,
1045            envelope(
1046                e.code(),
1047                message.clone(),
1048                serde_json::json!({
1049                    "target_id": target_id,
1050                    "target_mem": target_mem,
1051                }),
1052            ),
1053        ),
1054        E::CrossMemEdgeNotDeclared {
1055            source_schema,
1056            target_schema,
1057            rel_type,
1058            from_id,
1059            to_id,
1060        } => tool_error_with_payload(
1061            e.code(),
1062            &message,
1063            envelope(
1064                e.code(),
1065                message.clone(),
1066                serde_json::json!({
1067                    "source_schema": source_schema,
1068                    "target_schema": target_schema,
1069                    "rel_type": rel_type,
1070                    "from_id": from_id,
1071                    "to_id": to_id,
1072                }),
1073            ),
1074        ),
1075        E::RepairNotNeeded { id, recovery } => tool_error_with_payload(
1076            "REPAIR_NOT_NEEDED",
1077            &message,
1078            envelope(
1079                "REPAIR_NOT_NEEDED",
1080                message.clone(),
1081                serde_json::json!({ "id": id, "recovery": recovery }),
1082            ),
1083        ),
1084        E::ConflictingSectionModes { section, modes } => tool_error_with_payload(
1085            "CONFLICTING_SECTION_MODES",
1086            &message,
1087            envelope(
1088                "CONFLICTING_SECTION_MODES",
1089                message.clone(),
1090                serde_json::json!({ "section": section, "modes": modes }),
1091            ),
1092        ),
1093        E::RelationshipCycle {
1094            rel_type,
1095            from,
1096            to,
1097            existing_path,
1098            path_truncated,
1099        } => {
1100            let existing_path_json: Vec<String> =
1101                existing_path.iter().map(|id| id.to_string()).collect();
1102            tool_error_with_payload(
1103                "RELATIONSHIP_CYCLE",
1104                &message,
1105                envelope(
1106                    "RELATIONSHIP_CYCLE",
1107                    message.clone(),
1108                    serde_json::json!({
1109                        "rel_type": rel_type,
1110                        "from": from.to_string(),
1111                        "to": to.to_string(),
1112                        "existing_path": existing_path_json,
1113                        "path_truncated": path_truncated,
1114                    }),
1115                ),
1116            )
1117        }
1118        E::RequiredFieldUnset {
1119            field,
1120            entity_type,
1121            field_description,
1122            enum_values,
1123            type_write_rules,
1124            // Path-aware prose flows through `prose_render()`; the
1125            // structured payload below is the same on both paths so
1126            // `on_create` is intentionally not surfaced here.
1127            on_create: _,
1128            missing,
1129        } => {
1130            // `details.missing[]` carries every required-no-default
1131            // field unset on the create path. Each entry echoes
1132            // the type-level `write_rules` for self-containment.
1133            let missing_json: Vec<_> = missing
1134                .iter()
1135                .map(|m| {
1136                    serde_json::json!({
1137                        "field": m.key,
1138                        "description": m.description,
1139                        "enum_values": m.enum_values,
1140                        "write_rules": type_write_rules,
1141                    })
1142                })
1143                .collect();
1144            tool_error_with_payload(
1145                "REQUIRED_FIELD_UNSET",
1146                &message,
1147                envelope(
1148                    "REQUIRED_FIELD_UNSET",
1149                    message.clone(),
1150                    serde_json::json!({
1151                        "field": field,
1152                        "entity_type": entity_type,
1153                        "field_description": field_description,
1154                        "enum_values": enum_values,
1155                        "type_write_rules": type_write_rules,
1156                        "missing": missing_json,
1157                    }),
1158                ),
1159            )
1160        }
1161        E::MissingRequiredSection {
1162            entity_type,
1163            missing_count,
1164            sections,
1165            type_guidance,
1166        } => {
1167            let sections_json: Vec<_> = sections
1168                .iter()
1169                .map(|s| {
1170                    serde_json::json!({
1171                        "entity_type": s.entity_type,
1172                        "key": s.key,
1173                        "heading": s.heading,
1174                        "write_rules": s.write_rules,
1175                    })
1176                })
1177                .collect();
1178            tool_error_with_payload(
1179                "MISSING_REQUIRED_SECTION",
1180                &message,
1181                envelope(
1182                    "MISSING_REQUIRED_SECTION",
1183                    message.clone(),
1184                    serde_json::json!({
1185                        "entity_type": entity_type,
1186                        "missing_count": missing_count,
1187                        "sections": sections_json,
1188                        "type_guidance": type_guidance,
1189                    }),
1190                ),
1191            )
1192        }
1193        E::SetAndUnsetConflict { keys } => tool_error_with_payload(
1194            "SET_AND_UNSET_CONFLICT",
1195            &message,
1196            envelope(
1197                "SET_AND_UNSET_CONFLICT",
1198                message.clone(),
1199                serde_json::json!({ "keys": keys }),
1200            ),
1201        ),
1202        E::PatchSectionEmpty { section } => tool_error_with_payload(
1203            "PATCH_SECTION_EMPTY",
1204            &message,
1205            envelope(
1206                "PATCH_SECTION_EMPTY",
1207                message.clone(),
1208                serde_json::json!({ "section": section }),
1209            ),
1210        ),
1211        E::PatchOldNotFound {
1212            section,
1213            current_content,
1214            truncated,
1215        } => tool_error_with_payload(
1216            "PATCH_OLD_NOT_FOUND",
1217            &message,
1218            envelope(
1219                "PATCH_OLD_NOT_FOUND",
1220                message.clone(),
1221                serde_json::json!({
1222                    "section": section,
1223                    "current_content": current_content,
1224                    "truncated": truncated,
1225                }),
1226            ),
1227        ),
1228        E::InvalidTitle(slug_err) => {
1229            use memstead_base::SlugError;
1230            let reason = slug_err.reason();
1231            let details = match &slug_err {
1232                SlugError::IdTooLong { input, length, max } => serde_json::json!({
1233                    "reason": reason,
1234                    "input": input,
1235                    "length": length,
1236                    "max": max,
1237                }),
1238                SlugError::TitleEmpty { input } => serde_json::json!({
1239                    "reason": reason,
1240                    "input": input,
1241                }),
1242                SlugError::TitleHasControlChars {
1243                    input,
1244                    control_chars,
1245                    proposed_slug,
1246                } => {
1247                    let control_chars_str: Vec<String> = control_chars
1248                        .iter()
1249                        .map(|c| c.escape_default().to_string())
1250                        .collect();
1251                    serde_json::json!({
1252                        "reason": reason,
1253                        "input": input,
1254                        "control_chars": control_chars_str,
1255                        "proposed_slug": proposed_slug,
1256                    })
1257                }
1258            };
1259            tool_error_with_payload(
1260                "INVALID_TITLE",
1261                &message,
1262                envelope("INVALID_TITLE", message.clone(), details),
1263            )
1264        }
1265        E::StubCannotRelate { id } => tool_error_with_payload(
1266            "STUB_CANNOT_RELATE",
1267            &message,
1268            envelope(
1269                "STUB_CANNOT_RELATE",
1270                message.clone(),
1271                serde_json::json!({ "id": id }),
1272            ),
1273        ),
1274        E::StubNotUpdatable { id } => tool_error_with_payload(
1275            "STUB_NOT_UPDATABLE",
1276            &message,
1277            envelope(
1278                "STUB_NOT_UPDATABLE",
1279                message.clone(),
1280                serde_json::json!({ "id": id }),
1281            ),
1282        ),
1283        E::StubNotRenamable { id } => tool_error_with_payload(
1284            "STUB_NOT_RENAMABLE",
1285            &message,
1286            envelope(
1287                "STUB_NOT_RENAMABLE",
1288                message.clone(),
1289                serde_json::json!({ "id": id }),
1290            ),
1291        ),
1292        E::InvalidEntityId { id, reason } => tool_error_with_payload(
1293            "INVALID_ENTITY_ID",
1294            &message,
1295            envelope(
1296                "INVALID_ENTITY_ID",
1297                message.clone(),
1298                serde_json::json!({ "id": id, "reason": reason }),
1299            ),
1300        ),
1301        E::InvalidWikiLinkTarget {
1302            raw,
1303            suggested,
1304            section,
1305            link_source,
1306            reason,
1307        } => tool_error_with_payload(
1308            "INVALID_WIKI_LINK_TARGET",
1309            &message,
1310            envelope(
1311                "INVALID_WIKI_LINK_TARGET",
1312                message.clone(),
1313                serde_json::json!({
1314                    "raw": raw,
1315                    "suggested": suggested,
1316                    "section": section,
1317                    "source": link_source,
1318                    "reason": reason,
1319                }),
1320            ),
1321        ),
1322        E::InvalidWikiLinkMem {
1323            raw,
1324            section,
1325            reason,
1326        } => tool_error_with_payload(
1327            "INVALID_MEM_NAME",
1328            &message,
1329            envelope(
1330                "INVALID_MEM_NAME",
1331                message.clone(),
1332                serde_json::json!({
1333                    "raw": raw,
1334                    "section": section,
1335                    "reason": reason,
1336                }),
1337            ),
1338        ),
1339        E::RelationHasBodyLinks {
1340            from_id,
1341            to_id,
1342            rel_type,
1343            body_links,
1344        } => tool_error_with_payload(
1345            "RELATION_HAS_BODY_LINKS",
1346            &message,
1347            envelope(
1348                "RELATION_HAS_BODY_LINKS",
1349                message.clone(),
1350                serde_json::json!({
1351                    "from_id": from_id,
1352                    "to_id": to_id,
1353                    "rel_type": rel_type,
1354                    "body_links": body_links,
1355                }),
1356            ),
1357        ),
1358        E::Validation(verr) => unified_validation_envelope(verr.clone()),
1359        // Lifecycle envelopes: the unified
1360        // `mem_management::create_mem` / `delete_mem` paths
1361        // surface these on the same wire contract.
1362        E::MemNameCollision {
1363            name,
1364            source_origin,
1365        } => tool_error_with_payload(
1366            "MEM_NAME_COLLISION",
1367            &message,
1368            envelope(
1369                "MEM_NAME_COLLISION",
1370                message.clone(),
1371                serde_json::json!({
1372                    "name": name,
1373                    "source": source_origin,
1374                }),
1375            ),
1376        ),
1377        e @ E::SchemaNotFound { .. } => tool_error_with_payload(
1378            "SCHEMA_NOT_FOUND",
1379            &message,
1380            envelope("SCHEMA_NOT_FOUND", message.clone(), e.details()),
1381        ),
1382        E::EmbeddedSchemaInvalid { mem, pin, reason } => tool_error_with_payload(
1383            "EMBEDDED_SCHEMA_INVALID",
1384            &message,
1385            envelope(
1386                "EMBEDDED_SCHEMA_INVALID",
1387                message.clone(),
1388                serde_json::json!({ "mem": mem, "schema": pin, "error": reason }),
1389            ),
1390        ),
1391        E::SchemaPackageInvalid {
1392            name,
1393            version,
1394            message: detail,
1395        } => tool_error_with_payload(
1396            "SCHEMA_VALIDATION_FAILED",
1397            &message,
1398            envelope(
1399                "SCHEMA_VALIDATION_FAILED",
1400                message.clone(),
1401                serde_json::json!({
1402                    "schema": format!("{name}@{version}"),
1403                    "error": detail,
1404                }),
1405            ),
1406        ),
1407        E::SchemaResolverInit(detail) => tool_error_with_payload(
1408            "SCHEMA_RESOLVER_INIT_FAILED",
1409            &message,
1410            envelope(
1411                "SCHEMA_RESOLVER_INIT_FAILED",
1412                message.clone(),
1413                serde_json::json!({ "detail": detail }),
1414            ),
1415        ),
1416        E::Mem(detail) => tool_error_with_payload(
1417            "MEM_ERROR",
1418            &message,
1419            envelope(
1420                "MEM_ERROR",
1421                message.clone(),
1422                serde_json::json!({ "detail": detail }),
1423            ),
1424        ),
1425        E::InvalidInput(msg) => tool_error_with_payload(
1426            "INVALID_INPUT",
1427            &message,
1428            envelope(
1429                "INVALID_INPUT",
1430                message.clone(),
1431                serde_json::json!({ "message": msg }),
1432            ),
1433        ),
1434        E::RenameSimilarityOutOfRange {
1435            requested,
1436            allowed_min,
1437            allowed_max,
1438        } => tool_error_with_payload(
1439            "INVALID_INPUT",
1440            &message,
1441            envelope(
1442                "INVALID_INPUT",
1443                message.clone(),
1444                serde_json::json!({
1445                    "field": "rename_similarity",
1446                    "requested": requested,
1447                    "allowed_range": [allowed_min, allowed_max],
1448                }),
1449            ),
1450        ),
1451        E::MemConfigIncomplete {
1452            mem,
1453            missing_fields,
1454        } => tool_error_with_payload(
1455            "MEM_CONFIG_INCOMPLETE",
1456            &message,
1457            envelope(
1458                "MEM_CONFIG_INCOMPLETE",
1459                message.clone(),
1460                serde_json::json!({
1461                    "mem": mem,
1462                    "missing_fields": missing_fields,
1463                    "set_via": format!("memstead mem set-version {mem} <version>"),
1464                }),
1465            ),
1466        ),
1467        E::WikiLinkWithoutRelation { from_id, missing } => tool_error_with_payload(
1468            "WIKILINK_WITHOUT_RELATION",
1469            &message,
1470            envelope(
1471                "WIKILINK_WITHOUT_RELATION",
1472                message.clone(),
1473                serde_json::json!({
1474                    "from_id": from_id,
1475                    "missing": missing,
1476                }),
1477            ),
1478        ),
1479        E::DescriptionNotPermitted {
1480            rel_type,
1481            from_id,
1482            to_id,
1483        } => tool_error_with_payload(
1484            "DESCRIPTION_NOT_PERMITTED",
1485            &message,
1486            envelope(
1487                "DESCRIPTION_NOT_PERMITTED",
1488                message.clone(),
1489                serde_json::json!({
1490                    "rel_type": rel_type,
1491                    "from_id": from_id,
1492                    "to_id": to_id,
1493                }),
1494            ),
1495        ),
1496        E::MissingRequiredDescription {
1497            rel_type,
1498            from_id,
1499            to_id,
1500        } => tool_error_with_payload(
1501            "MISSING_REQUIRED_DESCRIPTION",
1502            &message,
1503            envelope(
1504                "MISSING_REQUIRED_DESCRIPTION",
1505                message.clone(),
1506                serde_json::json!({
1507                    "rel_type": rel_type,
1508                    "from_id": from_id,
1509                    "to_id": to_id,
1510                }),
1511            ),
1512        ),
1513        E::RelationManualAuthoringForbidden {
1514            rel_type,
1515            from_id,
1516            to_id,
1517            guidance,
1518        } => tool_error_with_payload(
1519            "RELATION_MANUAL_AUTHORING_FORBIDDEN",
1520            &message,
1521            envelope(
1522                "RELATION_MANUAL_AUTHORING_FORBIDDEN",
1523                message.clone(),
1524                serde_json::json!({
1525                    "rel_type": rel_type,
1526                    "from_id": from_id,
1527                    "to_id": to_id,
1528                    "guidance": guidance,
1529                }),
1530            ),
1531        ),
1532        E::RenameNoOp { id, new_title } => tool_error_with_payload(
1533            "RENAME_NO_OP",
1534            &message,
1535            envelope(
1536                "RENAME_NO_OP",
1537                message.clone(),
1538                serde_json::json!({
1539                    "id": id,
1540                    "new_title": new_title,
1541                }),
1542            ),
1543        ),
1544        E::RenameBlockedByCrossMemPolicy {
1545            from_mem,
1546            blocked_referrers,
1547        } => {
1548            let entries: Vec<_> = blocked_referrers
1549                .iter()
1550                .map(|r| {
1551                    serde_json::json!({
1552                        "from_mem": r.from_mem,
1553                        "to_mem": r.to_mem,
1554                        "count": r.count,
1555                    })
1556                })
1557                .collect();
1558            tool_error_with_payload(
1559                "RENAME_BLOCKED_BY_CROSS_MEM_POLICY",
1560                &message,
1561                envelope(
1562                    "RENAME_BLOCKED_BY_CROSS_MEM_POLICY",
1563                    message.clone(),
1564                    serde_json::json!({
1565                        "from_mem": from_mem,
1566                        "blocked_referrers": entries,
1567                    }),
1568                ),
1569            )
1570        }
1571        E::RenamePartialFailure {
1572            committed_mems,
1573            failed_mem,
1574            failure_cause,
1575        } => tool_error_with_payload(
1576            "RENAME_PARTIAL_FAILURE",
1577            &message,
1578            envelope(
1579                "RENAME_PARTIAL_FAILURE",
1580                message.clone(),
1581                serde_json::json!({
1582                    "committed_mems": committed_mems,
1583                    "failed_mem": failed_mem,
1584                    "failure_cause": failure_cause,
1585                }),
1586            ),
1587        ),
1588        E::DuplicateMem(name) => tool_error_with_payload(
1589            "DUPLICATE_MEM",
1590            &message,
1591            envelope(
1592                "DUPLICATE_MEM",
1593                message.clone(),
1594                serde_json::json!({ "name": name }),
1595            ),
1596        ),
1597        // Parse-after-write / parse / backend: typed code, free-form
1598        // detail string so callers can render the underlying message
1599        // without grepping it back out of the envelope's `message`.
1600        E::ParseAfterWrite(detail) => tool_error_with_payload(
1601            "PARSE_ERROR",
1602            &message,
1603            envelope(
1604                "PARSE_ERROR",
1605                message.clone(),
1606                serde_json::json!({ "detail": detail }),
1607            ),
1608        ),
1609        E::Parse(inner) => tool_error_with_payload(
1610            "PARSE_ERROR",
1611            &message,
1612            envelope(
1613                "PARSE_ERROR",
1614                message.clone(),
1615                serde_json::json!({ "detail": inner.to_string() }),
1616            ),
1617        ),
1618        E::Backend(inner) => tool_error_with_payload(
1619            "MEM_ERROR",
1620            &message,
1621            envelope(
1622                "MEM_ERROR",
1623                message.clone(),
1624                serde_json::json!({ "detail": inner.to_string() }),
1625            ),
1626        ),
1627        E::SearchUnavailable => tool_error_with_payload(
1628            "SEARCH_UNAVAILABLE_IN_WASM",
1629            &message,
1630            envelope(
1631                "SEARCH_UNAVAILABLE_IN_WASM",
1632                message.clone(),
1633                serde_json::json!({}),
1634            ),
1635        ),
1636        // Typed refusal when
1637        // `export_markdown` targets a mem whose active backend
1638        // doesn't support markdown regeneration.
1639        E::MarkdownExportUnsupportedBackend {
1640            mem,
1641            active_backend,
1642            supported_backends,
1643        } => tool_error_with_payload(
1644            "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND",
1645            &message,
1646            envelope(
1647                "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND",
1648                message.clone(),
1649                serde_json::json!({
1650                    "mem": mem,
1651                    "active_backend": active_backend,
1652                    "supported_backends": supported_backends,
1653                }),
1654            ),
1655        ),
1656        E::EmptyUpdate { id } => tool_error_with_payload(
1657            "EMPTY_UPDATE",
1658            &message,
1659            envelope(
1660                "EMPTY_UPDATE",
1661                message.clone(),
1662                serde_json::json!({
1663                    "id": id,
1664                    "recognised_keys": [
1665                        "sections", "append_sections", "patch_sections",
1666                        "metadata", "metadata_unset", "declare_relations", "relations_unset",
1667                    ],
1668                }),
1669            ),
1670        ),
1671        E::InvalidChangesCursor { mem, since } => tool_error_with_payload(
1672            "INVALID_CURSOR",
1673            &message,
1674            envelope(
1675                "INVALID_CURSOR",
1676                message.clone(),
1677                serde_json::json!({ "mem": mem, "since": since }),
1678            ),
1679        ),
1680        // Review-mark diff on a markless mem — typed refusal so agents
1681        // never equate "no mark" with "no changes".
1682        E::ReviewMarkNotSet { mem } => tool_error_with_payload(
1683            "REVIEW_MARK_NOT_SET",
1684            &message,
1685            envelope(
1686                "REVIEW_MARK_NOT_SET",
1687                message.clone(),
1688                serde_json::json!({ "mem": mem }),
1689            ),
1690        ),
1691        // Malformed `anchors[]` element on create/update: typed
1692        // `INVALID_ANCHOR` with the wrapped anchor error's recovery detail
1693        // (offending field, bad value, allowed set). The whole mutation
1694        // refused and the entity was not written.
1695        E::InvalidAnchor(anchor_err) => tool_error_with_payload(
1696            memstead_base::anchor::INVALID_ANCHOR_CODE,
1697            &message,
1698            envelope(
1699                memstead_base::anchor::INVALID_ANCHOR_CODE,
1700                message.clone(),
1701                serde_json::Value::Object(anchor_err.detail().into_iter().collect()),
1702            ),
1703        ),
1704    }
1705}
1706
1707/// Typed-envelope translator for `FullEngineError`. Delegates wrapped
1708/// lean errors to [`engine_err_unified`]; constructs the lifecycle-
1709/// specific envelopes (`MEM_PATH_NOT_ALLOWED`,
1710/// `MEM_REFERENCED_BY_POLICY`, `MEM_SCHEMA_NOT_ALLOWED`,
1711/// `CONFIG_ERROR`) here. The wire shape is
1712/// bit-identical to what `engine_err_unified` produced for the same
1713/// variants before the lifecycle variants moved off
1714/// `memstead_base::EngineError`; the move is pure plumbing.
1715fn full_engine_err_unified(
1716    e: memstead_engine::FullEngineError,
1717    engine: &memstead_base::Engine,
1718) -> CallToolResult {
1719    use memstead_engine::FullEngineError as PE;
1720    // The text-channel message uses the rich-prose renderer so lifecycle
1721    // refusals (MEM_PATH_NOT_ALLOWED, MEM_SCHEMA_NOT_ALLOWED,
1722    // MEM_REFERENCED_BY_POLICY) inline their full recovery payload
1723    // inline rather than relying on the structured channel for
1724    // recovery context.
1725    let message = e.prose_render();
1726    // Shared structured payload — computed before the match so the
1727    // lifecycle arms cannot drift from the CLI envelope, which lifts
1728    // the same `details()`.
1729    let shared_details = e.details();
1730    match e {
1731        // #55: thread the engine so the wrapped-lean path enriches
1732        // not-found envelopes the same as every other call site.
1733        PE::Lean(inner) => engine_err_unified(inner, engine),
1734        PE::MemPathNotAllowed { .. } => tool_error_with_payload(
1735            "MEM_PATH_NOT_ALLOWED",
1736            &message,
1737            envelope("MEM_PATH_NOT_ALLOWED", message.clone(), shared_details),
1738        ),
1739        PE::InvalidMemName { name, reason } => tool_error_with_payload(
1740            "INVALID_MEM_NAME",
1741            &message,
1742            envelope(
1743                "INVALID_MEM_NAME",
1744                message.clone(),
1745                serde_json::json!({
1746                    "name": name,
1747                    "reason": reason,
1748                }),
1749            ),
1750        ),
1751        PE::MemSchemaNotAllowed {
1752            candidate,
1753            matched_pattern,
1754            requested_schema,
1755            allowed_schemas,
1756        } => tool_error_with_payload(
1757            "MEM_SCHEMA_NOT_ALLOWED",
1758            &message,
1759            envelope(
1760                "MEM_SCHEMA_NOT_ALLOWED",
1761                message.clone(),
1762                serde_json::json!({
1763                    "candidate": candidate,
1764                    "matched_pattern": matched_pattern,
1765                    "requested_schema": requested_schema,
1766                    "allowed_schemas": allowed_schemas,
1767                }),
1768            ),
1769        ),
1770        PE::MemReferencedByPolicy {
1771            name,
1772            referring_mems,
1773        } => tool_error_with_payload(
1774            "MEM_REFERENCED_BY_POLICY",
1775            &message,
1776            envelope(
1777                "MEM_REFERENCED_BY_POLICY",
1778                message.clone(),
1779                serde_json::json!({
1780                    "name": name,
1781                    "referring_mems": referring_mems,
1782                }),
1783            ),
1784        ),
1785        PE::ConfigAlreadyExists { path } => tool_error_with_payload(
1786            "CONFIG_ERROR",
1787            &message,
1788            envelope(
1789                "CONFIG_ERROR",
1790                message.clone(),
1791                serde_json::json!({
1792                    "path": path.display().to_string(),
1793                    "reason": "config_already_exists",
1794                }),
1795            ),
1796        ),
1797        PE::MemStorageResidueDetected {
1798            branch_ref,
1799            config_blob,
1800            entity_count,
1801        } => tool_error_with_payload(
1802            "MEM_STORAGE_RESIDUE_DETECTED",
1803            &message,
1804            envelope(
1805                "MEM_STORAGE_RESIDUE_DETECTED",
1806                message.clone(),
1807                serde_json::json!({
1808                    "branch_ref": branch_ref,
1809                    "config_blob": config_blob,
1810                    "entity_count": entity_count,
1811                    "recovery": ["reattach", "force_overwrite", "hard_cleanup_first"],
1812                }),
1813            ),
1814        ),
1815    }
1816}
1817
1818/// Map a runtime [`memstead_base::runtime_validator::ValidationError`] to
1819/// the MCP wire envelope. Thin delegation to the shared
1820/// [`crate::error_envelopes::validation_envelope`] so the unified
1821/// engine's mutation handlers emit the same wire shape full's
1822/// filesystem-server already does.
1823fn unified_validation_envelope(
1824    err: memstead_base::runtime_validator::ValidationError,
1825) -> CallToolResult {
1826    crate::error_envelopes::validation_envelope(err)
1827}
1828
1829/// Find entity IDs that end with the given suffix (slug or medium--slug).
1830/// Returns up to `max` suggestions for "did you mean?" messages.
1831///
1832/// Takes `&Store` directly so the helper composes against any
1833/// engine — `memstead_base::Engine::store()` exposes a `memstead_base::Store`.
1834fn suggest_similar(store: &memstead_base::Store, input: &str) -> Vec<String> {
1835    let needle = input.trim_start_matches("@memstead/");
1836    store
1837        .all_ids()
1838        .filter(|id| {
1839            let haystack = id.as_ref();
1840            // Match if the stored ID ends with the input (e.g. "mcp-server" matches "...specs--mcp-server")
1841            haystack.ends_with(needle)
1842                || haystack.ends_with(&format!("--{needle}"))
1843                // Also match if the slug portion contains the input
1844                || haystack.rsplit_once("--").is_some_and(|(_, slug)| slug.contains(needle))
1845        })
1846        .take(5)
1847        .map(|id| id.to_string())
1848        .collect()
1849}
1850
1851/// Build a "not found" error with suggestions.
1852///
1853/// Routes through [`tool_error_with_payload`] so the text channel
1854/// carries the `ERROR [ENTITY_NOT_FOUND]: …` prefix and
1855/// `structured_content` carries the `{ code, message, details }`
1856/// envelope — matching every other not-found return on the surface.
1857/// Takes `&Store` so the helper works for both full and unified engines.
1858fn not_found_error(store: &memstead_base::Store, id: &EntityId) -> CallToolResult {
1859    let suggestions = suggest_similar(store, id.as_ref());
1860    let msg = if suggestions.is_empty() {
1861        format!("Entity not found: {id}")
1862    } else {
1863        format!(
1864            "Entity not found: \"{id}\". Did you mean: {}",
1865            suggestions.join(", ")
1866        )
1867    };
1868    tool_error_with_payload(
1869        "ENTITY_NOT_FOUND",
1870        &msg,
1871        envelope(
1872            "ENTITY_NOT_FOUND",
1873            msg.clone(),
1874            serde_json::json!({
1875                "id": id.as_ref(),
1876                "suggestions": suggestions,
1877            }),
1878        ),
1879    )
1880}
1881
1882// ==========================================================================
1883// Tool implementations
1884// ==========================================================================
1885
1886#[tool_router(vis = "pub")]
1887impl McpServer {
1888    // ----------------------------------------------------------------------
1889    // Read-only graph tools
1890    // ----------------------------------------------------------------------
1891
1892    #[tool(
1893        name = "memstead_entity",
1894        description = "Read one entity. Dual channel: text carries rendered markdown for direct prose consumption; `structured_content` carries the typed envelope `{ _hash, id, mem, type, origin, _tokens, metadata, sections, relationships, _stub_kind? }` so agents branch on fields without parsing the text. `origin` is the content's trust class — `first-party` for an entity from a writable workspace mem, `third-party` for one from a read-only mount (a registry-installed read-mem or an adopted foreign folder/clone), which the host should treat as quoted, untrusted data. `_hash` is the optimistic-lock token. The nested `metadata` map is the single home for every schema-declared frontmatter key the entity holds — read a value as `metadata.level`, etc. Identity keys (`mem`/`id`/`type`) and underscore-prefixed engine slots stay top-level, not repeated inside the map. After a successful `memstead_relate` the on-disk hash advances — the relate response's `_hash` is the next valid `expected_hash` (shared mutation contract, see server instructions). Use `include_relations: true` to append a `## Relations` section; `include_context: true` to append the entity's community cluster. Pass `sections` to narrow output to specific section keys (also narrows `structured_content.sections`); when narrowed, `_tokens_unfiltered_body` surfaces the unfiltered-base cost so agents can predict the cost of dropping the filter. With `include_relations`/`include_context` active, `_tokens` may exceed `_tokens_unfiltered_body` because opt-in inserts contribute only to `_tokens`. Stubs render with empty sections + relationships arrays and an empty `metadata: {}` map. `token_budget`/`chunk` bound only the rendered-markdown **text** channel: over-budget text adds `_chunk`/`_total_chunks`/`_truncated` markers. The `structured_content` envelope always ships whole — never chunked or truncated; size it ahead via `_tokens`. Use memstead_overview for cold-start, memstead_search to find IDs, memstead_update to mutate.",
1895        annotations(
1896            read_only_hint = true,
1897            destructive_hint = false,
1898            idempotent_hint = true,
1899            open_world_hint = false
1900        )
1901    )]
1902    fn memstead_entity(&self, Parameters(p): Parameters<EntityParams>) -> CallToolResult {
1903        if let Some(err) = validate_entity_id(&p.id) {
1904            return err;
1905        }
1906        let id = EntityId::canonical(&p.id);
1907
1908        let unified = self.unified_engine();
1909        let mut engine = crate::lock_engine!(unified);
1910        let drift_warnings = engine.reload_if_stale(Some(id.mem()));
1911        // Drain the stashed structured notices: attached to the
1912        // response's `structured_content` below (and the markdown
1913        // `MEM_RELOADED` warning still rides the text channel).
1914        let mem_changed_notices = engine.take_mem_changed_notices();
1915        let entity = match engine.get_entity(&id) {
1916            Some(e) => e.clone(),
1917            // Drift must survive the error path too: a sibling that
1918            // deleted X advanced this engine's head during the reload
1919            // above, so a bare `not_found_error` would consume the
1920            // drained notice and silently swallow the whole reload
1921            // window. Attach it on the success channel split.
1922            None => {
1923                // A quarantined mem's entities are deliberately not in
1924                // the store; refusing ENTITY_NOT_FOUND there would be
1925                // dishonest ("honest absence beats partial truth" —
1926                // the read names the quarantine, not a phantom miss).
1927                if engine.quarantine_reason(id.mem()).is_some() {
1928                    let err = engine.unknown_mem_error(id.mem());
1929                    return attach_drift_to_error(
1930                        engine_err_unified(err, &engine),
1931                        &drift_warnings,
1932                        mem_changed_notices,
1933                    );
1934                }
1935                return attach_drift_to_error(
1936                    not_found_error(engine.store(), &id),
1937                    &drift_warnings,
1938                    mem_changed_notices,
1939                );
1940            }
1941        };
1942        let schema_anchor = mem_schema_ref_unified(&engine, id.mem());
1943
1944        let sections_filter = p.sections.as_deref();
1945        let mut md = render::render_entity_markdown(&entity, sections_filter);
1946
1947        if p.include_relations.unwrap_or(false) {
1948            let outgoing = engine.store().outgoing(&id).to_vec();
1949            let incoming = engine.store().incoming(&id).to_vec();
1950            md.push_str(&render::render_relations_markdown(
1951                id.as_ref(),
1952                &outgoing,
1953                &incoming,
1954            ));
1955        }
1956
1957        if p.include_context.unwrap_or(false)
1958            && let Some(ctx) = engine.context(&id)
1959        {
1960            let cluster_id = ctx.community.clone().unwrap_or_else(|| "unknown".into());
1961            md.push_str(&render::render_community_context_section(&ctx, &cluster_id));
1962        }
1963
1964        if let Some(ref s) = schema_anchor {
1965            inject_md_mem_schema(&mut md, s);
1966        }
1967
1968        let mut extra_fm: Vec<(&str, &str)> = vec![("_hash", &entity.content_hash)];
1969        if let Some(ref s) = schema_anchor {
1970            extra_fm.push(("_mem_schema", s.as_str()));
1971        }
1972
1973        // Structured envelope
1974        // rides alongside the chunked markdown text channel. Built
1975        // off the *unchunked* entity so consumers can branch on full
1976        // field shapes regardless of which chunk the text channel
1977        // ships; sections-filtering still applies so a narrowed read
1978        // narrows both channels. `_tokens` reflects the rendered body
1979        // (post-filter, post-opt-in); `_tokens_unfiltered_body`
1980        // surfaces when the filter dropped any sections, matching
1981        // the markdown renderer's signal that there is "more entity"
1982        // to read. Renamed from `_tokens_full` because the
1983        // previous name implied a monotonic relationship the opt-in
1984        // path can invert.
1985        let rendered_body_tokens = estimate_tokens(&md);
1986        let full_tokens = if sections_filter.is_some() {
1987            let full_body = render::render_entity_markdown(&entity, None);
1988            Some(estimate_tokens(&full_body))
1989        } else {
1990            None
1991        };
1992        let mut structured = render::build_entity_envelope(
1993            &entity,
1994            rendered_body_tokens,
1995            full_tokens,
1996            sections_filter,
1997            schema_anchor.as_deref(),
1998            engine.store().outgoing(&id),
1999        );
2000        // Data-origin label: an entity from a read-only mount (a
2001        // registry-installed read-mem or an adopted foreign folder/
2002        // clone) is third-party — the consuming agent/host should treat
2003        // its body as quoted, untrusted data. Writable-mem content is
2004        // first-party. Additive top-level field on the structured channel.
2005        if let Some(obj) = structured.as_object_mut() {
2006            obj.insert(
2007                "origin".into(),
2008                serde_json::json!(engine.mem_origin_class(id.mem()).as_wire()),
2009            );
2010            // Authoring provenance carried in the installed archive. Emitted
2011            // only when the mem ships a provenance payload; `history`
2012            // makes the "full commit history not shipped" decision
2013            // observable, and `rationale` is `null` when this entity was
2014            // authored without a note — absence reported as absence, never
2015            // a fabricated value. A mem with no payload omits the field.
2016            if let Some(prov) = engine.archive_provenance_for(id.mem()) {
2017                let mut block = serde_json::Map::new();
2018                block.insert("history".into(), serde_json::json!(prov.history));
2019                let rec = prov.entity(id.path());
2020                block.insert(
2021                    "rationale".into(),
2022                    rec.and_then(|r| r.rationale.as_ref())
2023                        .map(|s| serde_json::json!(s))
2024                        .unwrap_or(serde_json::Value::Null),
2025                );
2026                if let Some(r) = rec {
2027                    if let Some(kind) = &r.kind {
2028                        block.insert("kind".into(), serde_json::json!(kind));
2029                    }
2030                    if let Some(ts) = &r.timestamp {
2031                        block.insert("timestamp".into(), serde_json::json!(ts));
2032                    }
2033                    if let Some(actor) = &r.actor {
2034                        block.insert("actor".into(), serde_json::json!(actor));
2035                    }
2036                }
2037                obj.insert("provenance".into(), serde_json::Value::Object(block));
2038            }
2039            // Mutation provenance (agent-trust plan 13), opt-in:
2040            // created-by / last-modified-by with actor, client,
2041            // declared role, and timestamp — derived from the
2042            // append-only mutation record, which no verb can edit.
2043            // Distinct key from the archive-provenance block above
2044            // (that one describes an installed archive's authoring
2045            // payload). Default responses are byte-unchanged; on a
2046            // mount whose seam records no history (archives) the
2047            // block states unavailability instead of fabricating.
2048            if p.include_provenance.unwrap_or(false) {
2049                let block = match engine.entity_provenance(id.mem(), id.as_ref()) {
2050                    Ok(prov) => serde_json::to_value(&prov).unwrap_or(serde_json::Value::Null),
2051                    Err(e) => serde_json::json!({
2052                        "unavailable": e.to_string(),
2053                    }),
2054                };
2055                obj.insert("mutation_provenance".into(), block);
2056            }
2057            // Provenance anchors (E3a). Additive, emitted only when the
2058            // entity has anchors so a pre-E3a reader is unaffected. Carries
2059            // the stored anchor records plus their class/grain composition
2060            // (derived inputs; tree-grain fan-out on its own axis) and, for a
2061            // path-medium mem, each anchor's live resolution `state`
2062            // (resolves / drifted / recheck / orphaned — additive per-anchor
2063            // field). A present hash-bearing anchor adjudicates its recorded
2064            // prepared-content hash against the observed one, so `drifted` is
2065            // deterministic on a stable medium; `state` is absent when the
2066            // source is unobserved (non-path medium), never fabricated.
2067            let resolved = engine.entity_anchors_resolved(&id);
2068            if !resolved.is_empty() {
2069                let anchors: Vec<memstead_base::anchor::Anchor> =
2070                    resolved.iter().map(|r| r.anchor.clone()).collect();
2071                let composition = memstead_base::anchor::compose_entity_anchors(&anchors);
2072                obj.insert(
2073                    "anchors".into(),
2074                    serde_json::to_value(&resolved).unwrap_or(serde_json::Value::Null),
2075                );
2076                obj.insert(
2077                    "anchor_composition".into(),
2078                    serde_json::to_value(&composition).unwrap_or(serde_json::Value::Null),
2079                );
2080            }
2081        }
2082
2083        let budget = p.token_budget.unwrap_or(self.token_budget);
2084        attach_mem_changed_to_result(
2085            match apply_chunking(&md, budget, p.chunk, &extra_fm) {
2086                Ok(result) => md_with_structured(
2087                    prepend_drift_warnings_md(result, &drift_warnings),
2088                    structured,
2089                ),
2090                Err(e) => prepend_drift_warnings_to_result_text(
2091                    tool_error("INVALID_INPUT", &e),
2092                    &drift_warnings,
2093                ),
2094            },
2095            mem_changed_notices,
2096        )
2097    }
2098
2099    #[tool(
2100        name = "memstead_search",
2101        description = "Search entities by lexical content + structural filters. Dual channel: rendered-markdown text plus typed `SearchResultEnvelope` on `structured_content`: `{ _total, _returned, _offset, _total_tokens, hits[], facets, warnings }` each hit: `score`, `score_breakdown`, `matched_terms`, `expansion`, `origin` (trust class), `snippet` (section bodies via memstead_entity). A page is bounded to `token_budget` (default 12000); an overflowing page is trimmed with a `SEARCH_RESULTS_TRUNCATED` warning (`kept`/`budget`), `_total` stays the full count, page with `offset`. The caller expands a concept into keyword variants. Put variants into `query.any` (OR, rank-boosted); excludes in `query.not`; `query.phrase` for exact adjacency; `query.field` to restrict to one field. Set `expand_via` to relationship types — reached hits carry `expansion` metadata incl. `via_direction` + decayed score (0.5^depth); `direction` (out|in|both) narrows the walk per hop. `facets` (by_type, by_mem, by_level, by_status, by_confidence, by_subsection, by_expansion) compose results structurally. Sub-heading matches carry `heading_path`. `stub: true|false` filters stub status (with `entity_type` it flags `STUB_FILTER_EXCLUDES_ALL`). Equality filters on `filterable: equality` fields ride on `filters` (e.g. `{\"level\": \"M0\"}`); one code per outcome: `FILTER_TYPE_SCOPED` (applied, type-narrowed), `FIELD_NOT_FILTERABLE` (ignored — result unfiltered, never emptied), `UNKNOWN_FILTER_KEY` (ignored), `INVALID_ENUM_VALUE` (applies but matches nothing; `details.allowed`). `related_to`: proximity-ranked neighbourhood, bounded with `NEIGHBOURHOOD_CAPPED`. Range filters on `filterable: range` fields ride on `range_filters` (`min_<field>`/`max_<field>`/`<field>_before`/`<field>_after`), same contract: `RANGE_FILTER_KEY_MALFORMED`, `RANGE_FILTER_TYPE_SCOPED`, `UNKNOWN_RANGE_FILTER_FIELD`, `FIELD_NOT_RANGE_FILTERABLE`. A failing mem index surfaces `SEARCH_MEM_INDEX_UNAVAILABLE` (`details.mem`/`details.reason`). Omit `query` for a pure metadata filter.",
2102        annotations(
2103            read_only_hint = true,
2104            destructive_hint = false,
2105            idempotent_hint = true,
2106            open_world_hint = false
2107        )
2108    )]
2109    fn memstead_search(&self, Parameters(p): Parameters<SearchParams>) -> CallToolResult {
2110        let filters = p.filters.clone().unwrap_or_default();
2111
2112        // Telemetry: one line per invocation with flags only — no
2113        // query strings, no entity content. Enables Tier 2 (regex / fuzzy /
2114        // nested / per-term field) to be decided from real usage data.
2115        let q = p.query.as_ref();
2116        tracing::info!(
2117            any_term_count = q.map(|q| q.any.len()).unwrap_or(0),
2118            has_phrase = q.is_some_and(|q| q.phrase.is_some()),
2119            has_not = q.is_some_and(|q| !q.not.is_empty()),
2120            has_field = q.is_some_and(|q| q.field.is_some()),
2121            has_expand = p.expand_via.as_ref().is_some_and(|v| !v.is_empty()),
2122            mem_scope = if p.mem.is_some() { "one" } else { "all" },
2123            "memstead_search invoked"
2124        );
2125
2126        // Snapshot the mem filter for drift detection before the scope
2127        // construction below moves `p.mem`.
2128        let mem_filter = p.mem.clone();
2129
2130        let scope = SearchScope {
2131            query: p.query,
2132            mem: p.mem,
2133            entity_type: p.entity_type,
2134            limit: p.limit,
2135            offset: p.offset,
2136            filters,
2137            // Thread the
2138            // agent's `range_filters` input into the engine arg.
2139            // The engine's `collect_range_filter_warnings` was
2140            // ready-but-unreachable before this wiring.
2141            range_filters: p.range_filters.unwrap_or_default(),
2142            edge_type: p.edge_type,
2143            related_to: p.related_to.map(EntityId),
2144            depth: p.depth,
2145            expand_via: p.expand_via,
2146            expand_depth: p.expand_depth,
2147            direction: p.direction.unwrap_or_default(),
2148            stub: p.stub,
2149            token_budget: p.token_budget,
2150        };
2151        let offset = scope.offset.unwrap_or(0);
2152
2153        let unified = self.unified_engine();
2154        let mut engine = crate::lock_engine!(unified);
2155        let drift_warnings = engine.reload_if_stale(mem_filter.as_deref());
2156        let mem_changed_notices = engine.take_mem_changed_notices();
2157        let result = match engine.search(&scope) {
2158            Ok(r) => r,
2159            Err(e) => {
2160                return attach_drift_to_error(
2161                    engine_err_unified(e, &engine),
2162                    &drift_warnings,
2163                    mem_changed_notices,
2164                );
2165            }
2166        };
2167
2168        let md = render::render_search_markdown(&result, offset);
2169        // Structured envelope
2170        // on `structured_content`, rendered markdown on the text
2171        // channel. Search results have a useful human-readable
2172        // canonical form (the rendered prose with score lines) and
2173        // a typed branching shape — both ship in one call.
2174        let envelope = render::build_search_envelope(&result, offset);
2175        let mut structured = serde_json::to_value(&envelope).unwrap_or(serde_json::Value::Null);
2176        // Data-origin label per hit: a snippet from a read-only mount (a
2177        // registry-installed read-mem or an adopted foreign folder/
2178        // clone) is third-party — the consuming agent/host should treat
2179        // it as quoted, untrusted data. Each hit already carries `mem`;
2180        // stamp `origin` from its mount's class. Additive per-hit field.
2181        if let Some(hits) = structured.get_mut("hits").and_then(|h| h.as_array_mut()) {
2182            let mut class_of: std::collections::HashMap<String, &'static str> =
2183                std::collections::HashMap::new();
2184            for hit in hits.iter_mut() {
2185                let Some(obj) = hit.as_object_mut() else {
2186                    continue;
2187                };
2188                let Some(mem) = obj
2189                    .get("mem")
2190                    .and_then(|v| v.as_str())
2191                    .map(|s| s.to_string())
2192                else {
2193                    continue;
2194                };
2195                let wire = *class_of
2196                    .entry(mem.clone())
2197                    .or_insert_with(|| engine.mem_origin_class(&mem).as_wire());
2198                obj.insert("origin".into(), serde_json::json!(wire));
2199            }
2200        }
2201        attach_mem_changed_to_result(
2202            md_with_structured(prepend_drift_warnings_md(md, &drift_warnings), structured),
2203            mem_changed_notices,
2204        )
2205    }
2206
2207    // ----------------------------------------------------------------------
2208    // Community detection + schema tools
2209    // ----------------------------------------------------------------------
2210
2211    #[tool(
2212        name = "memstead_overview",
2213        description = "Start here. Returns the schema catalogue, mem inventory, and community clusters as markdown. Schemas list as `{ref, description}` only — call `memstead_schema(name=<ref>)` for per-type bodies (lite skeleton by default; `verbosity: \"full\"` for prose write_rules/guidance) before any `memstead_create` / `memstead_update` / `memstead_relate`; cache per session, schema is workspace-stable. Token-budget-driven: hard-required content (mem roster, schema refs, community titles, workspace policy) always ships; heavy content is greedy-filled into the remaining budget by default-priority. Anything that didn't fit appears in the `## Hints` section with `estimated_tokens`; re-query by passing `key` into `include[]`. Override priority with `include`: keys there always ship, even past budget. Allowed `include` keys: `community_members`, `community_bridges`, `mem_distribution`, `dangling_links`. Control the budget via `token_budget` (default 8000). Frontmatter `_overview_mode` is `\"complete\"` (nothing dropped), `\"reduced\"` (heavy content omitted — see the Hints section), or `\"overbudget\"` (hard-required content alone exceeded the budget; raise `token_budget` or scope with `mem`). Workspace-level mutation and link policy is surfaced in `## Workspace policy` and mirrored into the `_policy` frontmatter slot — entries appear only when the value deviates from the engine default (`require_notes`, `cross_mem_links` posture). Frontmatter `_workspace_root` is the engine's absolute workspace path — target CLI calls with it, never with cwd. Pass `mem` to scope mems and schemas to any one visible mem (read-only mounts included); community detection stays workspace-global — `mem` only filters which clusters are reported (caveats on the `mem` param). `rebuild: true` recomputes the global partition. Non-fatal issues surface under `## Warnings` with a stable `code`.",
2214        annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false),
2215        meta = always_load_meta()
2216    )]
2217    fn memstead_overview(&self, Parameters(p): Parameters<OverviewParams>) -> CallToolResult {
2218        // The schemas catalogue includes rule-referenced unpinned
2219        // schemas via `engine.workspace_schemas()`.
2220        let unified = self.unified_engine();
2221        self.memstead_overview_unified(p, unified.clone())
2222    }
2223
2224    /// Unified-engine path for [`Self::memstead_overview`]. Body lifted to
2225    /// [`memstead_engine::overview::compose_overview`] so the full CLI
2226    /// surfaces the same rich-content output via the same composer.
2227    /// This wrapper handles drift-warning collection, error-envelope
2228    /// mapping, and response-cap chunking; the composer produces the
2229    /// markdown body + warnings + extra-frontmatter.
2230    fn memstead_overview_unified(
2231        &self,
2232        p: OverviewParams,
2233        unified: Arc<Mutex<memstead_base::Engine>>,
2234    ) -> CallToolResult {
2235        let mut engine = crate::lock_engine!(unified);
2236        let drift_warnings = engine.reload_if_stale(p.mem.as_deref());
2237        let _ = engine.take_mem_changed_notices(); // leak-proof drain; see memstead_entity
2238
2239        let include = p.include.clone().unwrap_or_default();
2240        let args = memstead_engine::overview::OverviewArgs {
2241            include: &include,
2242            mem: p.mem.as_deref(),
2243            rebuild: p.rebuild.unwrap_or(false) && p.chunk.unwrap_or(1) <= 1,
2244            token_budget: p
2245                .token_budget
2246                .unwrap_or(memstead_engine::overview::DEFAULT_OVERVIEW_BUDGET),
2247            operator_mode: self.operator_mode,
2248            // The full mem-repo MCP surface carries the mem-lifecycle tools.
2249            suppress_lifecycle: false,
2250        };
2251
2252        let out = match memstead_engine::overview::compose_overview(
2253            &mut engine,
2254            args,
2255            memstead_engine::overview::Surface::Mcp,
2256        ) {
2257            Ok(o) => o,
2258            Err(memstead_engine::overview::ComposeOverviewError::InvalidIncludeKeySchemaTypes) => {
2259                let msg = "include key 'schema_types' was removed; \
2260                           call memstead_schema(name=...) for full schema bodies."
2261                    .to_string();
2262                return tool_error_with_payload(
2263                    "INVALID_INPUT",
2264                    &msg,
2265                    envelope(
2266                        "INVALID_INPUT",
2267                        msg.clone(),
2268                        serde_json::json!({ "message": msg }),
2269                    ),
2270                );
2271            }
2272            Err(memstead_engine::overview::ComposeOverviewError::MemQuarantined(name)) => {
2273                let err = engine.unknown_mem_error(&name);
2274                return engine_err_unified(err, &engine);
2275            }
2276            Err(memstead_engine::overview::ComposeOverviewError::UnknownMem {
2277                name,
2278                writable_mems,
2279            }) => {
2280                let msg = format!(
2281                    "unknown mem: \"{name}\". Writable mems: [{}]",
2282                    writable_mems.join(", ")
2283                );
2284                return tool_error_with_payload(
2285                    "UNKNOWN_MEM",
2286                    &msg,
2287                    envelope(
2288                        "UNKNOWN_MEM",
2289                        msg.clone(),
2290                        serde_json::json!({
2291                            "name": name,
2292                            "writable_mems": writable_mems,
2293                        }),
2294                    ),
2295                );
2296            }
2297        };
2298
2299        // Promote the composer's extra-frontmatter into the
2300        // `apply_chunking` shape (`Vec<(&str, &str)>`). The slots stay
2301        // alive through `out` for the duration of this call.
2302        let extra_fm: Vec<(&str, &str)> = out
2303            .extra_frontmatter
2304            .iter()
2305            .map(|(k, v)| (k.as_str(), v.as_str()))
2306            .collect();
2307        match apply_chunking(&out.markdown, self.token_budget, p.chunk, &extra_fm) {
2308            Ok(r) => md_response(prepend_drift_warnings_md(r, &drift_warnings)),
2309            Err(e) => tool_error("INVALID_INPUT", &e),
2310        }
2311    }
2312
2313    #[tool(
2314        name = "memstead_schema",
2315        description = "Read one schema. Default `verbosity` is \"lite\": a structural skeleton — entity-type names with their section keys (`required` flags kept) and metadata-field shapes (`enum` values + `default`), `required_outgoing`, relationship names with endpoint constraints, plus `relationship_mode` (strict|open), `community.{resolution, seed}`, `used_by[]`, top-level `origin` (`first-party` for an engine built-in or workspace-authored schema; `third-party` otherwise), and top-level `alias_target_rel_type` (names the rel-type body wiki-links auto-emit; absent means unbacked wiki-links refuse with `WIKILINK_WITHOUT_RELATION`). The skeleton carries every legality flag needed to author a valid write. Pass `verbosity: \"full\"` for the prose layer — per-section `write_rules`, type-level `writing_guidance`, `system_context`, relationship `description`/`when_to_use`/`default_weight`, top-level `default_writing_guidance` and schema-level `system_context` — fetch it before substantial authoring against an unfamiliar schema. Full and lite ship the heavy arrays under distinct keys (`types`/`relationships` vs `types_summary`/`relationships_summary`) — decode by key presence. A `third-party` schema is served structural-only regardless of `verbosity` — its prose-instruction fields are omitted so a stranger's free-text never reaches the agent as instructions. Pass exactly one of: `name` — bare (\"default\") or canonical pin (\"default@1.0.0\"); `mem` — a mem name whose pinned `mem.schema_ref` the engine resolves. Both or neither returns `INVALID_INPUT`. Workflow: call once per writable mem per session before create/update/relate (schema-discovery contract — see server instructions); cache — schema is workspace-stable. Conformance errors carry recovery payloads as a fallback; fix from `details` rather than re-fetching. Returns `ENTITY_NOT_FOUND` for an unknown `name` (`details.id` echoes it; `details.suggestions` stays empty for schemas) or `UNKNOWN_MEM` for an unmounted `mem` (`details.known_mems` lists the writable roster).",
2316        annotations(
2317            read_only_hint = true,
2318            destructive_hint = false,
2319            idempotent_hint = true,
2320            open_world_hint = false
2321        )
2322    )]
2323    fn memstead_schema(&self, Parameters(p): Parameters<SchemaParams>) -> CallToolResult {
2324        // The unified engine exposes `schemas()` as a HashMap keyed by
2325        // mem name (one schema per mem per V1). suggest_name is
2326        // not available on the unified surface (the per-mem HashMap
2327        // has no fuzzy index); not-found errors carry an empty
2328        // suggestions list — wire shape stays consistent.
2329        let unified = self.unified_engine();
2330        let mut engine = crate::lock_engine!(unified);
2331        let drift_warnings = engine.reload_if_stale(None);
2332        let mem_changed_notices = engine.take_mem_changed_notices();
2333
2334        // Resolve the effective schema name. Accept exactly one of
2335        // `name` (canonical) or `mem` (mount-roster lookup); the
2336        // pair `(Some, Some)` and `(None, None)` are typed input
2337        // errors so an agent that misreads the API gets a precise
2338        // failure rather than silent fallback. `mem` resolves
2339        // through the same cascade as `name` once the engine maps
2340        // the mem to its pinned `schema_ref`.
2341        let effective_name: String = match (p.name.as_deref(), p.mem.as_deref()) {
2342            (Some(_), Some(_)) => {
2343                let msg =
2344                    "memstead_schema accepts exactly one of `name` or `mem`, not both.".to_string();
2345                return attach_drift_to_error(
2346                    tool_error_with_payload(
2347                        "INVALID_INPUT",
2348                        &msg,
2349                        envelope(
2350                            "INVALID_INPUT",
2351                            msg.clone(),
2352                            serde_json::json!({ "message": msg }),
2353                        ),
2354                    ),
2355                    &drift_warnings,
2356                    mem_changed_notices,
2357                );
2358            }
2359            (None, None) => {
2360                let msg = "memstead_schema requires either `name` or `mem`.".to_string();
2361                return attach_drift_to_error(
2362                    tool_error_with_payload(
2363                        "INVALID_INPUT",
2364                        &msg,
2365                        envelope(
2366                            "INVALID_INPUT",
2367                            msg.clone(),
2368                            serde_json::json!({ "message": msg }),
2369                        ),
2370                    ),
2371                    &drift_warnings,
2372                    mem_changed_notices,
2373                );
2374            }
2375            (Some(name), None) => name.to_string(),
2376            (None, Some(mem)) => match engine.mount(mem) {
2377                Some(m) => m.schema.as_ref().map(|s| s.to_string()).unwrap_or_default(),
2378                None if engine.quarantine_reason(mem).is_some() => {
2379                    let err = engine.unknown_mem_error(mem);
2380                    return attach_drift_to_error(
2381                        engine_err_unified(err, &engine),
2382                        &drift_warnings,
2383                        mem_changed_notices,
2384                    );
2385                }
2386                None => {
2387                    let known_mems: Vec<String> =
2388                        engine.mounts().iter().map(|m| m.mem.clone()).collect();
2389                    let msg = format!("unknown mem: \"{mem}\"");
2390                    return attach_drift_to_error(
2391                        tool_error_with_payload(
2392                            "UNKNOWN_MEM",
2393                            &msg,
2394                            envelope(
2395                                "UNKNOWN_MEM",
2396                                msg.clone(),
2397                                serde_json::json!({
2398                                    "name": mem,
2399                                    "known_mems": known_mems,
2400                                }),
2401                            ),
2402                        ),
2403                        &drift_warnings,
2404                        mem_changed_notices,
2405                    );
2406                }
2407            },
2408        };
2409
2410        // Lookup: name@version path uses parsed pin; bare-name path
2411        // picks the first matching schema by name. Cascade covers
2412        // mem-pinned, workspace-loaded, and embedded built-in
2413        // catalogues so any pin `memstead_mem_create` would accept also
2414        // resolves through `memstead_schema` — agents reading
2415        // `memstead_overview`'s lifecycle namespaces can introspect their
2416        // schemas without first creating a mem.
2417        let schema_arc: Option<std::sync::Arc<memstead_schema::Schema>> =
2418            if effective_name.contains('@') {
2419                match effective_name.parse::<memstead_schema::SchemaRef>() {
2420                    Ok(parsed) => find_schema_unified(&engine, &parsed).cloned(),
2421                    Err(_) => None,
2422                }
2423            } else {
2424                find_schema_by_name(&engine, &effective_name).cloned()
2425            };
2426        let schema = match schema_arc {
2427            Some(s) => s,
2428            None => {
2429                let msg = format!("schema not found: \"{effective_name}\"");
2430                return attach_drift_to_error(
2431                    tool_error_with_payload(
2432                        "ENTITY_NOT_FOUND",
2433                        &msg,
2434                        envelope(
2435                            "ENTITY_NOT_FOUND",
2436                            msg.clone(),
2437                            serde_json::json!({
2438                                "id": effective_name,
2439                                "suggestions": Vec::<String>::new(),
2440                            }),
2441                        ),
2442                    ),
2443                    &drift_warnings,
2444                    mem_changed_notices,
2445                );
2446            }
2447        };
2448
2449        // `used_by` — every writable mem whose pinned schema
2450        // resolves to this one. Iterate mounts(), compare each
2451        // mount.schema with the matched schema's canonical pin.
2452        let canon = format!("{}@{}", schema.manifest.name, schema.version);
2453        let mut used_by: Vec<String> = engine
2454            .mounts()
2455            .iter()
2456            .filter(|m| m.schema.as_ref().map(|s| s.to_string()).as_deref() == Some(canon.as_str()))
2457            .map(|m| m.mem.clone())
2458            .collect();
2459        used_by.sort();
2460
2461        // Resolve the optional `verbosity` toggle. Absent → lite: a fresh
2462        // session following the schema-discovery contract pays the
2463        // skeleton price (~7 KB), not the full-prose price (~52 KB); the
2464        // full body stays one explicit `verbosity: "full"` away. An
2465        // unrecognized value is a typed `INVALID_INPUT` naming the bad
2466        // value rather than a silent fallback to full/lite — the same
2467        // anti-silent-no-op principle the write-path plans enforce.
2468        let verbosity = match p.verbosity.as_deref() {
2469            None => render::SchemaVerbosity::Lite,
2470            Some(v) => match render::SchemaVerbosity::from_wire(v) {
2471                Some(sv) => sv,
2472                None => {
2473                    let msg = format!("unknown verbosity: \"{v}\" — expected \"full\" or \"lite\"");
2474                    return attach_drift_to_error(
2475                        tool_error_with_payload(
2476                            "INVALID_INPUT",
2477                            &msg,
2478                            envelope(
2479                                "INVALID_INPUT",
2480                                msg.clone(),
2481                                serde_json::json!({
2482                                    "value": v,
2483                                    "allowed": ["full", "lite"],
2484                                }),
2485                            ),
2486                        ),
2487                        &drift_warnings,
2488                        mem_changed_notices,
2489                    );
2490                }
2491            },
2492        };
2493        // Trust origin governs de-framing: a third-party schema is served
2494        // structural-only regardless of the requested `verbosity` (the
2495        // prose-instruction fields never reach the agent as instructions).
2496        let origin = engine.schema_origin(&schema);
2497        let payload = render::build_schema_payload(&schema, used_by, verbosity, origin);
2498        let mut res = json_response(&payload);
2499        for w in &drift_warnings {
2500            res = append_warning_hint(res, w);
2501        }
2502        attach_mem_changed_to_result(res, mem_changed_notices)
2503    }
2504
2505    // ----------------------------------------------------------------------
2506    // Write tools
2507    // ----------------------------------------------------------------------
2508
2509    #[tool(
2510        name = "memstead_create",
2511        description = "Create a new entity. Read the target mem's schema first via `memstead_schema` (schema-discovery contract — see server instructions). Required: `title`, `entity_type`, plus the type's required sections. The id joins the mem name with a Unicode-aware slug of the title. Titles accept any single-line text (control characters such as tab/newline are rejected); the title is stored verbatim as display text, while characters outside Unicode alphanumerics, whitespace, and hyphen are dropped from the derived slug — warning TITLE_CHARS_DROPPED_FROM_SLUG names them (`INVALID_TITLE` remains for control chars, empty-deriving titles, over-long ids). `mem` defaults to the primary writable mem. Pass `relations` to wire edges inline — an entry is literally `{to, type, description?}` — no source field; unresolved targets auto-stub. Optional `note` (see server instructions). Schema-bound failures (`UNKNOWN_SECTION`, `UNKNOWN_METADATA_FIELD`, `INVALID_ENUM_VALUE`, `REQUIRED_FIELD_UNSET`, `INVALID_REL_TYPE`) carry recovery payloads (see server instructions). Create-specific: `REQUIRED_FIELD_UNSET` also fires on an omitted required-no-default metadata field (supersedes warning `MISSING_REQUIRED_FIELD`); `MISSING_REQUIRED_SECTION` refuses on create — an entity never lands with a placeholder body — shipping per-section `write_rules` plus the top-level `type_guidance` map. Warnings (entity still lands): `UNDECLARED_RELATIONSHIP_OPEN`; `INLINE_WIKI_LINK_AUTO_STUBBED` (body `[[wiki-links]]` auto-stub unresolved targets — review `details.stubs` for prose-induced ghosts); `MISSING_REQUIRED_OUTGOING` (`details.missing[]={relationships, cardinality}`; follow up with memstead_relate). Real writes return `commit_sha` for memstead_changes_since polling (see server instructions). `dry_run: true` previews a VALID entity (prospective `id`, `file_path`, `_hash`, warnings, `type_guidance`, `incoming` edges adopted from a pre-existing stub; empty `commit_sha`) — an INVALID entity refuses with the same typed envelope a real call returns.",
2512        annotations(
2513            read_only_hint = false,
2514            destructive_hint = false,
2515            idempotent_hint = false,
2516            open_world_hint = false
2517        )
2518    )]
2519    fn memstead_create(&self, Parameters(p): Parameters<CreateParams>) -> CallToolResult {
2520        if let Some(err) = validate_note(p.note.as_deref()) {
2521            return err;
2522        }
2523        let mem = self.resolve_mem(p.mem.as_deref());
2524        let dry_run = p.dry_run.unwrap_or(false);
2525        let role = match self.resolve_role(p.role.as_deref()) {
2526            Ok(r) => r,
2527            Err(resp) => return *resp,
2528        };
2529
2530        // Wire JSON matches the `CreateResult` contract.
2531        let unified = self.unified_engine();
2532        let mut engine = crate::lock_engine!(unified);
2533        engine.set_role(role);
2534        let relations: Vec<memstead_base::ops::RelateArg> = p
2535            .relations
2536            .clone()
2537            .unwrap_or_default()
2538            .into_iter()
2539            .map(|r| memstead_base::ops::RelateArg {
2540                to: EntityId(r.to),
2541                rel_type: r.r#type,
2542                description: r.description,
2543            })
2544            .collect();
2545        let anchors: Vec<memstead_base::anchor::AnchorInput> = p
2546            .anchors
2547            .unwrap_or_default()
2548            .into_iter()
2549            .map(|a| a.into_engine())
2550            .collect();
2551        let args = memstead_base::CreateEntityArgs {
2552            mem: mem.clone(),
2553            title: p.title.clone(),
2554            entity_type: p.entity_type.clone(),
2555            sections: p.sections.unwrap_or_default(),
2556            metadata: p.metadata.unwrap_or_default(),
2557            relations,
2558            anchors,
2559            dry_run,
2560        };
2561        let client = self.client.get().cloned();
2562        match engine.create_entity(args, Actor::Agent, client.as_ref(), p.note.as_deref()) {
2563            Ok(outcome) => {
2564                // Skip empty `incoming` / `None` `incoming_count`
2565                // manually to match full's
2566                // `#[serde(skip_serializing_if=...)]`.
2567                let mut body = serde_json::json!({
2568                    "id": outcome.id.to_string(),
2569                    "title": outcome.title,
2570                    "mem": outcome.mem,
2571                    "file_path": outcome.file_path,
2572                    "created_date": outcome.created_date,
2573                    "_hash": outcome.content_hash,
2574                    "commit_sha": outcome.commit_sha,
2575                    "warnings": outcome.warnings,
2576                    "type_guidance": outcome.type_guidance,
2577                });
2578                if let Some(count) = outcome.incoming_count {
2579                    body["incoming_count"] = serde_json::json!(count);
2580                }
2581                if !outcome.incoming.is_empty() {
2582                    body["incoming"] =
2583                        serde_json::to_value(&outcome.incoming).unwrap_or(serde_json::Value::Null);
2584                }
2585                if !outcome.relations_declared.is_empty() {
2586                    body["relations_declared"] = serde_json::to_value(&outcome.relations_declared)
2587                        .unwrap_or(serde_json::Value::Null);
2588                }
2589                attach_durability(&mut body, &engine, &outcome.mem);
2590                attach_mem_changed(&mut body, engine.take_mem_changed_notices());
2591                let res = json_response(&body);
2592                match mem_schema_ref_unified(&engine, &mem) {
2593                    Some(s) => with_mem_schema_anchor(res, &s),
2594                    None => res,
2595                }
2596            }
2597            Err(e) => {
2598                // The notice already rode `structured_content` here;
2599                // the text channel lacked the `MEM_RELOADED` line a
2600                // successful response carries. A mutation reloads inside
2601                // the engine, so reconstruct the warning from the
2602                // drained notices to match the success channel split —
2603                // collision (`HASH_MISMATCH`) is the path drift matters
2604                // most, since it lands on the very entity being written.
2605                let notices = engine.take_mem_changed_notices();
2606                let warnings = notices_as_reload_warnings(&notices);
2607                attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
2608            }
2609        }
2610    }
2611
2612    #[tool(
2613        name = "memstead_update",
2614        description = "Modify an existing entity. Pre-fetch the target mem's schema via `memstead_schema` (see server instructions). Read the entity first and pass its `_hash` as `expected_hash` — mismatch emits `HASH_MISMATCH` (`details.current` carries the live hash). Warnings `INLINE_WIKI_LINK_AUTO_STUBBED` (`details.stubs`) and `MISSING_REQUIRED_OUTGOING` mirror memstead_create's (clear via memstead_relate). Section modes (one per key): `sections` (replace), `append_sections`, `patch_sections` (find-and-replace; every occurrence via `all: true`; errors on missing `old` or empty section). Schema-bound errors (`UNKNOWN_SECTION`, `UNKNOWN_METADATA_FIELD`, `INVALID_ENUM_VALUE`, `REQUIRED_FIELD_UNSET`) carry recovery payloads — fix from `details` (see server instructions). `metadata` sets frontmatter; `metadata_unset` removes it (silently no-ops on absent or section keys). Setting and unsetting the same key is a hard error. Read-only on SET (`READ_ONLY_FIELD`): `mem`/`id`/`type` (memstead_rename for title) and engine-stamped `created_date`/`last_modified` (also unset-refused). Unset MAY name the reserved triple — sanctioned repair for a smuggled key; no-op on healthy entities (`type` re-seeds). Stubs cannot be updated — memstead_create as real first. Optional `note` (≤280 chars) — see server instructions. No-op short-circuit: post-state bytes-identical to disk (e.g. same-day auto-stamp, absent-key unset) returns `UPDATE_NOOP`, empty `commit_sha`, unchanged `_hash` — `expected_hash` stays stable. `dry_run: true` validates then previews OR recovers from a stale hash: it bypasses ONLY the `expected_hash` check (returns current `_hash` + `prospective_hash`), but section/field validation still refuses with the same typed envelope a real call returns — dry_run never reports an invalid update as clean. Reuse `_hash` as `expected_hash`, never `prospective_hash` (the auto-stamp shifts it). A body-link removal orphaning its stub target GC's it into `orphan_stubs_removed`. Real writes carry `commit_sha` (see server instructions).",
2615        annotations(
2616            read_only_hint = false,
2617            destructive_hint = false,
2618            idempotent_hint = false,
2619            open_world_hint = false
2620        )
2621    )]
2622    fn memstead_update(&self, Parameters(p): Parameters<UpdateParams>) -> CallToolResult {
2623        if let Some(err) = validate_entity_id(&p.id) {
2624            return err;
2625        }
2626        if let Some(err) = validate_note(p.note.as_deref()) {
2627            return err;
2628        }
2629        let id = EntityId::canonical(&p.id);
2630        let mem_for_anchor = id.mem().to_string();
2631        let dry_run = p.dry_run.unwrap_or(false);
2632        let role = match self.resolve_role(p.role.as_deref()) {
2633            Ok(r) => r,
2634            Err(resp) => return *resp,
2635        };
2636
2637        // Wire JSON matches the `UpdateResult` contract.
2638        let unified = self.unified_engine();
2639        let mut engine = crate::lock_engine!(unified);
2640        engine.set_role(role);
2641        let patch_sections: indexmap::IndexMap<String, memstead_base::ops::PatchArg> = p
2642            .patch_sections
2643            .clone()
2644            .unwrap_or_default()
2645            .into_iter()
2646            .map(|(k, v)| {
2647                (
2648                    k,
2649                    memstead_base::ops::PatchArg {
2650                        old: v.old,
2651                        new: v.new,
2652                        all: v.all.unwrap_or(false),
2653                    },
2654                )
2655            })
2656            .collect();
2657        let declare_relations: Vec<memstead_base::ops::RelateArg> = p
2658            .declare_relations
2659            .clone()
2660            .unwrap_or_default()
2661            .into_iter()
2662            .map(|r| memstead_base::ops::RelateArg {
2663                to: EntityId(r.to),
2664                rel_type: r.r#type,
2665                description: r.description,
2666            })
2667            .collect();
2668        let anchors: Vec<memstead_base::anchor::AnchorInput> = p
2669            .anchors
2670            .unwrap_or_default()
2671            .into_iter()
2672            .map(|a| a.into_engine())
2673            .collect();
2674        let anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput> = p
2675            .anchors_unset
2676            .unwrap_or_default()
2677            .into_iter()
2678            .map(|u| u.into_engine())
2679            .collect();
2680        let args = memstead_base::UpdateEntityArgs {
2681            anchors,
2682            anchors_unset,
2683            id: id.clone(),
2684            expected_hash: Some(p.expected_hash.clone()),
2685            sections: p.sections.unwrap_or_default(),
2686            append_sections: p.append_sections.unwrap_or_default(),
2687            patch_sections,
2688            metadata: p.metadata.unwrap_or_default(),
2689            metadata_unset: p.metadata_unset.unwrap_or_default(),
2690            dry_run,
2691            declare_relations,
2692            relations_unset: p
2693                .relations_unset
2694                .unwrap_or_default()
2695                .into_iter()
2696                .map(|r| memstead_base::ops::RelationUnsetArg {
2697                    rel_type: r.rel_type,
2698                    target: EntityId(r.target),
2699                })
2700                .collect(),
2701        };
2702        let client = self.client.get().cloned();
2703        match engine.update_entity(args, Actor::Agent, client.as_ref(), p.note.as_deref()) {
2704            Ok(outcome) => {
2705                // ModifiedSections / ModifiedMetadata serialise
2706                // with `#[serde(skip_serializing_if = "Vec::is_empty")]`
2707                // on each inner vec — matching full's UpdateResult
2708                // wire shape.
2709                let mut body = serde_json::json!({
2710                    "id": outcome.id.to_string(),
2711                    "title": outcome.title,
2712                    "modified_sections": outcome.modified_sections,
2713                    "modified_metadata": outcome.modified_metadata,
2714                    "modified_date": outcome.modified_date,
2715                    "_hash": outcome.content_hash,
2716                    "commit_sha": outcome.commit_sha,
2717                    "warnings": outcome.warnings,
2718                    // Orphan-stub GC: removing a body wiki-link that was
2719                    // a stub target's last referrer GC's the stub and
2720                    // lists it here. Always present (empty array when
2721                    // nothing orphaned), matching the relate-remove
2722                    // always-emit shape so agents branch uniformly on
2723                    // the field rather than its presence.
2724                    "orphan_stubs_removed": outcome
2725                        .orphan_stubs_removed
2726                        .iter()
2727                        .map(|i| i.to_string())
2728                        .collect::<Vec<_>>(),
2729                });
2730                // Add `prospective_hash` only on the dry_run path
2731                // (matches full's `#[serde(skip_serializing_if = "Option::is_none")]`).
2732                if let Some(hash) = outcome.prospective_hash {
2733                    body["prospective_hash"] = serde_json::json!(hash);
2734                }
2735                // Surface `relations_declared` when the agent used
2736                // `declare_relations`. Always-present-when-non-empty
2737                // wire shape so consumers branch on `.len()` rather
2738                // than on key presence; `serde(skip_serializing_if =
2739                // "Vec::is_empty")` on the outcome keeps the
2740                // no-batch case bytes-identical to pre-feature.
2741                if !outcome.relations_declared.is_empty() {
2742                    body["relations_declared"] = serde_json::to_value(&outcome.relations_declared)
2743                        .unwrap_or(serde_json::Value::Null);
2744                }
2745                attach_durability(&mut body, &engine, outcome.id.mem());
2746                attach_mem_changed(&mut body, engine.take_mem_changed_notices());
2747                let res = json_response(&body);
2748                match mem_schema_ref_unified(&engine, &mem_for_anchor) {
2749                    Some(s) => with_mem_schema_anchor(res, &s),
2750                    None => res,
2751                }
2752            }
2753            Err(e) => {
2754                // The notice already rode `structured_content` here;
2755                // the text channel lacked the `MEM_RELOADED` line a
2756                // successful response carries. A mutation reloads inside
2757                // the engine, so reconstruct the warning from the
2758                // drained notices to match the success channel split —
2759                // collision (`HASH_MISMATCH`) is the path drift matters
2760                // most, since it lands on the very entity being written.
2761                let notices = engine.take_mem_changed_notices();
2762                let warnings = notices_as_reload_warnings(&notices);
2763                attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
2764            }
2765        }
2766    }
2767
2768    #[tool(
2769        name = "memstead_relate",
2770        description = "Connect entities with typed edges — a list of relation operations applied atomically. `relations` carries one or more `{from, to, type, remove?, description?}` entries; the whole list is all-or-nothing in ONE commit per touched mem, per-entry validation identical to a single operation, in-order semantics (later entries validate against the state earlier entries produced; an acyclic check sees edges added earlier in the list). A single-relation call is a list of one. Pre-fetch the mem's schema via `memstead_schema` (see server instructions). Type names case-insensitive; stored UPPER_SNAKE_CASE. One failing entry refuses the WHOLE list — nothing commits, every failing entry reported: a list of one surfaces its entry's own typed code top-level (`INVALID_REL_TYPE` with `details.allowed` + `suggestion`, `INVALID_REL_SHAPE`, `CROSS_MEM_LINK_NOT_ALLOWED`, `CROSS_MEM_TARGET_NOT_FOUND`, `RELATIONSHIP_CYCLE` with `details.existing_path`, `INVALID_ENTITY_ID`); larger lists wrap under `BATCH_REFUSED` with `details.entries[]` of `{index, from, to, rel_type, code, message, details}` (`errors_suppressed` counts envelopes past the cap). Remove skips shape validation. Per entry: `remove: true` deletes; `from` must be real; `to` may auto-stub (`AUTO_STUB_CREATED`; into an uncreated mem: `CROSS_MEM_TARGET_MEM_UNCREATED`). Add-existing / remove-missing are typed-warning no-ops (`DUPLICATE_RELATIONSHIP` / `NO_SUCH_RELATIONSHIP`, `action: \"noop\"`). Response: `results[]` in submission order, each `{from, to, rel_type, action, source, _hash}` — `_hash` is that source's next `expected_hash`; top-level `commit_sha` (empty when all no-op), `warnings`, `orphan_stubs_removed` (stubs GC'd when a removed edge was their last referrer; surviving body wiki-links refuse `RELATION_HAS_BODY_LINKS`). Optional `note` rides every entry. `dry_run: true` rehearses the list: same validation and refusals, would-be actions and stubs reported, nothing lands; `commit_sha` stays empty (the rehearsal marker). Edges never move files.",
2771        annotations(
2772            read_only_hint = false,
2773            destructive_hint = false,
2774            idempotent_hint = true,
2775            open_world_hint = false
2776        )
2777    )]
2778    fn memstead_relate(&self, Parameters(p): Parameters<RelateParams>) -> CallToolResult {
2779        if let Some(err) = validate_note(p.note.as_deref()) {
2780            return err;
2781        }
2782        let role = match self.resolve_role(p.role.as_deref()) {
2783            Ok(r) => r,
2784            Err(resp) => return *resp,
2785        };
2786        if p.relations.is_empty() {
2787            let msg = "relations must carry at least one operation";
2788            return tool_error_with_payload(
2789                "INVALID_INPUT",
2790                msg,
2791                envelope(
2792                    "INVALID_INPUT",
2793                    msg.to_string(),
2794                    serde_json::json!({ "message": msg }),
2795                ),
2796            );
2797        }
2798
2799        // The whole list is one engine batch: all-or-nothing in one
2800        // commit per touched mem, in-order validation, report-all
2801        // refusals. A list of one routes through the same path and
2802        // carries the same per-entry body today's single call did.
2803        let ops: Vec<(memstead_base::RelateEntityArgs, Option<String>)> = p
2804            .relations
2805            .iter()
2806            .map(|op| {
2807                (
2808                    memstead_base::RelateEntityArgs {
2809                        source: EntityId::canonical(&op.from),
2810                        target: EntityId::canonical(&op.to),
2811                        rel_type: op.r#type.clone(),
2812                        remove: op.remove.unwrap_or(false),
2813                        expected_hash: None,
2814                        description: op.description.clone(),
2815                        dry_run: p.dry_run.unwrap_or(false),
2816                    },
2817                    p.note.clone(),
2818                )
2819            })
2820            .collect();
2821        let mem_for_anchor = ops[0].0.source.mem().to_string();
2822
2823        let unified = self.unified_engine();
2824        let mut engine = crate::lock_engine!(unified);
2825        let client = self.client.get().cloned();
2826        engine.set_role(role);
2827
2828        // A list of one routes through the single-op engine path so it
2829        // behaves byte-identically to the historical single call —
2830        // full error enrichment (recovery payloads, message text),
2831        // full warning parity — wrapped in the same plural envelope
2832        // larger lists produce.
2833        if p.relations.len() == 1 {
2834            let (args, note) = {
2835                let mut it = ops.into_iter();
2836                it.next().expect("len checked above")
2837            };
2838            return match engine.relate_entity(args, Actor::Agent, client.as_ref(), note.as_deref())
2839            {
2840                Ok(outcome) => {
2841                    let action = match outcome.action {
2842                        memstead_base::RelateAction::Added => "added",
2843                        memstead_base::RelateAction::Removed => "removed",
2844                        memstead_base::RelateAction::NoOpAlreadyPresent
2845                        | memstead_base::RelateAction::NoOpAbsent => "noop",
2846                    };
2847                    let mut body = serde_json::json!({
2848                        "results": [{
2849                            "from": outcome.from.to_string(),
2850                            "to": outcome.to.to_string(),
2851                            "rel_type": outcome.rel_type,
2852                            "action": action,
2853                            "source": outcome.source,
2854                            "_hash": outcome.content_hash,
2855                        }],
2856                        "commit_sha": outcome.commit_sha,
2857                        "warnings": outcome.warnings,
2858                        "orphan_stubs_removed": outcome
2859                            .orphan_stubs_removed
2860                            .iter()
2861                            .map(|i| i.to_string())
2862                            .collect::<Vec<_>>(),
2863                    });
2864                    attach_durability(&mut body, &engine, outcome.from.mem());
2865                    attach_mem_changed(&mut body, engine.take_mem_changed_notices());
2866                    let res = json_response(&body);
2867                    match mem_schema_ref_unified(&engine, &mem_for_anchor) {
2868                        Some(sr) => with_mem_schema_anchor(res, &sr),
2869                        None => res,
2870                    }
2871                }
2872                Err(e) => {
2873                    let notices = engine.take_mem_changed_notices();
2874                    let warnings = notices_as_reload_warnings(&notices);
2875                    attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
2876                }
2877            };
2878        }
2879
2880        // Snapshot which targets are absent pre-call so applied
2881        // auto-stubs can surface the same AUTO_STUB_CREATED warning
2882        // the single call emitted.
2883        let absent_targets: std::collections::HashSet<String> = p
2884            .relations
2885            .iter()
2886            .filter(|op| !op.remove.unwrap_or(false))
2887            .map(|op| EntityId::canonical(&op.to))
2888            .filter(|to| engine.store().get(to).is_none())
2889            .map(|to| to.to_string())
2890            .collect();
2891        let dry_run = p.dry_run.unwrap_or(false);
2892        let result = match engine.batch_relate(ops, Actor::Agent, client.as_ref(), dry_run) {
2893            Ok(r) => r,
2894            Err(e) => {
2895                let notices = engine.take_mem_changed_notices();
2896                let warnings = notices_as_reload_warnings(&notices);
2897                return attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices);
2898            }
2899        };
2900
2901        if !result.applied {
2902            // Report-all refusal: nothing committed; every failing
2903            // entry ships its typed envelope. A list of one surfaces
2904            // its single entry's own code top-level so error-branching
2905            // callers keep working; larger lists wrap under
2906            // BATCH_REFUSED with per-entry envelopes in
2907            // `details.entries`.
2908            let entries: Vec<serde_json::Value> = result
2909                .results
2910                .iter()
2911                .zip(p.relations.iter())
2912                .enumerate()
2913                .map(|(i, (entry, op))| {
2914                    let mut e = serde_json::json!({
2915                        "index": i,
2916                        "from": op.from,
2917                        "to": op.to,
2918                        "rel_type": op.r#type,
2919                        "action": entry.action,
2920                    });
2921                    if let Some(err) = &entry.error {
2922                        e["code"] = serde_json::json!(err.code);
2923                        e["message"] = serde_json::json!(err.message);
2924                        e["details"] = err.details.clone();
2925                    }
2926                    e
2927                })
2928                .collect();
2929            let notices = engine.take_mem_changed_notices();
2930            let drift = notices_as_reload_warnings(&notices);
2931            let msg = format!(
2932                "batch refused — {} of {} operation(s) failed, nothing committed",
2933                result.failed,
2934                p.relations.len(),
2935            );
2936            let payload = envelope(
2937                "BATCH_REFUSED",
2938                msg.clone(),
2939                serde_json::json!({
2940                    "entries": entries,
2941                    "failed": result.failed,
2942                    "errors_suppressed": result.errors_suppressed,
2943                }),
2944            );
2945            return attach_drift_to_error(
2946                tool_error_with_payload("BATCH_REFUSED", &msg, payload),
2947                &drift,
2948                notices,
2949            );
2950        }
2951
2952        // Applied: enrich every entry with the same fields today's
2953        // single response carried — canonical rel_type comes back via
2954        // the store edge, `source` labels body_link vs explicit, and
2955        // `_hash` is the source entity's post-commit hash (the next
2956        // valid expected_hash for that entity). Noop entries
2957        // additionally synthesize the typed no-op warning the single
2958        // call emitted (DUPLICATE_RELATIONSHIP / NO_SUCH_RELATIONSHIP).
2959        let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
2960        let entries: Vec<serde_json::Value> = result
2961            .results
2962            .iter()
2963            .zip(p.relations.iter())
2964            .map(|(entry, op)| {
2965                let from = EntityId::canonical(&op.from);
2966                let to = EntityId::canonical(&op.to);
2967                let canonical_type = op.r#type.to_uppercase();
2968                if entry.action == "noop" {
2969                    if op.remove.unwrap_or(false) {
2970                        warnings.push(memstead_base::ops::WarningHint::NoSuchRelationship {
2971                            rel_type: canonical_type.clone(),
2972                            from: from.clone(),
2973                            to: to.clone(),
2974                        });
2975                    } else {
2976                        warnings.push(memstead_base::ops::WarningHint::DuplicateRelationship {
2977                            rel_type: canonical_type.clone(),
2978                            from: from.clone(),
2979                            to: to.clone(),
2980                        });
2981                    }
2982                }
2983                // Real batch: the stub exists post-commit. Rehearsal:
2984                // the staged state rolled back, so a still-absent
2985                // target of a validated add entry IS the would-be stub
2986                // — same warning, reported instead of created.
2987                let stubbed = engine.store().get(&to).map(|e| e.stub).unwrap_or(false);
2988                let would_stub =
2989                    dry_run && entry.action == "added" && engine.store().get(&to).is_none();
2990                if !op.remove.unwrap_or(false)
2991                    && absent_targets.contains(&to.to_string())
2992                    && (stubbed || would_stub)
2993                {
2994                    // `pending` only on the rehearsal branch — the
2995                    // rolled-back stub was never written, so the
2996                    // warning must not claim a performed effect.
2997                    warnings.push(memstead_base::ops::WarningHint::AutoStubCreated {
2998                        stub_id: to.clone(),
2999                        pending: would_stub,
3000                    });
3001                }
3002                let source_label = engine
3003                    .store()
3004                    .outgoing(&from)
3005                    .iter()
3006                    .find(|e| e.target == to && e.rel_type.eq_ignore_ascii_case(&op.r#type))
3007                    .map(|e| match e.source {
3008                        memstead_base::EdgeSource::BodyLink => "body_link",
3009                        memstead_base::EdgeSource::Hierarchy => "hierarchy",
3010                        memstead_base::EdgeSource::Explicit => "explicit",
3011                    })
3012                    .unwrap_or("explicit");
3013                // Real batch: post-commit hash (the next valid
3014                // `expected_hash`). Rehearsal: the rolled-back store
3015                // serves the CURRENT on-disk hash — still the value a
3016                // follow-up real call validates against.
3017                let hash = engine
3018                    .store()
3019                    .get(&from)
3020                    .map(|e| e.content_hash.clone())
3021                    .unwrap_or_default();
3022                serde_json::json!({
3023                    "from": from.to_string(),
3024                    "to": to.to_string(),
3025                    "rel_type": canonical_type,
3026                    "action": entry.action,
3027                    "source": source_label,
3028                    "_hash": hash,
3029                })
3030            })
3031            .collect();
3032
3033        let mut body = serde_json::json!({
3034            "results": entries,
3035            "commit_sha": result.commit_sha,
3036            "warnings": warnings,
3037            "orphan_stubs_removed": result
3038                .orphan_stubs_removed
3039                .iter()
3040                .map(|i| i.to_string())
3041                .collect::<Vec<_>>(),
3042        });
3043        attach_durability(&mut body, &engine, mem_for_anchor.as_str());
3044        attach_mem_changed(&mut body, engine.take_mem_changed_notices());
3045        let res = json_response(&body);
3046        match mem_schema_ref_unified(&engine, &mem_for_anchor) {
3047            Some(s) => with_mem_schema_anchor(res, &s),
3048            None => res,
3049        }
3050    }
3051
3052    #[tool(
3053        name = "memstead_delete",
3054        description = "Remove an entity permanently. Deletes the entity's store record, every edge touching it (both directions), and its markdown file on disk. Requires `expected_hash` (read the entity via memstead_entity first — mirrors memstead_update / memstead_rename for optimistic locking); mismatch emits `HASH_MISMATCH` with `details.current` carrying the current on-disk hash. Binary semantics: any incoming reference from another entity in a Write-Mem refuses the delete with `HAS_INCOMING_REFS` and `details.referrers` listing each `{from_id, rel_types, mem}` (one entry per unique source, rel_types collapses multi-edge cases) — the agent removes the offending references via `memstead_relate --remove` (or `memstead_update` for body wiki-links) before retrying. There is no force flag. When the only incoming references come from ReadOnly mounts (archives), the delete proceeds: the on-disk file is removed and the in-memory entity is demoted to a stub at the same id so the surviving edges keep a valid target — the response carries a `RESIDUAL_STUB_FOR_READONLY_REFERRERS` warning naming the surviving referrers. PART_OF children survive the delete: their parent edge is removed; file paths are unaffected (every entity already lives at `{mem}/{slug}.md`). Stubs (`_hash` empty) are deleted with `expected_hash: \"\"` — the hash check is skipped because there is nothing to compare. Optional `note` (≤280 chars) — shared provenance contract, see memstead_create. Response carries `relations_removed` (edges removed by this delete), `orphan_stubs_removed` (ids of stub entities whose last incoming edge was this entity — they are GC'd in the same op so the graph stays tidy; field is serde-omitted when empty), `warnings` (residual-stub warning when the demote path applied), and `commit_sha` (per-mem git; gitdir via `memstead_health include_config=true`) for polling via memstead_changes_since. Provenance anchors, if any, are removed in the same commit (no orphaned anchor survives).",
3055        annotations(
3056            read_only_hint = false,
3057            destructive_hint = true,
3058            idempotent_hint = false,
3059            open_world_hint = false
3060        )
3061    )]
3062    fn memstead_delete(&self, Parameters(p): Parameters<DeleteParams>) -> CallToolResult {
3063        if let Some(err) = validate_entity_id(&p.id) {
3064            return err;
3065        }
3066        if let Some(err) = validate_note(p.note.as_deref()) {
3067            return err;
3068        }
3069        let id = EntityId::canonical(&p.id);
3070        // Empty `expected_hash` is the stub-delete escape hatch —
3071        // stubs have an empty content_hash so the hash check is
3072        // skipped. Convert empty string to None at this boundary so
3073        // engine semantics stay opaque to the request shape.
3074        let expected_hash_opt = if p.expected_hash.is_empty() {
3075            None
3076        } else {
3077            Some(p.expected_hash.clone())
3078        };
3079
3080        let role = match self.resolve_role(p.role.as_deref()) {
3081            Ok(r) => r,
3082            Err(resp) => return *resp,
3083        };
3084        let unified = self.unified_engine();
3085        let mut engine = crate::lock_engine!(unified);
3086        engine.set_role(role);
3087        // Capture the schema-ref BEFORE the delete — mem may
3088        // drop with the last entity.
3089        let mem_for_anchor = mem_schema_ref_unified(&engine, id.mem());
3090        let args = memstead_base::DeleteEntityArgs {
3091            id: id.clone(),
3092            expected_hash: expected_hash_opt,
3093        };
3094        let client = self.client.get().cloned();
3095        match engine.delete_entity(args, Actor::Agent, client.as_ref(), p.note.as_deref()) {
3096            Ok(outcome) => {
3097                let mut body = serde_json::json!({
3098                    "id": outcome.id.to_string(),
3099                    "relations_removed": outcome.relations_removed,
3100                    "commit_sha": outcome.commit_sha,
3101                });
3102                // Full skip-serialises empty `orphan_stubs_removed`;
3103                // mirror by only adding the field when populated.
3104                if !outcome.orphan_stubs_removed.is_empty() {
3105                    body["orphan_stubs_removed"] = serde_json::json!(
3106                        outcome
3107                            .orphan_stubs_removed
3108                            .iter()
3109                            .map(|i| i.to_string())
3110                            .collect::<Vec<_>>()
3111                    );
3112                }
3113                attach_durability(&mut body, &engine, id.mem());
3114                attach_mem_changed(&mut body, engine.take_mem_changed_notices());
3115                let mut res = json_response(&body);
3116                if let Some(s) = mem_for_anchor.as_deref() {
3117                    res = with_mem_schema_anchor(res, s);
3118                }
3119                // Surface every engine-emitted warning on the outcome
3120                // (residual-stub demotion, and the `NOTE_MISSING`
3121                // provenance nudge the engine now emits when
3122                // `require_notes` is set and a commit landed).
3123                for w in &outcome.warnings {
3124                    res = append_warning_hint(res, w);
3125                }
3126                res
3127            }
3128            Err(e) => {
3129                // The notice already rode `structured_content` here;
3130                // the text channel lacked the `MEM_RELOADED` line a
3131                // successful response carries. A mutation reloads inside
3132                // the engine, so reconstruct the warning from the
3133                // drained notices to match the success channel split —
3134                // collision (`HASH_MISMATCH`) is the path drift matters
3135                // most, since it lands on the very entity being written.
3136                let notices = engine.take_mem_changed_notices();
3137                let warnings = notices_as_reload_warnings(&notices);
3138                attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
3139            }
3140        }
3141    }
3142
3143    #[tool(
3144        name = "memstead_check",
3145        description = "Record a check: \"entity E checked, verdict ok | failed, via method M\" — the engine-recorded act of verification (never a mutation: entity markdown, `_hash`, and mem commits are untouched; that non-mutation is what makes check-staleness computable). The record carries the caller-declared `role` plus actor/client identity and the entity's `_hash` at check time, appended to the workspace's append-only check ledger — a newer check supersedes older ones for state derivation but never erases them. Derived check state (`never_checked` | `checked_ok` | `check_failed` | `check_stale` — stale means the entity changed after its last check, computed by hash comparison, never stamped) is served in `memstead_entity`'s opt-in `mutation_provenance` block and echoed in this response as `check_state`. Verdict vocabulary is closed (`ok` | `failed`) — nuance goes in `method` or in process-mem entities; an unknown verdict refuses `INVALID_VERDICT`. Refuses typed on unknown entity (`ENTITY_NOT_FOUND`), unknown/quarantined mems, read-only mems (`READ_ONLY_MOUNT`), and persistence failure (`CHECK_NOT_RECORDED` — recording is never best-effort). A check with an unspecified role records honestly but cannot confirm independence downstream.",
3146        annotations(
3147            read_only_hint = false,
3148            destructive_hint = false,
3149            idempotent_hint = false,
3150            open_world_hint = false
3151        )
3152    )]
3153    fn memstead_check(&self, Parameters(p): Parameters<CheckParams>) -> CallToolResult {
3154        if let Some(err) = validate_entity_id(&p.entity) {
3155            return err;
3156        }
3157        let id = EntityId::canonical(&p.entity);
3158        let Some(verdict) = memstead_base::check::Verdict::from_wire(&p.verdict) else {
3159            let msg = format!(
3160                "unknown verdict {:?} — the vocabulary is: {}",
3161                p.verdict,
3162                memstead_base::check::VERDICTS.join(", ")
3163            );
3164            return tool_error_with_payload(
3165                "INVALID_VERDICT",
3166                &msg,
3167                envelope(
3168                    "INVALID_VERDICT",
3169                    msg.clone(),
3170                    serde_json::json!({ "allowed": memstead_base::check::VERDICTS }),
3171                ),
3172            );
3173        };
3174        let role = match self.resolve_role(p.role.as_deref()) {
3175            Ok(r) => r,
3176            Err(resp) => return *resp,
3177        };
3178
3179        let unified = self.unified_engine();
3180        let mut engine = crate::lock_engine!(unified);
3181        let _drift = engine.reload_if_stale(Some(id.mem()));
3182        engine.set_role(role);
3183        let client = self.client.get().cloned();
3184        match engine.record_check(
3185            id.mem(),
3186            id.as_ref(),
3187            verdict,
3188            p.method.as_deref(),
3189            Actor::Agent,
3190            client.as_ref(),
3191        ) {
3192            Ok(record) => {
3193                let (state, _) = match engine.entity_check_state(id.mem(), id.as_ref()) {
3194                    Ok(pair) => pair,
3195                    Err(e) => return engine_err_unified(e, &engine),
3196                };
3197                let body = serde_json::json!({
3198                    "entity": record.entity,
3199                    "verdict": record.verdict,
3200                    "check_state": state.as_str(),
3201                    "role": record.role,
3202                    "ts": record.ts,
3203                    "method": record.method,
3204                });
3205                md_with_structured(
3206                    format!(
3207                        "Check recorded: {} — verdict {}, state {}",
3208                        record.entity,
3209                        record.verdict,
3210                        state.as_str()
3211                    ),
3212                    body,
3213                )
3214            }
3215            Err(e) => engine_err_unified(e, &engine),
3216        }
3217    }
3218
3219    #[tool(
3220        name = "memstead_rename",
3221        description = "Rename an entity by changing its title. Updates the entity id and its file path (`{new_slug}.md`). Atomic referrer rewrite: every Write-Mem entity whose `relationships` or section bodies point at the old id has its `[[old-slug]]` tokens rewritten in one per-mem commit. Cross-mem referrers are gated by `cross_mem_links` policy in the propagated edge's direction — a blocked direction aborts up-front with `RENAME_BLOCKED_BY_CROSS_MEM_POLICY` (`details.from_mem`, `details.blocked_referrers`). Per-peer commits are parent-pinned; sibling-writer drift mid-rename surfaces `RENAME_PARTIAL_FAILURE` (`details.committed_mems`, `details.failed_mem`, `details.failure_cause`) — retry after reloading. Per-mem commits share a `logical_operation_id` — correlate via `memstead_changes_since`. ReadOnly referrers can't be rewritten; the old id demotes to a stub holding their edges (warning `RESIDUAL_STUB_FOR_READONLY_REFERRERS`). Requires `expected_hash`; mismatch emits `HASH_MISMATCH` (`details.current`). Slug-noop: a new title whose slug matches the current one returns `old_id` == `new_id`, empty `commit_sha`, and warning `TITLE_NORMALIZED_TO_SLUG_NOOP`. ID collisions error — pick a different title. Titles accept any single-line text (control characters such as tab/newline are rejected); the title is stored verbatim as display text, while characters outside Unicode alphanumerics, whitespace, and hyphen are dropped from the derived slug — warning TITLE_CHARS_DROPPED_FROM_SLUG names them (`INVALID_TITLE` remains for control chars, empty-deriving titles, over-long ids). Stubs cannot be renamed (create a real entity instead). Optional `note` (≤280 chars) — shared provenance contract, see memstead_create. Response: `old_id`, `new_id`, `_hash` (the next `expected_hash`), `warnings`, and `commit_sha` (per-mem git; gitdir via `memstead_health include_config=true`). Provenance anchors move to the new id in the same commit.",
3222        annotations(
3223            read_only_hint = false,
3224            destructive_hint = false,
3225            idempotent_hint = false,
3226            open_world_hint = false
3227        )
3228    )]
3229    fn memstead_rename(&self, Parameters(p): Parameters<RenameParams>) -> CallToolResult {
3230        if let Some(err) = validate_entity_id(&p.id) {
3231            return err;
3232        }
3233        if let Some(err) = validate_note(p.note.as_deref()) {
3234            return err;
3235        }
3236        let id = EntityId::canonical(&p.id);
3237        let mem_for_anchor = id.mem().to_string();
3238        let role = match self.resolve_role(p.role.as_deref()) {
3239            Ok(r) => r,
3240            Err(resp) => return *resp,
3241        };
3242
3243        // Unified outcome exposes `old_path` / `new_path` directly
3244        // (matching full's `RenameResult` shape).
3245        let unified = self.unified_engine();
3246        let mut engine = crate::lock_engine!(unified);
3247        engine.set_role(role);
3248        let args = memstead_base::RenameEntityArgs {
3249            id: id.clone(),
3250            expected_hash: Some(p.expected_hash.clone()),
3251            new_title: p.new_title.clone(),
3252        };
3253        let client = self.client.get().cloned();
3254        match engine.rename_entity(args, Actor::Agent, client.as_ref(), p.note.as_deref()) {
3255            Ok(outcome) => {
3256                let mut body = serde_json::json!({
3257                    "old_id": outcome.old_id.to_string(),
3258                    "new_id": outcome.new_id.to_string(),
3259                    "old_path": outcome.old_path,
3260                    "new_path": outcome.new_path,
3261                    "_hash": outcome.content_hash,
3262                    "commit_sha": outcome.commit_sha,
3263                    "warnings": outcome.warnings,
3264                });
3265                attach_durability(&mut body, &engine, id.mem());
3266                attach_mem_changed(&mut body, engine.take_mem_changed_notices());
3267                let res = json_response(&body);
3268                match mem_schema_ref_unified(&engine, &mem_for_anchor) {
3269                    Some(s) => with_mem_schema_anchor(res, &s),
3270                    None => res,
3271                }
3272            }
3273            Err(e) => {
3274                // The notice already rode `structured_content` here;
3275                // the text channel lacked the `MEM_RELOADED` line a
3276                // successful response carries. A mutation reloads inside
3277                // the engine, so reconstruct the warning from the
3278                // drained notices to match the success channel split —
3279                // collision (`HASH_MISMATCH`) is the path drift matters
3280                // most, since it lands on the very entity being written.
3281                let notices = engine.take_mem_changed_notices();
3282                let warnings = notices_as_reload_warnings(&notices);
3283                attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
3284            }
3285        }
3286    }
3287
3288    // ----------------------------------------------------------------------
3289    // Admin tools
3290    // ----------------------------------------------------------------------
3291
3292    #[tool(
3293        name = "memstead_health",
3294        description = "Return graph health metrics. Typed payload on `structured_content`; text chunkable past `token_budget` via `chunk`. Default: summary counts (`orphans_by_schema`/`communities_by_schema`), node/edge totals, distributions, `writable_mems`/`read_mems`, `default_writable_mem` (omitted-`mem` target), `mem_schemas`. `include` drills in — keys: `orphans`, `stubs`, `most_connected`, `missing_fields`, `stale`, `dangling_links`, `tags`, `missing_required_outgoing`, `constraints`, `conformance`, `integrity`, `config`, `anchors`, `friction` (refusal-ledger counts), `open_questions` (per-mem worklist of unknowns — stubs, open anchors, unsatisfied constraints, dangling links, process-mem entries; negative findings separated as already-searched; capped with explicit `more`), `stale_derivations` (derivation edges whose target changed since baseline), `checks` (check-state counts + gate: `unconfirmable` until caller identity; `self_checked`/`confirmed_independent` empty). `missing_fields` adds `issues[]` with `code` (`MISSING` / `SECTION_HEADING_MISMATCH`) beside `missing`. `config` = the `include_config` projection. `conformance` lints entities against `target_schema` or each pin into `findings` `{id, axis, code, detail}`; `integrity` adds `DANGLING_LINK`/`ORPHAN_STUB`. `dangling_links` lists body refs lacking files. `tags` aggregates into `tag_distribution` (`limit`-capped), `tag_distribution_folded`, `untagged_entities`. `missing_required_outgoing` lists unsatisfied blocks. `constraints` lists standing declared-constraint violations. `anchors` adds per-mem counts of `resolved`/`drifted`/`recheck`/`unresolvable`. Unknown keys emit `UNKNOWN_INCLUDE_KEY`; `limit` caps at 10 (>100 clamps: `LIMIT_CLAMPED`). Warnings — see server instructions. Pass `mem` to scope to one writable mem (rosters stay global; `dangling_links`/`warnings` filter too). `include_config: true` adds `mutations` (`require_notes`), the opaque `plugin` map, and per-mem `mems` entries (`origin`, `vcs` `gitdir`/`worktree`/`head`, `write_guidance`, `extra`).",
3295        annotations(
3296            read_only_hint = true,
3297            destructive_hint = false,
3298            idempotent_hint = true,
3299            open_world_hint = false
3300        )
3301    )]
3302    fn memstead_health(&self, Parameters(p): Parameters<HealthParams>) -> CallToolResult {
3303        // include_config: true is served end-to-end via the unified
3304        // accessors (gitdir_for / worktree_for / mem_head_sha /
3305        // mem_config_for). `mutations` + `plugin` remain server
3306        // state on `self`.
3307        //
3308        // Caveat: git-branch mounts return `None` from
3309        // `mem_config_for` until the backend's config-read path
3310        // lifts; under include_config: true their per-mem entries
3311        // emit without `write_guidance` / `extra` (the `vcs` block
3312        // is present instead).
3313        let unified = self.unified_engine();
3314        self.memstead_health_unified(p, unified.clone())
3315    }
3316
3317    /// Body of [`Self::memstead_health`]. Default shape (`summary`,
3318    /// totals, distributions, rosters, `mem_schemas`) plus the
3319    /// eight `include` detail sections (orphans, stubs,
3320    /// most_connected, missing_fields, stale, dangling_links, tags,
3321    /// missing_required_outgoing). `include_config: true` adds
3322    /// `mutations`, `plugin`, and per-mem `vcs` / `write_guidance`
3323    /// / `extra` — the vcs subobject for git-branch mounts uses
3324    /// the worktree heuristic from
3325    /// [`memstead_base::Engine::worktree_for`].
3326    fn memstead_health_unified(
3327        &self,
3328        p: HealthParams,
3329        unified: Arc<Mutex<memstead_base::Engine>>,
3330    ) -> CallToolResult {
3331        let mut engine = crate::lock_engine!(unified);
3332        let drift_warnings = engine.reload_if_stale(p.mem.as_deref());
3333        let mem_changed_notices = engine.take_mem_changed_notices();
3334
3335        let include = p.include.unwrap_or_default();
3336        let args = memstead_engine::health::HealthArgs {
3337            mem: p.mem.as_deref(),
3338            include: &include,
3339            limit: p.limit,
3340            target_schema: p.target_schema.as_deref(),
3341            include_config: p.include_config,
3342        };
3343        // Server-owned config the engine does not carry — prebuilt here so the
3344        // composer inserts the bytes verbatim (and stays free of the MCP
3345        // server's config types).
3346        let plugin_json: serde_json::Map<String, serde_json::Value> = self
3347            .plugin
3348            .iter()
3349            .map(|(k, v)| {
3350                let json = serde_json::to_value(v).unwrap_or(serde_json::Value::Null);
3351                (k.clone(), json)
3352            })
3353            .collect();
3354        let config = memstead_engine::health::HealthConfig {
3355            mutations: serde_json::json!({ "require_notes": self.mutations.require_notes }),
3356            plugin: serde_json::Value::Object(plugin_json),
3357        };
3358
3359        let result = match memstead_engine::health::compose_health(
3360            &mut engine,
3361            &args,
3362            drift_warnings,
3363            &config,
3364        ) {
3365            Ok(v) => v,
3366            Err(memstead_engine::health::ComposeHealthError::MemQuarantined(name)) => {
3367                let err = engine.unknown_mem_error(&name);
3368                return engine_err_unified(err, &engine);
3369            }
3370            Err(memstead_engine::health::ComposeHealthError::UnknownMem {
3371                name,
3372                writable_mems,
3373            }) => {
3374                let msg = format!(
3375                    "unknown mem: \"{name}\". Writable mems: [{}]",
3376                    writable_mems.join(", ")
3377                );
3378                return tool_error_with_payload(
3379                    "UNKNOWN_MEM",
3380                    &msg,
3381                    envelope(
3382                        "UNKNOWN_MEM",
3383                        msg.clone(),
3384                        serde_json::json!({
3385                            "name": name,
3386                            "writable_mems": writable_mems,
3387                        }),
3388                    ),
3389                );
3390            }
3391            Err(memstead_engine::health::ComposeHealthError::InvalidTargetSchema {
3392                raw,
3393                reason,
3394            }) => {
3395                let msg = format!("invalid target_schema {raw:?}: {reason}");
3396                return tool_error_with_payload(
3397                    "INVALID_INPUT",
3398                    &msg,
3399                    envelope(
3400                        "INVALID_INPUT",
3401                        msg.clone(),
3402                        serde_json::json!({ "target_schema": raw, "reason": reason }),
3403                    ),
3404                );
3405            }
3406            Err(memstead_engine::health::ComposeHealthError::Engine(e)) => {
3407                return engine_err_unified(e, &engine);
3408            }
3409        };
3410
3411        let res = json_response(&result);
3412        let res = match p
3413            .mem
3414            .as_deref()
3415            .and_then(|v| mem_schema_ref_unified(&engine, v))
3416        {
3417            Some(s) => with_mem_schema_anchor(res, &s),
3418            None => res,
3419        };
3420        let res = attach_mem_changed_to_result(res, mem_changed_notices);
3421        // #57: the text channel is chunkable markdown rendered from the
3422        // final structured payload (which ships whole), so a multi-include
3423        // report can't overflow the response cap. Done last — after the
3424        // anchor / mem-changed post-processing that mutates
3425        // `structured_content`.
3426        finalize_health_text(res, p.token_budget.unwrap_or(self.token_budget), p.chunk)
3427    }
3428
3429    #[tool(
3430        name = "memstead_changes_since",
3431        description = "Per-mem commit-delta feed — reads the mem's own git repo (gitdir via `memstead_health include_config=true`). Pass `since` = a commit SHA previously returned by any mutation (`commit_sha` from create / update / delete / rename / relate responses), or the canonical git empty-tree hash `4b825dc642cb6eb9a060e54bf8d69288fbee4904` for a fresh-client first sync (fresh mems also return that hash as `head`). Returns a flat list of entity-level events — each event's `action` is one of `added`, `updated`, `removed`, `renamed`. Non-`removed` events carry `entity_type` (schema type name, e.g. spec, memo), looked up from the post-diff store; `removed` events carry `entity_type: null` alongside `title: null`. Engine-authored renames pair via commit-note provenance (`memstead: rename <old> → <new>`) — exact, similarity-independent, transitively composed across multi-step rename chains in the same window. Non-engine renames (`git mv`, pre-provenance migrations) fall back to a content-similarity scorer (default 0.6, tunable via `rename_similarity` in [0.1, 1.0]), capped at 1000 rewrite pairs per diff. Either path surfaces as a single `renamed` event with `from_id` and `to_id` rather than a removed+added pair. Out-of-range `rename_similarity` values refuse with `INVALID_INPUT` naming `details.allowed_range` and `details.requested`. `head` echoes the current HEAD SHA — save it as the next polling cursor (prefer full SHAs over refs). No pagination — every qualifying commit ships in one response. Pass `include_notes: true` to fold per-commit agent-notes (`notes[]`) and `memstead_ref` (SHA of the unified schema + per-mem-config registry) into the response — a commit-mirroring client gets deltas, notes, and the registry-ref sha in one round-trip. Unknown or malformed `since` returns `INVALID_CURSOR` with `details.mem` and `details.since`.",
3432        annotations(
3433            read_only_hint = true,
3434            destructive_hint = false,
3435            idempotent_hint = true,
3436            open_world_hint = false
3437        )
3438    )]
3439    fn memstead_changes_since(
3440        &self,
3441        Parameters(p): Parameters<ChangesSinceParams>,
3442    ) -> CallToolResult {
3443        // The engine's
3444        // `changes_since` populates `notes` and `memstead_ref` on every
3445        // git-branch call — the rename map is note-driven, so the
3446        // walk happens regardless. `include_notes` becomes a
3447        // renderer-side filter on the wire response: when `false`,
3448        // strip the fields so the wire shape matches the
3449        // `include_notes: false` contract.
3450        let include_notes = p.include_notes;
3451        let unified = self.unified_engine();
3452        let mut engine = crate::lock_engine!(unified);
3453        let drift_warnings = engine.reload_if_stale(Some(&p.mem));
3454        let mem_changed_notices = engine.take_mem_changed_notices();
3455        let mem_for_anchor = p.mem.clone();
3456        let res = match engine.changes_since(&p.mem, &p.since, p.rename_similarity) {
3457            Ok(mut report) => {
3458                if !include_notes {
3459                    report.notes = None;
3460                    report.memstead_ref = None;
3461                }
3462                let mut res = json_response(&report);
3463                for w in &drift_warnings {
3464                    res = append_warning_hint(res, w);
3465                }
3466                match mem_schema_ref_unified(&engine, &mem_for_anchor) {
3467                    Some(s) => with_mem_schema_anchor(res, &s),
3468                    None => res,
3469                }
3470            }
3471            Err(e) => {
3472                // Delegate to the typed-envelope translator so the wire
3473                // `code` matches `EngineError::code()` for the underlying
3474                // variant. A bad `since` cursor now arrives as the typed
3475                // `EngineError::InvalidChangesCursor` (code `INVALID_CURSOR`,
3476                // `details.mem` + untruncated `details.since`) — lifted
3477                // from the backend's typed marker in `Engine::changes_since`
3478                // rather than sniffed out of a raw backend message string here.
3479                // Genuine backend faults still surface `MEM_ERROR`.
3480                // The structured notice rides via the shared
3481                // `attach_mem_changed_to_result` below; prepend the
3482                // `MEM_RELOADED` text line here so the error path
3483                // carries the same channel split a success carries.
3484                prepend_drift_warnings_to_result_text(
3485                    engine_err_unified(e, &engine),
3486                    &drift_warnings,
3487                )
3488            }
3489        };
3490        attach_mem_changed_to_result(res, mem_changed_notices)
3491    }
3492
3493    #[tool(
3494        name = "memstead_diff",
3495        description = "Return a two-ref structural diff at entity granularity. Walks the tree at `ref_a` and the tree at `ref_b` in the mem's gitdir, surfacing per-entity changes as `entries[]` whose `status` is one of `added`, `modified`, `deleted`, `renamed`, `invalid_entity`. Each entry carries the full markdown body on both sides by default in `content_before` / `content_after`; pass `include_content: false` for the metadata-only shape (`id`, `title`, `entity_type`, `status`). Ref-handling conventions mirror `memstead_changes_since`: the canonical empty-tree sentinel `4b825dc642cb6eb9a060e54bf8d69288fbee4904` is accepted as either ref and short-circuits to git's empty tree (first-sync diffs against a fresh mem use this for `ref_a`); a bare `HEAD` resolves to the selected mem's branch tip rather than the gitdir's symbolic HEAD. Cross-mem diffs work via fully-qualified refs naming the peer mem's branch; cross-different-gitdir diffs are out of scope (the op operates on one mem-repo). Refusal codes: `UNKNOWN_MEM` (`details.name`), `UNKNOWN_REF` (`details.ref`), `INVALID_INPUT` for folder / archive mounts and for `rename_similarity` outside the allowed range. Rename detection uses content-similarity tuned by `rename_similarity`; agent-notes-driven rename-chain collapse is a follow-up. Each entry's `ripple` field carries per-side `{from_id, side}` entries for entities with inbound wiki-links to the affected entry — `side: \"ref_a\"` lists referrers at the `ref_a` snapshot, `side: \"ref_b\"` at `ref_b`. Pass `include_ripple: false` to omit the field entirely (e.g. for large mems where the per-side wiki-link scan is the dominant cost). Response top-level: `ref_a`, `ref_b`, `resolved_a_sha`, `resolved_b_sha`, `config`, `entries`.",
3496        annotations(
3497            read_only_hint = true,
3498            destructive_hint = false,
3499            idempotent_hint = true,
3500            open_world_hint = false
3501        )
3502    )]
3503    fn memstead_diff(&self, Parameters(p): Parameters<DiffParams>) -> CallToolResult {
3504        let unified = self.unified_engine();
3505        let engine = crate::lock_engine!(unified);
3506        let config = memstead_base::ops::DiffConfig {
3507            rename_similarity: p
3508                .rename_similarity
3509                .unwrap_or(memstead_base::ops::RENAME_SIMILARITY_DEFAULT),
3510            include_content: p.include_content,
3511            include_ripple: p.include_ripple,
3512        };
3513        match engine.diff(&p.mem, &p.ref_a, &p.ref_b, Some(config)) {
3514            Ok(diff) => json_response(&diff),
3515            Err(e) => engine_err_unified(e, &engine),
3516        }
3517    }
3518
3519    #[tool(
3520        name = "memstead_reload",
3521        description = "Reload one writable mem's slice of the in-memory store from its on-disk branch tip — or every writable mem when `mem` is omitted. For multi-engine coexistence: a sibling (forked subagent, macOS app, parallel terminal) or out-of-band `git pull` may have advanced HEAD past this engine's snapshot. The auto-reload-on-read pipeline surfaces `MEM_RELOADED` on the next read; this tool is explicit operator-driven refresh for the rare cases the throttle missed. Not a workaround for direct .md edits — restart the server instead. Per-mem form is cheap (~10 ms per few-hundred-entity mem); workspace-wide scales linearly. Response: `reports[]`, each entry `{ mem, head_before, head_after, entities_loaded, changed_entity_ids[] }`. `head_before` is the engine's prior cached SHA (canonical empty-tree hash for fresh mems); `head_after` is the freshly-peeled branch tip. `changed_entity_ids` is the union of added ∪ content-hash-changed ∪ removed entity ids — pass `head_before` to `memstead_changes_since` for the full per-entity diff. The workspace-wide form (omit `mem`) additionally picks up CLI writes to allowlist / cross-link / mutation policy (via `memstead workspace allow-create` etc.) without process restart. Per-mem form skips that workspace-level settings refresh. **Membership and the schema catalogue are fixed at boot for both default forms.** `full: true` (workspace-wide only) adds the ADDITIVE re-scan: out-of-band schema installs become resolvable, out-of-band mems mount cold, no restart; removals are skipped and reported — a deleted mem leaves the roster only on restart (its content sweep still reads current storage, so a hard-deleted branch reads empty while membership stays). Response adds `refresh` `{schemas_added, schema_removals_skipped, mems_mounted, mem_removals_skipped, failures, elapsed_ms}`; per-item failures never surface as available and never abort the rest. In-band lifecycle: `memstead_mem_create` / `memstead_mem_delete`.",
3522        annotations(
3523            read_only_hint = false,
3524            destructive_hint = false,
3525            idempotent_hint = true,
3526            open_world_hint = false
3527        )
3528    )]
3529    fn memstead_reload(&self, Parameters(p): Parameters<ReloadParams>) -> CallToolResult {
3530        let unified = self.unified_engine();
3531        let mut engine = crate::lock_engine!(unified);
3532        // Full mode: the additive schema-source + mount-manifest
3533        // re-scan runs FIRST (so newly registered mems join the
3534        // content sweep below), then the ordinary workspace-wide
3535        // content reload — coherence (MEM_RELOADED, expected_hash
3536        // discipline) rides the same content-reload path it always
3537        // did. Per-item refresh failures ride the `refresh` block;
3538        // they never abort the content reload.
3539        let refresh = if p.full.unwrap_or(false) {
3540            if p.mem.is_some() {
3541                let msg = "`full: true` is workspace-scoped — omit `mem`".to_string();
3542                return tool_error_with_payload(
3543                    "INVALID_INPUT",
3544                    &msg,
3545                    envelope(
3546                        "INVALID_INPUT",
3547                        msg.clone(),
3548                        serde_json::json!({ "message": msg }),
3549                    ),
3550                );
3551            }
3552            Some(engine.full_refresh())
3553        } else {
3554            None
3555        };
3556        let result = match p.mem.as_deref() {
3557            Some(name) => engine.reload_one_mem_report(name).map(|r| vec![r]),
3558            None => engine.reload_each_writable_mem_reports(),
3559        };
3560        match result {
3561            Ok(reports) => {
3562                let mut payload = serde_json::json!({ "reports": reports });
3563                if let Some(refresh) = refresh {
3564                    payload["refresh"] =
3565                        serde_json::to_value(&refresh).unwrap_or(serde_json::Value::Null);
3566                }
3567                json_response(&payload)
3568            }
3569            // `engine_err_unified` reads `EngineError::code()` for the
3570            // underlying variant so the wire envelope here carries the
3571            // same typed token the rest of the surface emits for the
3572            // same fire condition — `MEM_ERROR` for backend wraps,
3573            // `UNKNOWN_MEM` for missing-mem, etc.
3574            Err(e) => engine_err_unified(e, &engine),
3575        }
3576    }
3577
3578    // ----------------------------------------------------------------------
3579    // Mem lifecycle tools
3580    // ----------------------------------------------------------------------
3581
3582    #[tool(
3583        name = "memstead_mem_create",
3584        description = "Create and register a new writable mem at runtime. Requires workspace opt-in via `[[mem_management.create]]` rules (each `pattern` + `schemas[]`) — discover via `memstead_overview`'s `## Lifecycle Namespaces`. Engine composes the lifecycle candidate, canonicalizes `location`, runs first-match-wins glob over the rule list, then checks `schema` against the matched rule's `schemas[]` (`[\"*\"]` admits any). Two error envelopes: `MEM_PATH_NOT_ALLOWED` carries `details.candidate`, `details.patterns`, `details.reason` (`no_allowlist_configured` / `no_match` / `outside_workspace`); `MEM_SCHEMA_NOT_ALLOWED` carries `details.candidate`, `details.matched_pattern`, `details.requested_schema`, `details.allowed_schemas`. Name-collision check runs only after a path match — out-of-namespace collision surfaces as `MEM_PATH_NOT_ALLOWED`, not `MEM_NAME_COLLISION`. Storage-residue probe catches residue surviving a prior `memstead mem unregister` or a crash; residue left by a deliberate unregister reattaches and emits `MEM_REATTACHED_AFTER_UNREGISTER` (audit signal); residue from a crash refuses with `MEM_STORAGE_RESIDUE_DETECTED` — run `memstead mem delete <name>` first. Cross-mem edge authorization is workspace policy (`[cross_mem_links]`); the matched create-rule may carry `default_cross_links`. Bootstraps the gitdir per `vcs`, loads any pre-existing markdown, and produces a seed commit carrying `note` (≤280 chars). Response carries `location`, `seed_commit_sha` for `memstead_changes_since` polling, and `schema_ref` (gitdir via `memstead_health include_config=true`). Pass `include_schema: true` to additionally inline the full schema body — byte-identical to `memstead_schema(name=<resolved-schema>)`. Default `false`. A mem already present at the location returns `CONFIG_ERROR`. Seed-commit failure leaves partial disk state — no implicit rollback.",
3585        annotations(
3586            read_only_hint = false,
3587            destructive_hint = false,
3588            idempotent_hint = false,
3589            open_world_hint = false
3590        )
3591    )]
3592    fn memstead_mem_create(
3593        &self,
3594        Parameters(p): Parameters<crate::lifecycle::MemCreateParams>,
3595    ) -> CallToolResult {
3596        // Collisions surface via the snapshot probe with the
3597        // `{name, source}` envelope (callers branch on `code` only).
3598        let unified = self.unified_engine();
3599        self.memstead_mem_create_unified(p, unified.clone())
3600    }
3601
3602    #[tool(
3603        name = "memstead_mem_delete",
3604        description = "Remove a writable mem at runtime — always destructive: removes the mem and prunes every backend-visible artifact. Requires workspace opt-in via `[[mem_management.delete]]` rules — discover the current policy via `memstead_overview`'s `## Lifecycle Namespaces` section. Engine resolves `name` (`UNKNOWN_MEM` otherwise), composes the lifecycle candidate from the mem's full hierarchical path (or the bare name for flat-layout mems), runs first-match-wins glob lookup over the delete rule list (refusing `MEM_PATH_NOT_ALLOWED` — `details.candidate`/`details.patterns`/`details.reason` discriminate `no_allowlist_configured` vs `no_match`). Refuses `MEM_REFERENCED_BY_POLICY` when the workspace `cross_mem_links` policy grants this mem as a write target (`details.referring_mems` names them). Refuses `MEM_HAS_INCOMING_REFS` when write-mem graph edges still target it (`details.referrers` lists each `{from_id, rel_types, mem}` — remove via `memstead_relate` / `memstead_update` first). On success the mem is gone — reads no longer see it and its backing storage is removed. The workspace policy is atomically scrubbed of the now-dangling `[cross_mem_links]` grants naming the deleted mem on either side. The `[[mem_management.*]]` allowlist rules are PRESERVED (exact-name and wildcard alike) — forward-looking permissions for the name; re-creating the same name needs no fresh allow rules. No per-mem commit — `note` (≤280 chars) rides on the provenance context. Response: `name`, `deleted_from_router: true`, `files_deleted: true`, and `allowlist_entries_removed[{table, pattern?, from?, to?}]` listing the scrubbed cross-link grants (`table` is always `cross_mem_links`; empty when none named the mem). On partial cleanup failure `files_deleted` ends `false` and `MEM_FILES_NOT_DELETED` warnings name the survivors: `details.reason` is `rmdir_failed` (with `details.path` + `details.error`) or `backend_prune_failed` (with `details.error`).",
3605        annotations(
3606            read_only_hint = false,
3607            destructive_hint = true,
3608            idempotent_hint = false,
3609            open_world_hint = false
3610        )
3611    )]
3612    fn memstead_mem_delete(
3613        &self,
3614        Parameters(p): Parameters<crate::lifecycle::MemDeleteParams>,
3615    ) -> CallToolResult {
3616        let unified = self.unified_engine();
3617        self.memstead_mem_delete_unified(p, unified.clone())
3618    }
3619
3620    #[tool(
3621        name = "memstead_mem_set_version",
3622        description = "Update a registered mem's `version` field. The version is consumed by `memstead_export --format mem` to stamp the archive filename and the `.mem` archive's published config — bump before publishing. Mem-create seeds `0.1.0` automatically, so this tool is the only surface that needs to fire when an agent or operator is ready to ship a new version. Gate-free: no `[[mem_management.*]]` allowlist check, no operator-mode bypass needed. Validates the new version as semver; malformed values refuse with `INVALID_INPUT`. Unknown mem name refuses with `UNKNOWN_MEM`; read-only mem refuses with `READ_ONLY_MOUNT`; a mem whose config failed to load returns `INVALID_INPUT`. Response carries `{mem, old_version, new_version, warnings}`; `MEM_RELOADED` rides on `warnings` when a sibling engine commit landed between the engine's prior snapshot and this write (no extra read needed to learn the drift).",
3623        annotations(
3624            read_only_hint = false,
3625            destructive_hint = false,
3626            idempotent_hint = false,
3627            open_world_hint = false
3628        )
3629    )]
3630    fn memstead_mem_set_version(
3631        &self,
3632        Parameters(p): Parameters<crate::lifecycle::MemSetVersionParams>,
3633    ) -> CallToolResult {
3634        let new_version = match semver::Version::parse(&p.version) {
3635            Ok(v) => v,
3636            Err(e) => {
3637                let msg = format!("version {:?} is not a valid semver: {e}", p.version);
3638                return tool_error_with_payload(
3639                    "INVALID_INPUT",
3640                    &msg,
3641                    envelope(
3642                        "INVALID_INPUT",
3643                        msg.clone(),
3644                        serde_json::json!({ "message": msg }),
3645                    ),
3646                );
3647            }
3648        };
3649        let unified = self.unified_engine();
3650        let mut engine = crate::lock_engine!(unified);
3651        match engine.set_mem_version(&p.name, new_version, p.note.as_deref()) {
3652            Ok(outcome) => {
3653                let mut body = serde_json::json!({
3654                    "mem": outcome.mem,
3655                    "old_version": outcome.old_version.map(|v| v.to_string()),
3656                    "new_version": outcome.new_version.to_string(),
3657                    "warnings": outcome.warnings,
3658                });
3659                attach_mem_changed(&mut body, engine.take_mem_changed_notices());
3660                json_response(&body)
3661            }
3662            Err(e) => {
3663                // The notice already rode `structured_content` here;
3664                // the text channel lacked the `MEM_RELOADED` line a
3665                // successful response carries. A mutation reloads inside
3666                // the engine, so reconstruct the warning from the
3667                // drained notices to match the success channel split —
3668                // collision (`HASH_MISMATCH`) is the path drift matters
3669                // most, since it lands on the very entity being written.
3670                let notices = engine.take_mem_changed_notices();
3671                let warnings = notices_as_reload_warnings(&notices);
3672                attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
3673            }
3674        }
3675    }
3676
3677    #[tool(
3678        name = "memstead_mem_configure",
3679        description = "Update a mem's curation fields — display title, one-line description, and subject block — in one call: set what is present. Absent field = untouched; empty string (`title` / `description`) = clear; `clear_subject: true` clears the subject block as a unit (mutually exclusive with `subject`, both set refuses `INVALID_INPUT`). `subject` is `{scope, method?, exclusions?}` — what the mem covers, how its content was arrived at, what was deliberately left out. Display text, never identity: the mem stays addressed by `name` everywhere; a title is roster/UI text only. Same validation and storage as the CLI's `mem set-title` / `set-description` / `set-subject` — one config commit per touched field. Gate-free like the sibling setters (no `[[mem_management.*]]` allowlist applies), but every structural gate holds: unknown mem refuses `UNKNOWN_MEM`; read-only mounts refuse `READ_ONLY_MOUNT`. A call with no field present is a no-op returning the unchanged state. Response `{mem, title, description, subject, warnings}` carries the post-call values (null = unset); `MEM_RELOADED` rides on `warnings` when a sibling engine commit landed since the prior snapshot. Optional `note` (≤280 chars) rides each field's config commit.",
3680        annotations(
3681            read_only_hint = false,
3682            destructive_hint = false,
3683            idempotent_hint = true,
3684            open_world_hint = false
3685        )
3686    )]
3687    fn memstead_mem_configure(
3688        &self,
3689        Parameters(p): Parameters<crate::lifecycle::MemConfigureParams>,
3690    ) -> CallToolResult {
3691        if let Some(err) = validate_note(p.note.as_deref()) {
3692            return err;
3693        }
3694        if p.subject.is_some() && p.clear_subject {
3695            let msg = "`subject` and `clear_subject` are mutually exclusive — pass one";
3696            return tool_error_with_payload(
3697                "INVALID_INPUT",
3698                msg,
3699                envelope(
3700                    "INVALID_INPUT",
3701                    msg.to_string(),
3702                    serde_json::json!({ "message": msg }),
3703                ),
3704            );
3705        }
3706        let unified = self.unified_engine();
3707        let mut engine = crate::lock_engine!(unified);
3708        let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
3709
3710        // "Set what is present": each Some field routes through the
3711        // engine setter the CLI verb uses; empty string clears.
3712        if let Some(t) = p.title.clone() {
3713            let value = if t.is_empty() { None } else { Some(t) };
3714            match engine.set_mem_title(&p.name, value, p.note.as_deref()) {
3715                Ok(o) => warnings.extend(o.warnings),
3716                Err(e) => {
3717                    let notices = engine.take_mem_changed_notices();
3718                    let drift = notices_as_reload_warnings(&notices);
3719                    return attach_drift_to_error(engine_err_unified(e, &engine), &drift, notices);
3720                }
3721            }
3722        }
3723        if let Some(d) = p.description.clone() {
3724            let value = if d.is_empty() { None } else { Some(d) };
3725            match engine.set_mem_description(&p.name, value, p.note.as_deref()) {
3726                Ok(o) => warnings.extend(o.warnings),
3727                Err(e) => {
3728                    let notices = engine.take_mem_changed_notices();
3729                    let drift = notices_as_reload_warnings(&notices);
3730                    return attach_drift_to_error(engine_err_unified(e, &engine), &drift, notices);
3731                }
3732            }
3733        }
3734        if p.subject.is_some() || p.clear_subject {
3735            let value = p.subject.clone().map(|s| s.into_engine());
3736            match engine.set_mem_subject(&p.name, value, p.note.as_deref()) {
3737                Ok(o) => warnings.extend(o.warnings),
3738                Err(e) => {
3739                    let notices = engine.take_mem_changed_notices();
3740                    let drift = notices_as_reload_warnings(&notices);
3741                    return attach_drift_to_error(engine_err_unified(e, &engine), &drift, notices);
3742                }
3743            }
3744        }
3745
3746        // No-field calls still validate the mem exists (a pure no-op
3747        // against an unknown name would be a silent lie).
3748        let no_field =
3749            p.title.is_none() && p.description.is_none() && p.subject.is_none() && !p.clear_subject;
3750        if no_field && engine.mount(&p.name).is_none() {
3751            let e = engine.unknown_mem_error(&p.name);
3752            let notices = engine.take_mem_changed_notices();
3753            let drift = notices_as_reload_warnings(&notices);
3754            return attach_drift_to_error(engine_err_unified(e, &engine), &drift, notices);
3755        }
3756
3757        // Post-call state from the loaded config — the stable response
3758        // shape regardless of which fields were touched.
3759        let config = engine
3760            .mem_configs_named()
3761            .find(|(name, _)| *name == p.name)
3762            .map(|(_, c)| c.clone());
3763        let mut body = serde_json::json!({
3764            "mem": p.name,
3765            "title": config.as_ref().and_then(|c| c.title.clone()),
3766            "description": config.as_ref().and_then(|c| c.description.clone()),
3767            "subject": config.as_ref().and_then(|c| c.subject.as_ref().map(|sub| serde_json::json!({
3768                "scope": sub.scope,
3769                "method": sub.method,
3770                "exclusions": sub.exclusions,
3771            }))),
3772            "warnings": warnings,
3773        });
3774        attach_mem_changed(&mut body, engine.take_mem_changed_notices());
3775        json_response(&body)
3776    }
3777
3778    #[tool(
3779        name = "memstead_mem_set_schema",
3780        description = "Update a mem's schema pin — the integrity-driven schema-migration trigger. Stable response `{mem, schema_pin, migration_target, outcome, findings}`; branch on `outcome`: `noop` (requested == current pin), `switched` (mem already integral against the target — pin moved atomically), `migration_started` (not integral — mem enters dual-pin: writes now validate against the target, `findings` lists the non-integral entities as `{id, axis, code, detail}`), `migration_pending` (same target re-issued while repairs remain — `findings` carries the remaining entities). Migration loop: read `findings`, read both schemas via `memstead_schema`, repair each entity via `memstead_update` (validated strictly against the target; `relations_unset` is available on non-conformant entities), then re-issue this call — once every entity is integral it completes the switch. Reads stay permissive throughout; the dual-pin state survives engine restarts. Unknown mem refuses `UNKNOWN_MEM`; a schema ref that resolves to no loaded schema refuses `SCHEMA_NOT_FOUND`; malformed refs refuse `INVALID_INPUT`. Distinct from `memstead_mem_set_version`, which sets the mem *content* version, never the pin.",
3781        annotations(
3782            read_only_hint = false,
3783            destructive_hint = false,
3784            idempotent_hint = false,
3785            open_world_hint = false
3786        )
3787    )]
3788    fn memstead_mem_set_schema(
3789        &self,
3790        Parameters(p): Parameters<crate::lifecycle::MemSetSchemaParams>,
3791    ) -> CallToolResult {
3792        let target = match p.schema.parse::<memstead_schema::SchemaRef>() {
3793            Ok(r) => r,
3794            Err(e) => {
3795                let msg = format!("invalid schema ref {:?}: {e}", p.schema);
3796                return tool_error_with_payload(
3797                    "INVALID_INPUT",
3798                    &msg,
3799                    envelope(
3800                        "INVALID_INPUT",
3801                        msg.clone(),
3802                        serde_json::json!({ "message": msg }),
3803                    ),
3804                );
3805            }
3806        };
3807        let unified = self.unified_engine();
3808        let mut engine = crate::lock_engine!(unified);
3809        match engine.set_mem_schema(&p.mem, &target) {
3810            Ok(outcome) => {
3811                let mut body = serde_json::to_value(&outcome).expect("SetSchemaOutcome serialises");
3812                attach_mem_changed(&mut body, engine.take_mem_changed_notices());
3813                json_response(&body)
3814            }
3815            Err(e) => {
3816                let notices = engine.take_mem_changed_notices();
3817                let warnings = notices_as_reload_warnings(&notices);
3818                attach_drift_to_error(engine_err_unified(e, &engine), &warnings, notices)
3819            }
3820        }
3821    }
3822
3823    /// Body of [`Self::memstead_mem_create`].
3824    fn memstead_mem_create_unified(
3825        &self,
3826        p: crate::lifecycle::MemCreateParams,
3827        unified: Arc<Mutex<memstead_base::Engine>>,
3828    ) -> CallToolResult {
3829        let mut engine = crate::lock_engine!(unified);
3830
3831        let schema_ref = match p.schema.parse::<memstead_schema::SchemaRef>() {
3832            Ok(r) => r,
3833            Err(e) => {
3834                let msg = format!("invalid schema ref {:?}: {e}", p.schema);
3835                return tool_error_with_payload(
3836                    "INVALID_INPUT",
3837                    &msg,
3838                    envelope(
3839                        "INVALID_INPUT",
3840                        msg.clone(),
3841                        serde_json::json!({ "message": msg }),
3842                    ),
3843                );
3844            }
3845        };
3846
3847        // Resolve the inlined-schema verbosity up front — *before* the
3848        // create side-effect — so a bad value refuses cleanly rather than
3849        // after the mem has already landed on disk. Only meaningful when
3850        // `include_schema` is set; ignored otherwise per the param
3851        // contract (so a moot typo doesn't sink an otherwise-valid create).
3852        let schema_verbosity = if p.include_schema {
3853            match p.schema_verbosity.as_deref() {
3854                // Absent → lite, mirroring `memstead_schema`'s default so
3855                // the inlined body stays byte-identical to that tool's
3856                // default reply.
3857                None => render::SchemaVerbosity::Lite,
3858                Some(v) => match render::SchemaVerbosity::from_wire(v) {
3859                    Some(sv) => sv,
3860                    None => {
3861                        let msg = format!(
3862                            "unknown schema_verbosity: \"{v}\" — expected \"full\" or \"lite\""
3863                        );
3864                        return tool_error_with_payload(
3865                            "INVALID_INPUT",
3866                            &msg,
3867                            envelope(
3868                                "INVALID_INPUT",
3869                                msg.clone(),
3870                                serde_json::json!({
3871                                    "value": v,
3872                                    "allowed": ["full", "lite"],
3873                                }),
3874                            ),
3875                        );
3876                    }
3877                },
3878            }
3879        } else {
3880            render::SchemaVerbosity::Full
3881        };
3882
3883        // Curation fields ride the create call and are applied through
3884        // the same setters the CLI verbs use, after the create lands.
3885        let cur_title = p.title.clone().filter(|t| !t.is_empty());
3886        let cur_description = p.description.clone().filter(|d| !d.is_empty());
3887        let cur_subject = p.subject.clone();
3888        let cur_note = p.note.clone();
3889
3890        // Hierarchical paths are first-class. The separate `path`
3891        // wire-shape field retired; `name` carries the full
3892        // identifier (`team/sub-mem`) verbatim.
3893        let params = memstead_engine::mem_management::MemCreateParams {
3894            name: p.name,
3895            location: std::path::PathBuf::from(p.location),
3896            schema_ref,
3897            vcs: p.vcs.map(Into::into),
3898            note: p.note,
3899            operator_mode: self.operator_mode,
3900            // Forward the optional
3901            // recovery action from the MCP wire shape. Bare creates
3902            // pass `None` and route via the tombstone-driven
3903            // default; explicit values (`reattach` /
3904            // `force_overwrite` / `hard_cleanup_first`) override.
3905            recovery: p.recovery.map(Into::into),
3906            // Optional per-instance writing guidance from the wire
3907            // shape, forwarded opaquely into the seed config.
3908            write_guidance: p.write_guidance,
3909            actor: Actor::Agent,
3910            client: self.client.get().cloned(),
3911            // The MCP wire shape does not expose the storage override
3912            // yet — the workspace-shape heuristic keeps behaviour
3913            // identical.
3914            storage: None,
3915        };
3916
3917        match memstead_engine::mem_management::create_mem(&mut engine, params) {
3918            Ok(response) => {
3919                // Apply curation at creation — same setters, same
3920                // validation, same storage as the CLI verbs. Each is
3921                // its own config commit; a failure here surfaces after
3922                // the mem exists (the create itself has no implicit
3923                // rollback, matching the seed-commit contract).
3924                let mut curation_warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
3925                if let Some(t) = cur_title {
3926                    match engine.set_mem_title(&response.name, Some(t), cur_note.as_deref()) {
3927                        Ok(o) => curation_warnings.extend(o.warnings),
3928                        Err(e) => return engine_err_unified(e, &engine),
3929                    }
3930                }
3931                if let Some(d) = cur_description {
3932                    match engine.set_mem_description(&response.name, Some(d), cur_note.as_deref()) {
3933                        Ok(o) => curation_warnings.extend(o.warnings),
3934                        Err(e) => return engine_err_unified(e, &engine),
3935                    }
3936                }
3937                if let Some(subj) = cur_subject {
3938                    match engine.set_mem_subject(
3939                        &response.name,
3940                        Some(subj.into_engine()),
3941                        cur_note.as_deref(),
3942                    ) {
3943                        Ok(o) => curation_warnings.extend(o.warnings),
3944                        Err(e) => return engine_err_unified(e, &engine),
3945                    }
3946                }
3947
3948                // Build wire response with the same shape full emits.
3949                let body = serde_json::json!({
3950                    "name": response.name,
3951                    "location": response.location,
3952                    "schema_ref": response.schema_ref.to_string(),
3953                    "seed_commit_sha": response.seed_commit_sha,
3954                });
3955                // The engine ships the
3956                // `MEM_REATTACHED_AFTER_UNREGISTER` warning on the
3957                // create response. Surface every response-side warning
3958                // via `append_warning_hint` so MCP callers see the
3959                // structured envelope alongside the success payload.
3960                let mut create_warnings: Vec<memstead_base::ops::WarningHint> =
3961                    response.warnings.clone();
3962                create_warnings.extend(curation_warnings);
3963                let res = json_response(&body);
3964                let res = create_warnings.iter().fold(res, append_warning_hint);
3965                // Inline the
3966                // full schema body only when the caller opts in via
3967                // `include_schema: true`. Otherwise every successful
3968                // create would ship ~25 KB of schema body, even for the
3969                // agent's second+ mem on the same schema where the
3970                // value is workspace-stable and already cached.
3971                let schema_payload = if p.include_schema {
3972                    engine.schemas().get(&response.name).cloned().map(|s| {
3973                        let origin = engine.schema_origin(&s);
3974                        render::build_schema_payload(
3975                            &s,
3976                            vec![response.name.clone()],
3977                            schema_verbosity,
3978                            origin,
3979                        )
3980                    })
3981                } else {
3982                    None
3983                };
3984                let res = if let Some(payload) = schema_payload {
3985                    let mut res = res;
3986                    if let Some(sc) = res.structured_content.as_mut()
3987                        && let Some(obj) = sc.as_object_mut()
3988                    {
3989                        obj.insert("schema".to_string(), payload);
3990                        if let Ok(text) = serde_json::to_string_pretty(&*sc) {
3991                            res.content = vec![rmcp::model::ContentBlock::text(text)];
3992                        }
3993                    }
3994                    res
3995                } else {
3996                    res
3997                };
3998                match mem_schema_ref_unified(&engine, &response.name) {
3999                    Some(s) => with_mem_schema_anchor(res, &s),
4000                    None => res,
4001                }
4002            }
4003            Err(e) => full_engine_err_unified(e, &engine),
4004        }
4005    }
4006
4007    /// Unified-engine path for [`Self::memstead_mem_delete`].
4008    fn memstead_mem_delete_unified(
4009        &self,
4010        p: crate::lifecycle::MemDeleteParams,
4011        unified: Arc<Mutex<memstead_base::Engine>>,
4012    ) -> CallToolResult {
4013        let mut engine = crate::lock_engine!(unified);
4014
4015        // MCP `memstead_mem_delete`
4016        // always means destructive. The wire shape no longer exposes
4017        // `delete_files`; the wrapper hardcodes `true` so the engine
4018        // runs both refusal gates (`MEM_REFERENCED_BY_POLICY`,
4019        // `MEM_HAS_INCOMING_REFS`) and the policy scrub on success.
4020        let params = memstead_engine::mem_management::MemDeleteParams {
4021            name: p.name,
4022            delete_files: true,
4023            note: p.note,
4024            operator_mode: self.operator_mode,
4025            detach_incoming: false,
4026        };
4027
4028        // Snapshot the schema-ref BEFORE the delete so the response
4029        // can still anchor to the now-departed mem's schema.
4030        let mem_for_anchor = mem_schema_ref_unified(&engine, &params.name);
4031
4032        match memstead_engine::mem_management::delete_mem(&mut engine, params) {
4033            Ok(response) => {
4034                let body = serde_json::json!({
4035                    "name": response.name,
4036                    "deleted_from_router": response.deleted_from_router,
4037                    "files_deleted": response.files_deleted,
4038                    // Surface scrubbed
4039                    // `.memstead/workspace.toml` entries so the agent
4040                    // doesn't have to re-read `workspace show` to
4041                    // learn the policy side effects of the delete.
4042                    "allowlist_entries_removed": &response.allowlist_entries_removed,
4043                });
4044                let res = json_response(&body);
4045                let res = match mem_for_anchor {
4046                    Some(s) => with_mem_schema_anchor(res, &s),
4047                    None => res,
4048                };
4049                // Surface every engine-emitted warning: disk-cleanup
4050                // outcome (rmdir_failed / backend_prune_failed) and the
4051                // `NOTE_MISSING` provenance nudge the engine now emits
4052                // when `require_notes` is set.
4053                let mut res = res;
4054                for w in &response.warnings {
4055                    res = append_warning_hint(res, w);
4056                }
4057                res
4058            }
4059            Err(e) => full_engine_err_unified(e, &engine),
4060        }
4061    }
4062
4063    // ------------------------------------------------------------------
4064    // Six MCP tools wrapping the
4065    // engine-located `workspace_config_edit` writers. Closes the F7
4066    // dynamic-mem-lifecycle gap — an MCP-driven agent can now
4067    // grant a cross-mem link, perform the multi-mem work, then
4068    // revoke the grant and delete the target mem all without
4069    // dropping to the CLI. Each tool is idempotent: re-grant /
4070    // re-revoke / re-add / re-remove return success with a typed
4071    // warning rather than an error.
4072    // ------------------------------------------------------------------
4073
4074    #[tool(
4075        name = "memstead_workspace_grant_cross_link",
4076        description = "Grant mem `from` permission to author cross-mem links into mem `to`. Mutates the `[cross_mem_links]` workspace policy. Dynamic-mem-lifecycle workflow: `memstead_mem_create → memstead_workspace_grant_cross_link → memstead_relate cross-mem → memstead_relate remove → memstead_workspace_revoke_cross_link → memstead_mem_delete`. Idempotent: re-grant of an existing grant returns success with `GRANT_ALREADY_PRESENT` warning, file unchanged. Conflict mode (wildcard against an existing specific list, or a named target against an existing wildcard) returns `CROSS_LINK_CONFLICT` — operators pick a single shape per `from`-mem. Refuses with `WORKSPACE_NOT_INITIALISED` when the workspace config is missing, `INVALID_TOML` when the file fails to parse, `IO_ERROR` on write failure. Response carries `{from, to, warnings}`.",
4077        annotations(
4078            read_only_hint = false,
4079            destructive_hint = false,
4080            idempotent_hint = true,
4081            open_world_hint = false
4082        )
4083    )]
4084    fn memstead_workspace_grant_cross_link(
4085        &self,
4086        Parameters(p): Parameters<crate::lifecycle::WorkspaceGrantCrossLinkParams>,
4087    ) -> CallToolResult {
4088        // Registered mems (any mount) drive the grant's target
4089        // validation; collect owned names so the dispatch closure
4090        // doesn't hold the engine lock.
4091        let known_mems: Vec<String> = {
4092            let engine = crate::lock_engine!(self.unified_engine());
4093            engine.mem_names().iter().map(|s| s.to_string()).collect()
4094        };
4095        self.workspace_edit_dispatch("grant_cross_link", |root| {
4096            let target = memstead_engine::workspace_config_edit::CrossLinkTarget::parse(&p.to);
4097            memstead_engine::workspace_config_edit::grant_cross_link(
4098                root,
4099                &p.from,
4100                &target,
4101                &known_mems,
4102            )
4103            .map(|warnings| {
4104                serde_json::json!({
4105                    "from": p.from.clone(),
4106                    "to": p.to.clone(),
4107                    "warnings": warnings_payload(&warnings),
4108                })
4109            })
4110        })
4111    }
4112
4113    #[tool(
4114        name = "memstead_workspace_revoke_cross_link",
4115        description = "Revoke mem `from`'s permission to author cross-mem links into mem `to`. Mutates the `[cross_mem_links]` workspace policy; when the underlying list becomes empty, the `from` key is dropped entirely. Dynamic-mem-lifecycle workflow: revoke before `memstead_mem_delete` to clear the `MEM_REFERENCED_BY_POLICY` refusal. Idempotent: re-revoke of an absent grant returns success with `GRANT_NOT_FOUND` warning, file unchanged. Refuses with `WORKSPACE_NOT_INITIALISED` when the workspace config is missing, `INVALID_TOML` when the file fails to parse, `IO_ERROR` on write failure. Response carries `{from, to, warnings}`.",
4116        annotations(
4117            read_only_hint = false,
4118            destructive_hint = true,
4119            idempotent_hint = true,
4120            open_world_hint = false
4121        )
4122    )]
4123    fn memstead_workspace_revoke_cross_link(
4124        &self,
4125        Parameters(p): Parameters<crate::lifecycle::WorkspaceRevokeCrossLinkParams>,
4126    ) -> CallToolResult {
4127        self.workspace_edit_dispatch("revoke_cross_link", |root| {
4128            let target = memstead_engine::workspace_config_edit::CrossLinkTarget::parse(&p.to);
4129            memstead_engine::workspace_config_edit::revoke_cross_link(root, &p.from, &target).map(
4130                |warnings| {
4131                    serde_json::json!({
4132                        "from": p.from.clone(),
4133                        "to": p.to.clone(),
4134                        "warnings": warnings_payload(&warnings),
4135                    })
4136                },
4137            )
4138        })
4139    }
4140
4141    #[tool(
4142        name = "memstead_workspace_allow_create",
4143        description = "Append a `[[mem_management.create]]` rule admitting mem names matching `pattern` with the given schema pins. The allowlist gates `memstead_mem_create`; without a matching rule, mem creation refuses with `MEM_PATH_NOT_ALLOWED`. Pass `before` to lift the new rule above an existing pattern; without `before` the rule appends at the end (lowest priority). Pass `default_cross_links` to confer a cross-mem link grant on every mem matching `pattern` — saves a follow-up `memstead_workspace_grant_cross_link`. The grant is rule-derived and evaluated lazily at relate time (it is NOT written into the `[cross_mem_links]` table); `memstead_overview` surfaces it under the matching pattern in `## Lifecycle Namespaces` and as the `cross_mem_links_from_rules` workspace-policy posture. Idempotent: re-add with the same `pattern` AND the same `schemas` set returns success with `RULE_ALREADY_PRESENT` warning, file unchanged (schema-set comparison is order- and duplicate-insensitive). Re-adding an existing `pattern` with a *different* `schemas` set is refused with `RULE_EXISTS_SCHEMAS_DIFFER` (`details.stored_schemas`, `details.requested_schemas`, `details.recovery`) — this verb only adds rules, it does not modify a rule's schema pins; to change them, `memstead_workspace_revoke_create` the pattern then re-add with the new schemas. `before` resolution failure surfaces as `BEFORE_PATTERN_NOT_FOUND`. Refuses with `WORKSPACE_NOT_INITIALISED` when the workspace config is missing.",
4144        annotations(
4145            read_only_hint = false,
4146            destructive_hint = false,
4147            idempotent_hint = true,
4148            open_world_hint = false
4149        )
4150    )]
4151    fn memstead_workspace_allow_create(
4152        &self,
4153        Parameters(p): Parameters<crate::lifecycle::WorkspaceAllowCreateParams>,
4154    ) -> CallToolResult {
4155        self.workspace_edit_dispatch("allow_create", |root| {
4156            let cross_links: Vec<memstead_engine::workspace_config_edit::CrossLinkTarget> = p
4157                .default_cross_links
4158                .as_ref()
4159                .map(|targets| {
4160                    targets
4161                        .iter()
4162                        .map(|t| memstead_engine::workspace_config_edit::CrossLinkTarget::parse(t))
4163                        .collect()
4164                })
4165                .unwrap_or_default();
4166            let cross_links_slice = if cross_links.is_empty() {
4167                None
4168            } else {
4169                Some(cross_links.as_slice())
4170            };
4171            memstead_engine::workspace_config_edit::add_create_rule(
4172                root,
4173                &p.pattern,
4174                &p.schemas,
4175                cross_links_slice,
4176                p.before.as_deref(),
4177            )
4178            .map(|warnings| {
4179                serde_json::json!({
4180                    "pattern": p.pattern.clone(),
4181                    "schemas": p.schemas.clone(),
4182                    "before": p.before.clone(),
4183                    "default_cross_links": p.default_cross_links.clone(),
4184                    "warnings": warnings_payload(&warnings),
4185                })
4186            })
4187        })
4188    }
4189
4190    #[tool(
4191        name = "memstead_workspace_revoke_create",
4192        description = "Remove a `[[mem_management.create]]` rule by `pattern`. Counterpart to `memstead_workspace_allow_create`. Idempotent: revoking a `pattern` with no matching rule returns success with `RULE_NOT_FOUND_NOOP` warning, file unchanged. Refuses with `WORKSPACE_NOT_INITIALISED` when the workspace config is missing, `INVALID_TOML` on parse failure, `IO_ERROR` on write failure. Response carries `{pattern, warnings}`.",
4193        annotations(
4194            read_only_hint = false,
4195            destructive_hint = true,
4196            idempotent_hint = true,
4197            open_world_hint = false
4198        )
4199    )]
4200    fn memstead_workspace_revoke_create(
4201        &self,
4202        Parameters(p): Parameters<crate::lifecycle::WorkspaceRevokeCreateParams>,
4203    ) -> CallToolResult {
4204        self.workspace_edit_dispatch("revoke_create", |root| {
4205            memstead_engine::workspace_config_edit::remove_create_rule(root, &p.pattern).map(
4206                |warnings| {
4207                    serde_json::json!({
4208                        "pattern": p.pattern.clone(),
4209                        "warnings": warnings_payload(&warnings),
4210                    })
4211                },
4212            )
4213        })
4214    }
4215
4216    #[tool(
4217        name = "memstead_workspace_allow_delete",
4218        description = "Append a `[[mem_management.delete]]` rule admitting deletes of mem names matching `pattern`. Symmetric counterpart to `memstead_workspace_allow_create` — agent-creatable equals agent-deletable. Without a matching rule, `memstead_mem_delete` refuses with `MEM_PATH_NOT_ALLOWED`. Idempotent: re-add with the same `pattern` returns success with `RULE_ALREADY_PRESENT` warning, file unchanged. Refuses with `WORKSPACE_NOT_INITIALISED` when the workspace config is missing.",
4219        annotations(
4220            read_only_hint = false,
4221            destructive_hint = false,
4222            idempotent_hint = true,
4223            open_world_hint = false
4224        )
4225    )]
4226    fn memstead_workspace_allow_delete(
4227        &self,
4228        Parameters(p): Parameters<crate::lifecycle::WorkspaceAllowDeleteParams>,
4229    ) -> CallToolResult {
4230        self.workspace_edit_dispatch("allow_delete", |root| {
4231            memstead_engine::workspace_config_edit::add_delete_rule(root, &p.pattern).map(
4232                |warnings| {
4233                    serde_json::json!({
4234                        "pattern": p.pattern.clone(),
4235                        "warnings": warnings_payload(&warnings),
4236                    })
4237                },
4238            )
4239        })
4240    }
4241
4242    #[tool(
4243        name = "memstead_workspace_revoke_delete",
4244        description = "Remove a `[[mem_management.delete]]` rule by `pattern`. Counterpart to `memstead_workspace_allow_delete`. Idempotent: revoking a `pattern` with no matching rule returns success with `RULE_NOT_FOUND_NOOP` warning, file unchanged. Refuses with `WORKSPACE_NOT_INITIALISED` when the workspace config is missing, `INVALID_TOML` on parse failure, `IO_ERROR` on write failure. Response carries `{pattern, warnings}`.",
4245        annotations(
4246            read_only_hint = false,
4247            destructive_hint = true,
4248            idempotent_hint = true,
4249            open_world_hint = false
4250        )
4251    )]
4252    fn memstead_workspace_revoke_delete(
4253        &self,
4254        Parameters(p): Parameters<crate::lifecycle::WorkspaceRevokeDeleteParams>,
4255    ) -> CallToolResult {
4256        self.workspace_edit_dispatch("revoke_delete", |root| {
4257            memstead_engine::workspace_config_edit::remove_delete_rule(root, &p.pattern).map(
4258                |warnings| {
4259                    serde_json::json!({
4260                        "pattern": p.pattern.clone(),
4261                        "warnings": warnings_payload(&warnings),
4262                    })
4263                },
4264            )
4265        })
4266    }
4267
4268    /// Common dispatcher for the six `memstead_workspace_*` tools.
4269    /// Locates the workspace
4270    /// root from the engine, invokes the writer closure, and maps
4271    /// `WorkspaceEditError` to the typed MCP envelope. The closure
4272    /// receives the workspace root and returns either the success
4273    /// payload (the mutated subsection + warnings) or a typed error.
4274    fn workspace_edit_dispatch<F>(&self, _verb: &'static str, f: F) -> CallToolResult
4275    where
4276        F: FnOnce(
4277            &std::path::Path,
4278        ) -> Result<
4279            serde_json::Value,
4280            memstead_engine::workspace_config_edit::WorkspaceEditError,
4281        >,
4282    {
4283        let unified = self.unified_engine();
4284        let engine = crate::lock_engine!(unified);
4285        let root = match engine.workspace_root() {
4286            Some(p) => p.to_path_buf(),
4287            None => {
4288                let msg = "engine has no workspace root — `memstead_workspace_*` tools require a workspace-backed engine, not an ad-hoc mount list".to_string();
4289                return tool_error_with_payload(
4290                    "WORKSPACE_NOT_INITIALISED",
4291                    &msg,
4292                    envelope(
4293                        "WORKSPACE_NOT_INITIALISED",
4294                        msg.clone(),
4295                        serde_json::json!({}),
4296                    ),
4297                );
4298            }
4299        };
4300        drop(engine);
4301        match f(&root) {
4302            Ok(body) => {
4303                // F6 MCP: policy mutations write the file and
4304                // also refresh the engine's in-memory settings
4305                // cache. The next call into the engine after this
4306                // tool returns sees the new policy without an
4307                // intervening `memstead_reload`. Failure to refresh is
4308                // non-fatal — the file write already succeeded;
4309                // surfacing a refresh error would mislead the caller
4310                // about the durable outcome.
4311                if let Ok(refreshed) =
4312                    memstead_base::workspace_store::parse_workspace_settings(&root)
4313                {
4314                    let mut engine = crate::lock_engine!(unified);
4315                    engine.set_settings(refreshed);
4316                }
4317                json_response(&body)
4318            }
4319            Err(e) => workspace_edit_err_to_envelope(e),
4320        }
4321    }
4322}
4323
4324/// Render a `Vec<WorkspaceEditWarning>` as a JSON array of
4325/// `{code, message}` entries. Mirrors the shape `memstead_mem_delete`
4326/// uses for its warnings so agents have one consistent decoder.
4327fn warnings_payload(
4328    warnings: &[memstead_engine::workspace_config_edit::WorkspaceEditWarning],
4329) -> Vec<serde_json::Value> {
4330    warnings
4331        .iter()
4332        .map(|w| serde_json::json!({ "code": w.code(), "message": w.to_string() }))
4333        .collect()
4334}
4335
4336/// Map a `WorkspaceEditError` to the typed MCP envelope. The error
4337/// `code()` is preserved verbatim so the agent's branching surface
4338/// is unchanged across CLI and MCP.
4339fn workspace_edit_err_to_envelope(
4340    err: memstead_engine::workspace_config_edit::WorkspaceEditError,
4341) -> CallToolResult {
4342    use memstead_engine::workspace_config_edit::WorkspaceEditError as E;
4343    let code = err.code();
4344    let message = err.to_string();
4345    let details = match &err {
4346        E::WorkspaceNotInitialised { path } => {
4347            serde_json::json!({ "path": path.display().to_string() })
4348        }
4349        E::InvalidToml { path, message } => {
4350            serde_json::json!({ "path": path.display().to_string(), "parse_error": message })
4351        }
4352        E::BeforePatternNotFound { section, pattern } => {
4353            serde_json::json!({ "section": section, "pattern": pattern })
4354        }
4355        E::CrossLinkConflict { from, message } => {
4356            serde_json::json!({ "from": from, "reason": message })
4357        }
4358        E::RuleExistsSchemasDiffer {
4359            section,
4360            pattern,
4361            stored,
4362            requested,
4363        } => serde_json::json!({
4364            "section": section,
4365            "pattern": pattern,
4366            "stored_schemas": stored,
4367            "requested_schemas": requested,
4368            "recovery": format!("revoke_create({pattern}) then allow_create({pattern}, …) with the new schemas"),
4369        }),
4370        E::Io { path, source } => {
4371            serde_json::json!({ "path": path.display().to_string(), "error": source.to_string() })
4372        }
4373    };
4374    tool_error_with_payload(code, &message, envelope(code, message.clone(), details))
4375}
4376
4377// ==========================================================================
4378// ServerHandler — wired up by #[tool_handler] macro
4379// ==========================================================================
4380
4381/// The full server's session-start instructions — one named const so
4382/// the registry-honesty tests (`memstead-mcp/tests/tool_surface.rs`)
4383/// read the SAME string the macro serves, with no duplicated copy to
4384/// drift. Built with `concat!` so the engine version is baked in at
4385/// compile time; the trailing roster must name every registered tool
4386/// (bidirectionally test-enforced) and the CLI-companion note names
4387/// the verb families that deliberately live on the CLI only.
4388pub const SERVER_INSTRUCTIONS: &str = concat!(
4389    "Memstead: schema-agnostic graph engine for typed, interconnected markdown entities. Each mem is a typed model of a chosen subject — its modal flavour follows from its schema (knowledge / planning / inquiry / spec / hybrid). Each mem pins one schema; types and relationships are vocabulary-controlled. Granularity: a mem is the packaged unit — a whole typed model, designed for 1,000-5,000 entities (operating costs measured in docs/sizing-curve.md; larger holdings work at proportionally higher load cost); an entity is never called a mem (a mem is not one 'memory'/fact). Cold-start: call memstead_overview first for the schema catalogue (`{ref, description}` per schema), mem inventory, and communities (token-budgeted; drill via include/hints). Schema-discovery contract: each writable mem pins one schema (visible on overview's `## Mems` entries). Before any memstead_create / memstead_update / memstead_relate against mem X, call memstead_schema(name=<X.schema_ref>) once per session. The default reply is the lite structural skeleton — entity-type names with section keys and metadata-field shapes, relationship names with endpoint constraints, plus every legality flag (required sections/fields, required_outgoing edge blocks with cardinality, alias_target_rel_type, manual-authoring posture, acyclic) — enough to plan a legal write. Pass verbosity: full for the prose layer (per-section write_rules, writing_guidance, system_context, when_to_use) before substantial authoring against an unfamiliar schema. Cache for the session — schema is workspace-stable. Schema-conformance errors carry recovery payloads as a fallback (UNKNOWN_SECTION, UNKNOWN_METADATA_FIELD, INVALID_ENUM_VALUE, REQUIRED_FIELD_UNSET, INVALID_REL_TYPE, INVALID_REL_SHAPE, MISSING_REQUIRED_SECTION) — fix from `details` rather than re-fetching the schema after every error. Edge model is alias: body wiki-links `[[X]]` are foreign-key references to entries in the auto-managed `## Relationships` section. Schemas with `alias_target_rel_type` auto-emit relations of that rel-type (e.g. REFERENCES) from each body wiki-link via the alias-synthesis pass; explicit author of the named rel-type refuses with RELATION_MANUAL_AUTHORING_FORBIDDEN. Schemas without the pointer refuse unbacked body wiki-links with WIKILINK_WITHOUT_RELATION. Removing a relation while body wiki-links to its target remain refuses only when no other relation to that target survives (RELATION_HAS_BODY_LINKS — set-membership semantics). Shared mutation contract: every mutation accepts an optional note (≤280 chars) landing in the commit body as provenance; when [mutations].require_notes=true a missing note adds a non-blocking NOTE_MISSING warning — the mutation still commits (the policy nudges, it never blocks). memstead_create and memstead_update additionally accept an optional anchors[] — durable provenance records tying the entity to the source artifacts it describes (artifact, grain span|file|tree|url|entity, provenance class anchored|derived|authored|informed-by); they write into the mem-branch anchors sidecar in the SAME commit as the entity, a malformed element refuses the whole mutation with INVALID_ANCHOR (details carries the offending field + allowed set), and the sidecar never participates in _hash — attaching or refreshing anchors never invalidates a cached expected_hash and never surfaces as an entity delta in memstead_changes_since. Anchor writes MERGE into the entity's existing set — same (artifact, grain, class) triple replaces, otherwise appends; writing never removes an anchor the call did not name in memstead_update's anchors_unset[] (explicit removal: bare artifact removes every anchor on it, grain/class narrow the selection, unsetting a nonexistent target is a no-op). memstead_delete removes the entity's anchors and memstead_rename moves them to the new id, both in the same commit as the operation. Real writes return commit_sha (per-mem git; gitdir via memstead_health include_config=true) — use it as the since cursor for memstead_changes_since polling. Schema-conformance recovery payloads carry the fix material in place: details.declared / details.allowed with nearest-match suggestion, details.field_description, details.enum_values, and the type's details.type_write_rules. After a successful memstead_relate the touched entity's on-disk _hash advances; the relate response's _hash is the next valid expected_hash (no re-read needed) — no-op relates (duplicate add, remove-nonexistent) echo the unchanged _hash, which stays valid. Common workflows: search entities by content/structure (memstead_search — omit query for pure metadata filter); read one (memstead_entity — `_hash` is the optimistic-locking token for mutations); read one schema (memstead_schema); create/update/relate/rename/delete entities (memstead_create, memstead_update, memstead_relate, memstead_rename, memstead_delete); manage workspace mems including planning phases (memstead_mem_create, memstead_mem_delete); inspect drift and per-mem config (memstead_health); poll commit deltas for incremental sync (memstead_changes_since). Errors and warnings ship as { code, message, details } on structured_content; branch on the stable UPPER_SNAKE_CASE code. The text channel mirrors the same code inline as `ERROR [<CODE>]: <message>` so consumers that only read `result.content[0].text` still recover the code with a one-line regex. Never edit `.md` spec files directly — always go through Memstead tools. Error codes: ENTITY_NOT_FOUND, ENTITY_ALREADY_EXISTS, UNKNOWN_MEM, HASH_MISMATCH, RELATIONSHIP_CYCLE, UNKNOWN_SECTION, UNKNOWN_METADATA_FIELD, UNKNOWN_ENTITY_TYPE, INVALID_ENUM_VALUE, INVALID_REL_TYPE, INVALID_REL_SHAPE, READ_ONLY_FIELD, REQUIRED_FIELD_UNSET, SET_AND_UNSET_CONFLICT, CONFLICTING_SECTION_MODES, SECTION_NOT_UPDATABLE, PATCH_OLD_NOT_FOUND, PATCH_SECTION_EMPTY, CROSS_MEM_LINK_NOT_ALLOWED, CROSS_MEM_TARGET_NOT_FOUND, CROSS_MEM_EDGE_NOT_DECLARED, MEM_NOT_WRITABLE, MEM_NAME_COLLISION, MEM_PATH_NOT_ALLOWED, INVALID_MEM_NAME, MEM_SCHEMA_NOT_ALLOWED, MEM_BRANCH_MISSING, MEM_REFERENCED_BY_POLICY, HAS_INCOMING_REFS, STUB_NOT_UPDATABLE, STUB_NOT_RENAMABLE, STUB_CANNOT_RELATE, INVALID_ENTITY_ID, WIKILINK_WITHOUT_RELATION, RELATION_HAS_BODY_LINKS, MISSING_REQUIRED_DESCRIPTION, DESCRIPTION_NOT_PERMITTED, RELATION_MANUAL_AUTHORING_FORBIDDEN, SCHEMA_NOT_FOUND, SCHEMA_RESOLVER_INIT_FAILED, PARSE_ERROR, MEM_ERROR, INVALID_INPUT, VCS_ERROR, INTERNAL_IO_ERROR, CONFIG_ERROR, EXPORT_ERROR, WORKSPACE_SCHEMAS_ERROR, TOOL_DISABLED, INVALID_CURSOR, INVALID_ANCHOR. Health warnings: OUTER_REPO_NOT_IGNORING_MEM_REPO (workspace embedded in an outer git checkout that does not ignore mem-repo/), SUSPICIOUS_NESTED_PREFIX (nested-prefix drift — fix via memstead_update), DUPLICATE_SECTION_HEADING (a section key whose ## Heading appeared twice; first body kept), SCHEMA_AUTHORING_SOURCE_MISSING / SCHEMA_AUTHORING_SOURCE_DIVERGED (a pinned schema's install-stamped authoring package is gone from the working tree, or no longer parses equivalent to the sealed copy the engine runs on; unstamped schemas are not checked). Mem-create warning: FOLDER_MEM_PROVENANCE (the new mem's folder storage has no version control — mutations land in the changelog ledger with their notes, commit_sha is a synthetic placeholder, durability depends on the surrounding repo). Drift warning on any tool: MEM_RELOADED (a sibling engine committed to this mem-repo; the engine auto-reloaded — response content is fresh but cached expected_hash values are stale; re-derive before the next mutation). Relate warnings: AUTO_STUB_CREATED. Delete warning: RESIDUAL_STUB_FOR_READONLY_REFERRERS. Boot warnings: PARSED_RELATION_INVALID, AMBIGUOUS_DESCRIPTION_DELIMITER, MISSING_REQUIRED_DESCRIPTION, DESCRIPTION_NOT_PERMITTED, ENGINE_VERSION_SKEW (the mem's last mutation was performed by a different engine version than this binary; informative, the next mutation re-stamps), SCHEMA_GENERATIONS_BEHIND (a mem pins a built-in schema while newer built-in generations are registered; the pin keeps working — migrate via memstead_mem_set_schema when ready; locally-installed pins are not checked). Mutation warning: MISSING_REQUIRED_OUTGOING.",
4390    " Engine version: ",
4391    env!("CARGO_PKG_VERSION"),
4392    " — serverInfo.version carries the same value; a version different from your last session means this surface may have changed: re-read the roster below. Tool roster (complete, 25 tools): READ — memstead_overview (workspace dashboard: schemas, mems, communities, quarantine roster), memstead_entity (one entity + _hash), memstead_search (text + metadata filter), memstead_schema (one schema, lite/full), memstead_health (drift, conformance, per-mem config, quarantine roster, boot diagnosis), memstead_diff (two-ref structural diff), memstead_changes_since (commit deltas for incremental sync). WRITE — memstead_create, memstead_update, memstead_relate, memstead_rename, memstead_delete (entity mutations, optimistic _hash locking). PROCESS — memstead_check (record a check of one entity: verdict ok|failed with method note; never a mutation — derived check state serves in memstead_entity's opt-in provenance block). SESSION — memstead_reload (re-read mems after out-of-band commits; also returns a repaired quarantined mem to service). MEM LIFECYCLE — memstead_mem_create, memstead_mem_delete, memstead_mem_configure (title/description/subject), memstead_mem_set_schema (pin migration; also the quarantined-mem repair verb), memstead_mem_set_version (content semver). WORKSPACE POLICY — memstead_workspace_allow_create, memstead_workspace_revoke_create, memstead_workspace_allow_delete, memstead_workspace_revoke_delete, memstead_workspace_grant_cross_link, memstead_workspace_revoke_cross_link (edit the [mem_management]/[cross_mem_links] allowlists). CLI companion: the `memstead` CLI serves this same engine with verb families that deliberately live only there — bulk mutation in one commit (batch-create, batch-update, batch-relate: reach for these instead of looping single MCP mutation calls when writing many entities), archive export/install (export, install, uninstall), distribution/registry (publish, unpublish, login, logout, domain), workspace bootstrap and repair (init, mem-repo init, quickstart, recover, projection migrate, schema install), and read/report verbs (status, list, context, due — the due-brief: open entities whose schema-declared due date falls inside a window, overdue first). If a task feels like N repetitive single-entity calls, check the CLI first."
4393);
4394
4395#[tool_handler]
4396impl ServerHandler for McpServer {
4397    /// Hand-written so `instructions` can be the named
4398    /// [`SERVER_INSTRUCTIONS`] const (the macro only accepts string
4399    /// literals) and the serverInfo version is the engine's full
4400    /// build version (semver + git build sha for dev builds) by
4401    /// construction — the historical hardcoded `"0.1.0"` cannot
4402    /// recur, and two dev builds between releases stay
4403    /// distinguishable. The const keeps its compile-time semver line;
4404    /// a short runtime "Build:" sentence is appended only when a sha
4405    /// exists. Mirrors the shape `#[tool_handler]` would generate.
4406    fn get_info(&self) -> rmcp::model::ServerInfo {
4407        let full_version = memstead_base::build_info::full_version();
4408        let instructions = if memstead_base::build_info::BUILD_SHA.is_empty() {
4409            SERVER_INSTRUCTIONS.to_string()
4410        } else {
4411            format!("{SERVER_INSTRUCTIONS} Build: {full_version}.")
4412        };
4413        rmcp::model::ServerInfo::new(
4414            rmcp::model::ServerCapabilities::builder()
4415                .enable_tools()
4416                .build(),
4417        )
4418        .with_server_info(rmcp::model::Implementation::new("memstead", full_version))
4419        .with_instructions(instructions)
4420    }
4421
4422    /// Capture the client's `clientInfo` from the initialize handshake so
4423    /// every agent-initiated mutation can tag its commit with a
4424    /// `Client: <name>@<version>` trailer.
4425    ///
4426    /// Mirrors the default `ServerHandler::initialize` body (set peer info +
4427    /// return `get_info()`) and additionally stashes the client identity
4428    /// into `McpServer::client`. `OnceLock::set` returning `Err` would mean
4429    /// a second `initialize` arrived on the same server instance —
4430    /// impossible under the stdio transport (one process per client) but
4431    /// worth logging if the transport ever changes.
4432    async fn initialize(
4433        &self,
4434        request: InitializeRequestParams,
4435        context: RequestContext<RoleServer>,
4436    ) -> Result<InitializeResult, McpError> {
4437        let info = request.client_info.clone();
4438        let cid = ClientId {
4439            name: info.name.clone(),
4440            version: info.version.clone(),
4441        };
4442        if let Err(existing) = self.client.set(cid) {
4443            tracing::warn!(
4444                existing = ?existing,
4445                incoming_name = info.name.as_str(),
4446                incoming_version = info.version.as_str(),
4447                "second initialize received on memstead-mcp server — single-client assumption violated; keeping first client identity",
4448            );
4449        }
4450        if context.peer.peer_info().is_none() {
4451            context.peer.set_peer_info(request);
4452        }
4453        Ok(self.get_info())
4454    }
4455
4456    /// `list_tools` with the workspace's `disabled_tools` filter applied.
4457    /// Defining this method stops `#[tool_handler]` from generating the
4458    /// default body (see `has_method` gate in `rmcp-macros::tool_handler`).
4459    /// Behavior is byte-identical to the default when the filter is empty;
4460    /// otherwise matching tool records are omitted from the response.
4461    async fn list_tools(
4462        &self,
4463        _request: Option<PaginatedRequestParams>,
4464        _context: RequestContext<RoleServer>,
4465    ) -> Result<ListToolsResult, McpError> {
4466        Ok(ListToolsResult {
4467            tools: self.filtered_tool_list(),
4468            meta: None,
4469            next_cursor: None,
4470        })
4471    }
4472
4473    /// `get_tool` short-circuits on disabled names to `None` so rmcp's
4474    /// validation path treats a disabled tool as non-existent. Matches
4475    /// `list_tools` — a disabled tool is neither listed nor discoverable
4476    /// by name.
4477    fn get_tool(&self, name: &str) -> Option<Tool> {
4478        if self.disabled_tools.contains(name) {
4479            return None;
4480        }
4481        Self::tool_router().get(name).cloned()
4482    }
4483
4484    /// `call_tool` rejects disabled names with a `TOOL_DISABLED` envelope
4485    /// before dispatch. A client that kept a stale tool list or
4486    /// deliberately probes the bypass gets the same contract as the
4487    /// `list_tools` omission: this tool is not available here.
4488    ///
4489    /// This is also the friction ledger's one recording seam for the
4490    /// whole tool surface (agent-trust plan 08): every dispatched
4491    /// result that is a typed refusal appends one ledger entry whose
4492    /// values all come from closed engine-defined vocabularies (the
4493    /// module's privacy hard line — never parameters or payload text)
4494    /// — best-effort, after the response is already built, so
4495    /// recording can never perturb the refusal it measures.
4496    async fn call_tool(
4497        &self,
4498        request: CallToolRequestParams,
4499        context: RequestContext<RoleServer>,
4500    ) -> Result<CallToolResult, McpError> {
4501        let verb = request.name.to_string();
4502        if self.disabled_tools.contains(request.name.as_ref()) {
4503            let resp = self.tool_disabled_response(request.name.as_ref());
4504            self.record_friction(&verb, &resp);
4505            return Ok(resp);
4506        }
4507        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
4508        let result = Self::tool_router().call(tcc).await;
4509        if let Ok(r) = &result {
4510            self.record_friction(&verb, r);
4511        }
4512        result
4513    }
4514}
4515
4516impl McpServer {
4517    /// Append a friction-ledger entry when `result` is a typed refusal
4518    /// (`is_error` with a structured `code`). Untyped errors and
4519    /// successes append nothing; an unresolvable workspace root
4520    /// degrades to not-recording. Best-effort throughout — the
4521    /// response has already been built and is returned unchanged.
4522    fn record_friction(&self, verb: &str, result: &CallToolResult) {
4523        if !result.is_error.unwrap_or(false) {
4524            return;
4525        }
4526        let Some(code) = result
4527            .structured_content
4528            .as_ref()
4529            .and_then(|v| v.get("code"))
4530            .and_then(|c| c.as_str())
4531        else {
4532            return;
4533        };
4534        let root = match self.unified_engine().lock() {
4535            Ok(engine) => engine.workspace_root().map(|p| p.to_path_buf()),
4536            Err(_) => None,
4537        };
4538        if let Some(root) = root {
4539            let details = result
4540                .structured_content
4541                .as_ref()
4542                .and_then(|v| v.get("details"));
4543            memstead_base::friction::FrictionLedger::for_workspace(&root).record(
4544                "mcp",
4545                verb,
4546                code,
4547                memstead_base::friction::closed_reason(code, details),
4548            );
4549        }
4550    }
4551}
4552
4553#[cfg(test)]
4554mod tests {
4555    use super::*;
4556    use crate::tools::mutation::{PatchInput, RelateOpInput};
4557    use indexmap::IndexMap;
4558    use memstead_base::Query;
4559    use std::fs;
4560    use tempfile::TempDir;
4561
4562    /// Build the workspace directory layout used by every test in this
4563    /// module: a `specs` mem with two seed entities and an
4564    /// auto-seeded `mem-repo/.git/` so the git-branch backend factory
4565    /// has a real gitdir to point at.
4566    fn setup_test_workspace() -> TempDir {
4567        let tmp = TempDir::new().unwrap();
4568        // Dir basename must equal the declared `name` per the
4569        // basename-invariant. Router key matches too for consistency.
4570        let mem_dir = tmp.path().join("specs");
4571        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
4572
4573        fs::write(
4574            mem_dir.join(".memstead/config.json"),
4575            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
4576        )
4577        .unwrap();
4578
4579        fs::write(
4580            mem_dir.join("entity-a.md"),
4581            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\ntags: backend\n---\n# Entity A\n\n## Identity\n\nFirst test entity.\n\n## Purpose\n\nTesting the MCP server.\n\n## Relationships\n\n- **USES**: [[entity-b]]\n",
4582        )
4583        .unwrap();
4584
4585        fs::write(
4586            mem_dir.join("entity-b.md"),
4587            "---\ntype: spec\ncreated_date: 2026-02-01\nlast_modified: 2026-04-12\nlevel: M1\ntags: frontend\n---\n# Entity B\n\n## Identity\n\nSecond test entity.\n\n## Purpose\n\nDependency of Entity A.\n",
4588        )
4589        .unwrap();
4590
4591        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
4592
4593        tmp
4594    }
4595
4596    /// Build a default `McpServer` over the standard test workspace.
4597    /// Tests that want a customised engine (different settings, different
4598    /// backend variant) call `setup_test_workspace`, build the engine
4599    /// themselves, then `McpServer::new(engine, BUDGET)` directly.
4600    fn setup_test_engine() -> (McpServer, TempDir) {
4601        let tmp = setup_test_workspace();
4602        let engine = setup_unified_test_engine(tmp.path());
4603        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
4604        (server, tmp)
4605    }
4606
4607    /// Build a unified `memstead_base::Engine` from any disk-shaped mem
4608    /// directories under `workspace_root` — every subdir carrying
4609    /// `.memstead/config.json` becomes a folder-backend `Mount`.
4610    fn setup_unified_test_engine(workspace_root: &std::path::Path) -> memstead_base::Engine {
4611        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4612        let mut mounts: Vec<(Mount, Box<dyn memstead_base::backend::MemBackend>)> = Vec::new();
4613        for entry in std::fs::read_dir(workspace_root).unwrap().flatten() {
4614            let p = entry.path();
4615            if !p.is_dir() {
4616                continue;
4617            }
4618            // Skip the auto-seeded mem-repo gitdir — folder mounts
4619            // only.
4620            if p.file_name().and_then(|s| s.to_str()) == Some("mem-repo") {
4621                continue;
4622            }
4623            let cfg = p.join(".memstead").join("config.json");
4624            if !cfg.is_file() {
4625                continue;
4626            }
4627            let name = p.file_name().and_then(|s| s.to_str()).unwrap().to_string();
4628            let cfg_bytes = std::fs::read(&cfg).unwrap();
4629            let cfg_val: serde_json::Value =
4630                serde_json::from_slice(&cfg_bytes).unwrap_or(serde_json::Value::Null);
4631            let schema_str = cfg_val
4632                .get("schema")
4633                .and_then(|v| v.as_str())
4634                .unwrap_or("default@1.0.0")
4635                .to_string();
4636            let schema = Some(schema_str.parse().unwrap());
4637            let mount = Mount {
4638                mem: name,
4639                schema,
4640                storage: MountStorage::Folder { path: p.clone() },
4641                capability: MountCapability::Write,
4642                lifecycle: MountLifecycle::Eager,
4643                cross_linkable: true,
4644                migration_target: None,
4645            };
4646            let backend = memstead_base::instantiate_lean_backend(&mount).unwrap();
4647            mounts.push((mount, backend));
4648        }
4649        let mut engine = memstead_base::Engine::from_mounts(mounts).unwrap();
4650        // Install the full backend factory so
4651        // `mem_management::create_mem` can materialise
4652        // git-branch backends when its workspace-shape heuristic
4653        // fires. Test fixtures auto-seed a mem-repo
4654        // (`auto_seeded_settings`) so the heuristic always picks
4655        // `MountStorage::GitBranch` for runtime-created mems — the
4656        // lean default factory would reject with
4657        // `GitBranchRequiresMemRepoFeature`.
4658        engine.set_backend_factory(memstead_git_branch::storage::instantiate_full_backend);
4659        engine
4660    }
4661
4662    /// Build a unified `memstead_base::Engine` whose mounts use the
4663    /// git-branch backend rather than the folder backend.
4664    ///
4665    /// Test fixtures that call `auto_seeded_settings(tmp.path())`
4666    /// produce a `<tmp>/mem-repo/.git/` with a branch
4667    /// `refs/heads/<mem>` per disk mem. Mutations through this
4668    /// engine variant land as real commits on the mem-repo
4669    /// gitdir, producing 40-char hex `commit_sha` /
4670    /// `seed_commit_sha` values that lifecycle / commit-body tests
4671    /// assert on.
4672    fn setup_unified_test_engine_git_branch(
4673        workspace_root: &std::path::Path,
4674    ) -> memstead_base::Engine {
4675        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4676        // Canonicalise the gitdir so `engine.gitdir_for(name)` matches
4677        // full's canonical paths (TempDir on macOS returns a symlink
4678        // to /private/var/...; full canonicalizes at init).
4679        let gitdir = workspace_root.join("mem-repo").join(".git");
4680        let gitdir = gitdir.canonicalize().unwrap_or(gitdir);
4681        let mut mounts: Vec<(Mount, Box<dyn memstead_base::backend::MemBackend>)> = Vec::new();
4682        for entry in std::fs::read_dir(workspace_root).unwrap().flatten() {
4683            let p = entry.path();
4684            if !p.is_dir() {
4685                continue;
4686            }
4687            if p.file_name().and_then(|s| s.to_str()) == Some("mem-repo") {
4688                continue;
4689            }
4690            let cfg = p.join(".memstead").join("config.json");
4691            if !cfg.is_file() {
4692                continue;
4693            }
4694            let name = p.file_name().and_then(|s| s.to_str()).unwrap().to_string();
4695            let cfg_bytes = std::fs::read(&cfg).unwrap();
4696            let cfg_val: serde_json::Value =
4697                serde_json::from_slice(&cfg_bytes).unwrap_or(serde_json::Value::Null);
4698            let schema_str = cfg_val
4699                .get("schema")
4700                .and_then(|v| v.as_str())
4701                .unwrap_or("default@1.0.0")
4702                .to_string();
4703            let schema = Some(schema_str.parse().unwrap());
4704            let mount = Mount {
4705                migration_target: None,
4706                mem: name.clone(),
4707                schema,
4708                storage: MountStorage::GitBranch {
4709                    gitdir: gitdir.clone(),
4710                    branch: format!("refs/heads/{name}"),
4711                },
4712                capability: MountCapability::Write,
4713                lifecycle: MountLifecycle::Eager,
4714                cross_linkable: true,
4715            };
4716            let backend = memstead_git_branch::storage::instantiate_full_backend(&mount).unwrap();
4717            mounts.push((mount, backend));
4718        }
4719        let mut engine = memstead_base::Engine::from_mounts(mounts).unwrap();
4720        // Install the full backend factory so create_mem's
4721        // git-branch path can materialise a backend when the
4722        // workspace-shape heuristic fires.
4723        engine.set_backend_factory(memstead_git_branch::storage::instantiate_full_backend);
4724        engine
4725    }
4726
4727    /// Alias for [`setup_test_engine`]. Kept for callsite compatibility;
4728    /// pre-rebuild this materialised an additional full engine alongside
4729    /// the unified one.
4730    fn setup_dual_test_engine() -> (McpServer, TempDir) {
4731        setup_test_engine()
4732    }
4733
4734    /// Extract text from a CallToolResult.
4735    fn extract_text(result: &CallToolResult) -> String {
4736        result
4737            .content
4738            .first()
4739            .and_then(|c| c.as_text())
4740            .map(|t| t.text.clone())
4741            .unwrap_or_default()
4742    }
4743
4744    // ------------------------------------------------------------------
4745    // Test-only overview Markdown introspection. Parses the handful of
4746    // facts (frontmatter + headings) out of the Markdown body so
4747    // existing assertions translate 1:1. Intentionally shallow: we do
4748    // not try to round-trip every field, just the ones tests actually
4749    // assert on.
4750    // ------------------------------------------------------------------
4751
4752    /// Thin parsed view over the overview markdown body.
4753    struct ParsedOverview {
4754        text: String,
4755        frontmatter: std::collections::HashMap<String, String>,
4756    }
4757
4758    impl ParsedOverview {
4759        fn from(result: &CallToolResult) -> Self {
4760            let text = extract_text(result);
4761            let mut frontmatter = std::collections::HashMap::new();
4762            let mut in_fm = false;
4763            let mut seen_first = false;
4764            for line in text.lines() {
4765                if line == "---" {
4766                    if !seen_first {
4767                        seen_first = true;
4768                        in_fm = true;
4769                        continue;
4770                    } else if in_fm {
4771                        break;
4772                    }
4773                }
4774                if in_fm && let Some((k, v)) = line.split_once(':') {
4775                    frontmatter.insert(k.trim().to_string(), v.trim().to_string());
4776                }
4777            }
4778            Self { text, frontmatter }
4779        }
4780
4781        fn overview_mode(&self) -> &str {
4782            self.frontmatter
4783                .get("_overview_mode")
4784                .map(String::as_str)
4785                .unwrap_or("")
4786        }
4787
4788        fn budget_used(&self) -> u64 {
4789            self.frontmatter
4790                .get("_budget_used")
4791                .and_then(|v| v.parse().ok())
4792                .unwrap_or(0)
4793        }
4794
4795        fn budget_requested(&self) -> u64 {
4796            self.frontmatter
4797                .get("_budget_requested")
4798                .and_then(|v| v.parse().ok())
4799                .unwrap_or(0)
4800        }
4801
4802        /// Schema refs from the `## Schemas` block — each `### <ref>` heading.
4803        fn schema_refs(&self) -> Vec<String> {
4804            self.section_h3("## Schemas", "## ")
4805                .iter()
4806                .map(|l| l.trim_start_matches("### ").trim().to_string())
4807                .collect()
4808        }
4809
4810        /// Mem names from the `## Mems` block — each `### <name>` heading.
4811        fn mem_names(&self) -> Vec<String> {
4812            self.section_h3("## Mems", "## ")
4813                .iter()
4814                .map(|l| l.trim_start_matches("### ").trim().to_string())
4815                .collect()
4816        }
4817
4818        /// Hint keys from the `## Hints` block — each `- `<key>` — ...` line.
4819        fn hint_keys(&self) -> Vec<String> {
4820            let block = self.section_lines("## Hints", "## ");
4821            let mut keys = Vec::new();
4822            for line in block {
4823                let l = line.trim();
4824                if let Some(rest) = l.strip_prefix("- `")
4825                    && let Some((key, _)) = rest.split_once('`')
4826                {
4827                    keys.push(key.to_string());
4828                }
4829            }
4830            keys
4831        }
4832
4833        /// Warning codes from the `## Warnings` block — each
4834        /// `- **CODE** — message` line.
4835        fn warning_codes(&self) -> Vec<String> {
4836            let block = self.section_lines("## Warnings", "## ");
4837            let mut codes = Vec::new();
4838            for line in block {
4839                let l = line.trim();
4840                if let Some(rest) = l.strip_prefix("- **")
4841                    && let Some((code, _)) = rest.split_once("**")
4842                {
4843                    codes.push(code.to_string());
4844                }
4845            }
4846            codes
4847        }
4848
4849        /// Community-bridges heading block — each `### from ↔ to (N edges)`.
4850        fn bridge_headings(&self) -> Vec<String> {
4851            self.section_h3("## Community Bridges", "## ")
4852                .iter()
4853                .map(|s| s.trim_start_matches("### ").to_string())
4854                .collect()
4855        }
4856
4857        fn section_lines(&self, start: &str, end_prefix: &str) -> Vec<&str> {
4858            let mut out = Vec::new();
4859            let mut in_block = false;
4860            for line in self.text.lines() {
4861                if line.starts_with(start) {
4862                    in_block = true;
4863                    continue;
4864                }
4865                if in_block && line.starts_with(end_prefix) && !line.starts_with("### ") {
4866                    break;
4867                }
4868                if in_block {
4869                    out.push(line);
4870                }
4871            }
4872            out
4873        }
4874
4875        fn section_h3(&self, start: &str, end_prefix: &str) -> Vec<&str> {
4876            self.section_lines(start, end_prefix)
4877                .into_iter()
4878                .filter(|l| l.starts_with("### "))
4879                .collect()
4880        }
4881    }
4882
4883    // ------------------------------------------------------------------
4884    // Regression guards for the read/error contract. The contract for
4885    // `memstead_entity` + `memstead_search` is:
4886    //   entity/search success = Markdown on text + structured envelope
4887    //                            on `structured_content`
4888    //   other read success    = pure Markdown on the text channel
4889    //   error / warning       = `structured_content` with `{code,
4890    //                            message, details}`
4891    // ------------------------------------------------------------------
4892
4893    #[test]
4894    fn entity_and_search_emit_structured_envelope_on_success() {
4895        let (server, _tmp) = setup_dual_test_engine();
4896
4897        // memstead_entity — every combination must populate
4898        // structured_content with the typed Entity envelope.
4899        for (relations, context) in [(false, false), (true, false), (false, true), (true, true)] {
4900            if context {
4901                let _ = server.memstead_overview(Parameters(OverviewParams {
4902                    rebuild: Some(true),
4903                    chunk: None,
4904                    mem: None,
4905                    include: None,
4906                    token_budget: None,
4907                }));
4908            }
4909            let r = server.memstead_entity(Parameters(EntityParams {
4910                id: "specs--entity-a".to_string(),
4911                include_relations: Some(relations),
4912                include_context: Some(context),
4913                sections: None,
4914                token_budget: None,
4915                chunk: None,
4916                include_provenance: None,
4917            }));
4918            let sc = r.structured_content.as_ref().unwrap_or_else(|| {
4919                panic!(
4920                    "memstead_entity(relations={relations}, context={context}) must populate structured_content"
4921                )
4922            });
4923            assert!(
4924                sc.get("_hash").and_then(|v| v.as_str()).is_some(),
4925                "entity structured_content must carry `_hash`",
4926            );
4927            assert!(
4928                sc.get("sections").and_then(|v| v.as_object()).is_some(),
4929                "entity structured_content must carry `sections` object",
4930            );
4931        }
4932
4933        // memstead_search — text query and filter-only must each carry
4934        // the structured `SearchResultEnvelope`.
4935        for params in [
4936            SearchParams {
4937                query: Some(Query {
4938                    any: vec!["Entity".into()],
4939                    ..Default::default()
4940                }),
4941                ..search_params_defaults()
4942            },
4943            SearchParams {
4944                query: None,
4945                entity_type: Some("spec".to_string()),
4946                ..search_params_defaults()
4947            },
4948        ] {
4949            let r = server.memstead_search(Parameters(params));
4950            let sc = r
4951                .structured_content
4952                .as_ref()
4953                .expect("memstead_search must populate structured_content");
4954            assert!(
4955                sc.get("_total").and_then(|v| v.as_u64()).is_some(),
4956                "search structured_content must carry `_total`",
4957            );
4958            assert!(
4959                sc.get("hits").and_then(|v| v.as_array()).is_some(),
4960                "search structured_content must carry `hits[]`",
4961            );
4962        }
4963    }
4964
4965    #[test]
4966    fn other_read_tools_emit_no_structured_content_on_success() {
4967        let (server, _tmp) = setup_dual_test_engine();
4968
4969        // memstead_overview — default, with include, with mem filter.
4970        for (include, mem) in [
4971            (None, None),
4972            (Some(vec!["community_members".to_string()]), None),
4973            (None, Some("specs".to_string())),
4974        ] {
4975            let r = server.memstead_overview(Parameters(OverviewParams {
4976                rebuild: Some(true),
4977                chunk: None,
4978                mem,
4979                include,
4980                token_budget: None,
4981            }));
4982            assert!(
4983                r.structured_content.is_none(),
4984                "memstead_overview must not emit structured_content on success; got {:?}",
4985                r.structured_content
4986            );
4987        }
4988    }
4989
4990    fn search_params_defaults() -> SearchParams {
4991        SearchParams {
4992            query: None,
4993            direction: None,
4994            mem: None,
4995            entity_type: None,
4996            expand_via: None,
4997            expand_depth: None,
4998            related_to: None,
4999            depth: None,
5000            edge_type: None,
5001            limit: None,
5002            offset: None,
5003            filters: None,
5004            range_filters: None,
5005            stub: None,
5006            token_budget: None,
5007        }
5008    }
5009
5010    #[test]
5011    fn error_envelopes_still_emit_structured_content() {
5012        // Exercises the unified dispatcher's error-envelope contract
5013        // (HASH_MISMATCH details.current and the
5014        // STUB_FILTER_EXCLUDES_ALL warning code).
5015        let (server, _tmp) = setup_dual_test_engine();
5016
5017        // HASH_MISMATCH via memstead_update with a stale hash.
5018        let stale = server.memstead_update(Parameters(crate::tools::mutation::UpdateParams {
5019            anchors: None,
5020            relations_unset: None,
5021            anchors_unset: None,
5022            id: "specs--entity-a".to_string(),
5023            expected_hash: "0000000000000000000000000000000000000000000000000000000000000000"
5024                .to_string(),
5025            sections: None,
5026            append_sections: None,
5027            patch_sections: None,
5028            metadata: None,
5029            metadata_unset: None,
5030            dry_run: Some(false),
5031
5032            note: None,
5033
5034            role: None,
5035            declare_relations: None,
5036        }));
5037        assert!(stale.is_error.unwrap_or(false), "stale hash must error");
5038        let sc = stale
5039            .structured_content
5040            .as_ref()
5041            .expect("HASH_MISMATCH must carry structured_content");
5042        assert_eq!(sc["code"], "HASH_MISMATCH");
5043        assert!(
5044            sc["details"]["current"].is_string(),
5045            "HASH_MISMATCH must carry details.current; got {sc:?}"
5046        );
5047
5048        // STUB_FILTER_EXCLUDES_ALL via memstead_search with stub=true + entity_type.
5049        let stub_excl = server.memstead_search(Parameters(SearchParams {
5050            query: None,
5051            entity_type: Some("spec".to_string()),
5052            stub: Some(true),
5053            token_budget: None,
5054            ..search_params_defaults()
5055        }));
5056        // This path surfaces via a warnings entry in the list, not an error
5057        // envelope — post-removal, warnings live in the `## Filter warnings`
5058        // Markdown block for search. The test asserts that the stable code
5059        // is still named somewhere on the wire.
5060        let body = extract_text(&stub_excl);
5061        assert!(
5062            body.contains("STUB_FILTER_EXCLUDES_ALL"),
5063            "STUB_FILTER_EXCLUDES_ALL code must surface on the wire; got:\n{body}"
5064        );
5065    }
5066
5067    /// #57: a health call whose rendered report exceeds `token_budget`
5068    /// switches the text channel to chunkable markdown (with chunk-walk
5069    /// frontmatter) while `structured_content` still ships whole.
5070    #[test]
5071    fn health_over_budget_text_is_chunked_markdown() {
5072        let (server, _tmp) = setup_dual_test_engine();
5073        // A tiny budget forces the markdown+chunk path even for a small graph.
5074        let result = server.memstead_health(Parameters(HealthParams {
5075            include: None,
5076            limit: None,
5077            mem: None,
5078            include_config: false,
5079            token_budget: Some(5),
5080            chunk: None,
5081            target_schema: None,
5082        }));
5083        // structured_content is whole regardless of chunking.
5084        let sc = result.structured_content.clone().unwrap();
5085        assert!(
5086            sc.get("summary").is_some(),
5087            "structured payload ships whole"
5088        );
5089        let text = extract_text(&result);
5090        assert!(
5091            text.contains("# Graph health"),
5092            "text is the markdown report: {text}"
5093        );
5094        assert!(
5095            text.contains("_total_chunks") || text.contains("_chunk"),
5096            "over-budget text carries chunk-walk frontmatter: {text}"
5097        );
5098        // The markdown text is no longer parseable as JSON.
5099        assert!(serde_json::from_str::<serde_json::Value>(&text).is_err());
5100    }
5101
5102    /// Durability honesty (Plan 02, Part A) refusal complement: a
5103    /// folder-backed (durable on disk) mem reports `durable: true` /
5104    /// `storage: folder` in the `include_config` mem detail — the marker
5105    /// reflects the real backend, so a durable mem never reads ephemeral.
5106    #[test]
5107    fn folder_mem_health_detail_reports_durable() {
5108        let (server, _tmp) = setup_test_engine();
5109        let result = server.memstead_health(Parameters(HealthParams {
5110            include: None,
5111            limit: None,
5112            mem: None,
5113            include_config: true,
5114            token_budget: None,
5115            chunk: None,
5116            target_schema: None,
5117        }));
5118        let sc = result.structured_content.clone().unwrap();
5119        let mems = sc["mems"]
5120            .as_array()
5121            .expect("include_config carries the per-mem detail array");
5122        assert!(!mems.is_empty(), "at least one writable folder mem");
5123        for v in mems {
5124            assert_eq!(v["durable"], true, "folder mem must report durable: {v}");
5125            assert_eq!(v["storage"], "folder", "folder mem storage kind: {v}");
5126        }
5127    }
5128
5129    /// #57 refusal: a default-budget call keeps the text channel as
5130    /// parseable JSON — byte-identical behavior for the common small call.
5131    #[test]
5132    fn health_default_budget_text_stays_json() {
5133        let (server, _tmp) = setup_dual_test_engine();
5134        let result = server.memstead_health(Parameters(HealthParams {
5135            include: None,
5136            limit: None,
5137            mem: None,
5138            include_config: false,
5139            token_budget: None,
5140            chunk: None,
5141            target_schema: None,
5142        }));
5143        let text = extract_text(&result);
5144        assert!(
5145            serde_json::from_str::<serde_json::Value>(&text).is_ok(),
5146            "default-budget text stays JSON"
5147        );
5148    }
5149
5150    #[test]
5151    fn health_default_contains_legacy_stats_fields() {
5152        // `memstead_stats` is folded into `memstead_health`'s default output —
5153        // stats-era fields sit as top-level siblings of `summary`.
5154        let (server, _tmp) = setup_dual_test_engine();
5155        let result = server.memstead_health(Parameters(HealthParams {
5156            include: None,
5157            limit: None,
5158            mem: None,
5159            include_config: false,
5160            token_budget: None,
5161            chunk: None,
5162            target_schema: None,
5163        }));
5164        let text = extract_text(&result);
5165        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
5166        // Stats-era fields sit as top-level siblings of `summary`.
5167        assert!(json["total_nodes"].as_u64().unwrap() >= 2);
5168        assert!(json["real_nodes"].as_u64().unwrap() >= 2);
5169        assert!(json["stub_nodes"].is_number());
5170        assert!(json["total_edges"].as_u64().unwrap() >= 1);
5171        assert!(json["edge_types"].is_array());
5172        assert!(json["type_distribution"].is_array());
5173        assert!(json["writable_mems"].is_array());
5174        assert!(json["read_mems"].is_array());
5175        assert!(json["mem_schemas"].is_array());
5176    }
5177
5178    #[test]
5179    fn health_default_contains_existing_summary_fields() {
5180        // Absorbing stats must not regress the pre-existing `summary` object.
5181        let (server, _tmp) = setup_dual_test_engine();
5182        let result = server.memstead_health(Parameters(HealthParams {
5183            include: None,
5184            limit: None,
5185            mem: None,
5186            include_config: false,
5187            token_budget: None,
5188            chunk: None,
5189            target_schema: None,
5190        }));
5191        let text = extract_text(&result);
5192        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
5193        let summary = json["summary"]
5194            .as_object()
5195            .expect("summary object still present");
5196        assert!(summary["total_entities"].is_number());
5197        assert!(summary["total_orphans"].is_number());
5198        assert!(summary["total_stubs"].is_number());
5199        assert!(summary["total_stale"].is_number());
5200        assert!(summary["total_missing_fields"].is_number());
5201        assert!(summary["total_communities"].is_number());
5202    }
5203
5204    #[test]
5205    fn health_never_surfaces_lifecycle_policy_fields() {
5206        // Lifecycle policy lives on `memstead_overview` — `memstead_health`
5207        // is drift / diagnostics, not "what can I do here." The two
5208        // legacy field names must not appear on either the default
5209        // or the `include_config=true` response.
5210        let (server, _tmp) = setup_dual_test_engine();
5211        for include_config in [false, true] {
5212            let result = server.memstead_health(Parameters(HealthParams {
5213                include: None,
5214                limit: None,
5215                mem: None,
5216                include_config,
5217                target_schema: None,
5218                token_budget: None,
5219                chunk: None,
5220            }));
5221            // #57: typed payload from structured_content (text is markdown).
5222            let json: serde_json::Value = result.structured_content.clone().unwrap();
5223            assert!(
5224                json.get("allowed_create_patterns").is_none(),
5225                "allowed_create_patterns must never surface on memstead_health (include_config={include_config})"
5226            );
5227            assert!(
5228                json.get("allowed_delete_patterns").is_none(),
5229                "allowed_delete_patterns must never surface on memstead_health (include_config={include_config})"
5230            );
5231        }
5232    }
5233
5234    /// F6: under `memstead_health(mem=B)`,
5235    /// `most_connected` degrees count only source-in-mem edges, matching
5236    /// the same response's `total_edges`/`edge_types`. A cross-mem edge
5237    /// `A→B` is excluded from B's scoped aggregate, so it must also be
5238    /// excluded from B's node degree — pre-fix the per-node degree reused
5239    /// the global adjacency and reported a degree that included an edge the
5240    /// same scoped response did not count.
5241    #[test]
5242    fn health_scoped_most_connected_degrees_exclude_cross_mem_incoming() {
5243        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5244
5245        let tmp = TempDir::new().unwrap();
5246        let a_dir = tmp.path().join("specs");
5247        let b_dir = tmp.path().join("memos");
5248        std::fs::create_dir_all(&a_dir).unwrap();
5249        std::fs::create_dir_all(&b_dir).unwrap();
5250        let mk_mount = |mem: &str, path: std::path::PathBuf| Mount {
5251            mem: mem.to_string(),
5252            schema: Some("default@1.0.0".parse().unwrap()),
5253            storage: MountStorage::Folder { path },
5254            capability: MountCapability::Write,
5255            lifecycle: MountLifecycle::Eager,
5256            cross_linkable: true,
5257            migration_target: None,
5258        };
5259        let a_writer = memstead_base::storage::FilesystemMemWriter::new(a_dir.clone());
5260        let b_writer = memstead_base::storage::FilesystemMemWriter::new(b_dir.clone());
5261        let mut engine = memstead_base::Engine::from_mounts(vec![
5262            (
5263                mk_mount("specs", a_dir),
5264                Box::new(a_writer) as Box<dyn memstead_base::backend::MemBackend>,
5265            ),
5266            (
5267                mk_mount("memos", b_dir),
5268                Box::new(b_writer) as Box<dyn memstead_base::backend::MemBackend>,
5269            ),
5270        ])
5271        .unwrap();
5272        // Grant specs → memos so the cross-mem relate lands.
5273        let mut cross_links = std::collections::BTreeMap::new();
5274        cross_links.insert(
5275            "specs".to_string(),
5276            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
5277        );
5278        engine.set_settings(memstead_base::WorkspaceSettings {
5279            cross_mem_links: cross_links,
5280            ..Default::default()
5281        });
5282        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
5283
5284        let mk = |mem: &str, title: &str| {
5285            let mut sections = indexmap::IndexMap::new();
5286            sections.insert("identity".to_string(), "the identity".to_string());
5287            sections.insert("purpose".to_string(), "the purpose".to_string());
5288            let r = server.memstead_create(Parameters(CreateParams {
5289                anchors: None,
5290                mem: Some(mem.to_string()),
5291                title: title.to_string(),
5292                entity_type: "spec".to_string(),
5293                sections: Some(sections),
5294                metadata: None,
5295                relations: None,
5296                dry_run: None,
5297                note: None,
5298                role: None,
5299            }));
5300            assert!(
5301                !r.is_error.unwrap_or(false),
5302                "create {title}: {}",
5303                extract_text(&r)
5304            );
5305        };
5306        mk("specs", "Source");
5307        mk("memos", "Target");
5308        mk("memos", "Hub");
5309
5310        let relate = |from: &str, to: &str| {
5311            let r = server.memstead_relate(Parameters(RelateParams {
5312                relations: vec![RelateOpInput {
5313                    from: from.to_string(),
5314                    to: to.to_string(),
5315                    r#type: "USES".to_string(),
5316                    remove: None,
5317                    description: None,
5318                }],
5319                note: None,
5320                role: None,
5321                dry_run: None,
5322            }));
5323            assert!(
5324                !r.is_error.unwrap_or(false),
5325                "relate {from}->{to}: {}",
5326                extract_text(&r)
5327            );
5328        };
5329        // Intra-mem incoming (source in memos → counted) and a
5330        // cross-mem incoming (source in specs → excluded under mem=memos).
5331        relate("memos--hub", "memos--target");
5332        relate("specs--source", "memos--target");
5333
5334        // Global view: target has both incoming edges (degree 2).
5335        let global = server.memstead_health(Parameters(HealthParams {
5336            include: Some(vec!["most_connected".to_string()]),
5337            limit: None,
5338            mem: None,
5339            include_config: false,
5340            token_budget: None,
5341            chunk: None,
5342            target_schema: None,
5343        }));
5344        let gjson: serde_json::Value = serde_json::from_str(&extract_text(&global)).unwrap();
5345        let g_target = gjson["most_connected"]
5346            .as_array()
5347            .unwrap()
5348            .iter()
5349            .find(|e| e["id"] == "memos--target")
5350            .expect("target in global most_connected");
5351        assert_eq!(
5352            g_target["incoming"].as_u64(),
5353            Some(2),
5354            "global degree counts both edges"
5355        );
5356
5357        // Scoped to memos: the cross-mem incoming from specs is excluded
5358        // from both the aggregate and the node degree.
5359        let scoped = server.memstead_health(Parameters(HealthParams {
5360            include: Some(vec!["most_connected".to_string()]),
5361            limit: None,
5362            mem: Some("memos".to_string()),
5363            include_config: false,
5364            token_budget: None,
5365            chunk: None,
5366            target_schema: None,
5367        }));
5368        let sjson: serde_json::Value = serde_json::from_str(&extract_text(&scoped)).unwrap();
5369        let s_target = sjson["most_connected"]
5370            .as_array()
5371            .unwrap()
5372            .iter()
5373            .find(|e| e["id"] == "memos--target")
5374            .expect("target in scoped most_connected");
5375        assert_eq!(
5376            s_target["incoming"].as_u64(),
5377            Some(1),
5378            "scoped degree must exclude the cross-mem incoming edge: {s_target}",
5379        );
5380        assert_eq!(s_target["outgoing"].as_u64(), Some(0));
5381        assert_eq!(s_target["total"].as_u64(), Some(1));
5382        // The scoped aggregate counts only the one source-in-memos edge
5383        // (hub→target); the cross-mem USES is source-in-specs, excluded —
5384        // so the node's degree and the aggregate now describe one subgraph.
5385        assert_eq!(
5386            sjson["total_edges"].as_u64(),
5387            Some(1),
5388            "scoped total_edges counts only source-in-memos edges: {}",
5389            sjson["total_edges"],
5390        );
5391    }
5392
5393    /// Scoped `memstead_health` reports a community count that reflects the
5394    /// mem: an empty mem → 0 communities (consistent with its 0
5395    /// entities, no "0 entities / N communities" contradiction), and a
5396    /// non-empty mem → exactly the clusters with ≥1 member in it. The
5397    /// global (unscoped) count is unchanged. Filters the global
5398    /// partition — no per-mem detection.
5399    #[test]
5400    fn health_scoped_community_count_reflects_mem() {
5401        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5402
5403        let tmp = TempDir::new().unwrap();
5404        let specs_dir = tmp.path().join("specs");
5405        let memos_dir = tmp.path().join("memos");
5406        let scratch_dir = tmp.path().join("scratch");
5407        for d in [&specs_dir, &memos_dir, &scratch_dir] {
5408            std::fs::create_dir_all(d).unwrap();
5409        }
5410        let mk_mount = |mem: &str, path: std::path::PathBuf| Mount {
5411            mem: mem.to_string(),
5412            schema: Some("default@1.0.0".parse().unwrap()),
5413            storage: MountStorage::Folder { path },
5414            capability: MountCapability::Write,
5415            lifecycle: MountLifecycle::Eager,
5416            cross_linkable: true,
5417            migration_target: None,
5418        };
5419        let mut engine = memstead_base::Engine::from_mounts(vec![
5420            (
5421                mk_mount("specs", specs_dir.clone()),
5422                Box::new(memstead_base::storage::FilesystemMemWriter::new(specs_dir))
5423                    as Box<dyn memstead_base::backend::MemBackend>,
5424            ),
5425            (
5426                mk_mount("memos", memos_dir.clone()),
5427                Box::new(memstead_base::storage::FilesystemMemWriter::new(memos_dir))
5428                    as Box<dyn memstead_base::backend::MemBackend>,
5429            ),
5430            (
5431                mk_mount("scratch", scratch_dir.clone()),
5432                Box::new(memstead_base::storage::FilesystemMemWriter::new(
5433                    scratch_dir,
5434                )) as Box<dyn memstead_base::backend::MemBackend>,
5435            ),
5436        ])
5437        .unwrap();
5438        let _ = &mut engine;
5439        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
5440
5441        let mk = |mem: &str, title: &str| {
5442            let mut sections = indexmap::IndexMap::new();
5443            sections.insert("identity".to_string(), "the identity".to_string());
5444            sections.insert("purpose".to_string(), "the purpose".to_string());
5445            let r = server.memstead_create(Parameters(CreateParams {
5446                anchors: None,
5447                mem: Some(mem.to_string()),
5448                title: title.to_string(),
5449                entity_type: "spec".to_string(),
5450                sections: Some(sections),
5451                metadata: None,
5452                relations: None,
5453                dry_run: None,
5454                note: None,
5455                role: None,
5456            }));
5457            assert!(
5458                !r.is_error.unwrap_or(false),
5459                "create {title}: {}",
5460                extract_text(&r)
5461            );
5462        };
5463        let relate = |from: &str, to: &str| {
5464            let r = server.memstead_relate(Parameters(RelateParams {
5465                relations: vec![RelateOpInput {
5466                    from: from.to_string(),
5467                    to: to.to_string(),
5468                    r#type: "USES".to_string(),
5469                    remove: None,
5470                    description: None,
5471                }],
5472                note: None,
5473                role: None,
5474                dry_run: None,
5475            }));
5476            assert!(
5477                !r.is_error.unwrap_or(false),
5478                "relate {from}->{to}: {}",
5479                extract_text(&r)
5480            );
5481        };
5482        // Two disconnected intra-mem edges → two clusters, one wholly
5483        // in `specs`, one wholly in `memos`. `scratch` stays empty.
5484        mk("specs", "A1");
5485        mk("specs", "A2");
5486        relate("specs--a1", "specs--a2");
5487        mk("memos", "B1");
5488        mk("memos", "B2");
5489        relate("memos--b1", "memos--b2");
5490
5491        let total_communities = |mem: Option<&str>| -> u64 {
5492            let r = server.memstead_health(Parameters(HealthParams {
5493                include: None,
5494                limit: None,
5495                mem: mem.map(String::from),
5496                include_config: false,
5497                token_budget: None,
5498                chunk: None,
5499                target_schema: None,
5500            }));
5501            let j: serde_json::Value = serde_json::from_str(&extract_text(&r)).unwrap();
5502            j["summary"]["total_communities"].as_u64().unwrap()
5503        };
5504        let total_entities = |mem: Option<&str>| -> u64 {
5505            let r = server.memstead_health(Parameters(HealthParams {
5506                include: None,
5507                limit: None,
5508                mem: mem.map(String::from),
5509                include_config: false,
5510                token_budget: None,
5511                chunk: None,
5512                target_schema: None,
5513            }));
5514            let j: serde_json::Value = serde_json::from_str(&extract_text(&r)).unwrap();
5515            j["summary"]["total_entities"].as_u64().unwrap()
5516        };
5517
5518        // Global: both clusters, all four entities.
5519        assert_eq!(total_communities(None), 2, "global community count");
5520        assert_eq!(total_entities(None), 4, "global entity count");
5521
5522        // Empty mem: 0 entities, 0
5523        // communities (was 0 entities / 2 communities pre-fix).
5524        assert_eq!(total_entities(Some("scratch")), 0);
5525        assert_eq!(
5526            total_communities(Some("scratch")),
5527            0,
5528            "empty mem must report 0 communities, not the global count",
5529        );
5530
5531        // Non-empty scope: exactly the one cluster whose members live in
5532        // the mem.
5533        assert_eq!(total_entities(Some("specs")), 2);
5534        assert_eq!(
5535            total_communities(Some("specs")),
5536            1,
5537            "specs touches one cluster"
5538        );
5539        assert_eq!(total_entities(Some("memos")), 2);
5540        assert_eq!(
5541            total_communities(Some("memos")),
5542            1,
5543            "memos touches one cluster"
5544        );
5545    }
5546
5547    /// Scoped `memstead_overview` frontmatter reflects the mem: an empty
5548    /// mem reports `_entity_count: 0` / `_cluster_count: 0` and a
5549    /// "no communities" `## Communities` section — consistent with its
5550    /// `## Mems` roster (was `_entity_count: N` / global clusters
5551    /// pre-fix). A non-empty scope reports the mem's own count and only
5552    /// its clusters. Global (unscoped) overview is unchanged. Mirrors the
5553    /// health fix via the shared `clusters_in_mem` helper.
5554    #[test]
5555    fn overview_scoped_entity_and_community_count_reflect_mem() {
5556        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5557
5558        let tmp = TempDir::new().unwrap();
5559        let specs_dir = tmp.path().join("specs");
5560        let scratch_dir = tmp.path().join("scratch");
5561        for d in [&specs_dir, &scratch_dir] {
5562            std::fs::create_dir_all(d).unwrap();
5563        }
5564        let mk_mount = |mem: &str, path: std::path::PathBuf| Mount {
5565            mem: mem.to_string(),
5566            schema: Some("default@1.0.0".parse().unwrap()),
5567            storage: MountStorage::Folder { path },
5568            capability: MountCapability::Write,
5569            lifecycle: MountLifecycle::Eager,
5570            cross_linkable: true,
5571            migration_target: None,
5572        };
5573        let engine = memstead_base::Engine::from_mounts(vec![
5574            (
5575                mk_mount("specs", specs_dir.clone()),
5576                Box::new(memstead_base::storage::FilesystemMemWriter::new(specs_dir))
5577                    as Box<dyn memstead_base::backend::MemBackend>,
5578            ),
5579            (
5580                mk_mount("scratch", scratch_dir.clone()),
5581                Box::new(memstead_base::storage::FilesystemMemWriter::new(
5582                    scratch_dir,
5583                )) as Box<dyn memstead_base::backend::MemBackend>,
5584            ),
5585        ])
5586        .unwrap();
5587        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
5588
5589        let mk = |title: &str| {
5590            let mut sections = indexmap::IndexMap::new();
5591            sections.insert("identity".to_string(), "the identity".to_string());
5592            sections.insert("purpose".to_string(), "the purpose".to_string());
5593            let r = server.memstead_create(Parameters(CreateParams {
5594                anchors: None,
5595                mem: Some("specs".to_string()),
5596                title: title.to_string(),
5597                entity_type: "spec".to_string(),
5598                sections: Some(sections),
5599                metadata: None,
5600                relations: None,
5601                dry_run: None,
5602                note: None,
5603                role: None,
5604            }));
5605            assert!(
5606                !r.is_error.unwrap_or(false),
5607                "create {title}: {}",
5608                extract_text(&r)
5609            );
5610        };
5611        mk("A1");
5612        mk("A2");
5613        let r = server.memstead_relate(Parameters(RelateParams {
5614            relations: vec![RelateOpInput {
5615                from: "specs--a1".to_string(),
5616                to: "specs--a2".to_string(),
5617                r#type: "USES".to_string(),
5618                remove: None,
5619                description: None,
5620            }],
5621            note: None,
5622            role: None,
5623            dry_run: None,
5624        }));
5625        assert!(!r.is_error.unwrap_or(false), "relate: {}", extract_text(&r));
5626
5627        let overview = |mem: Option<&str>| -> String {
5628            extract_text(&server.memstead_overview(Parameters(OverviewParams {
5629                rebuild: None,
5630                chunk: None,
5631                mem: mem.map(String::from),
5632                include: None,
5633                token_budget: None,
5634            })))
5635        };
5636
5637        // Global: one cluster, two entities.
5638        let global = overview(None);
5639        assert!(
5640            global.contains("_entity_count: 2"),
5641            "global entity count: {global}"
5642        );
5643        assert!(
5644            global.contains("_cluster_count: 1"),
5645            "global cluster count: {global}"
5646        );
5647
5648        // Empty mem: reconcilable summary — 0 entities, 0 clusters, no
5649        // communities listed.
5650        let empty = overview(Some("scratch"));
5651        assert!(
5652            empty.contains("_entity_count: 0"),
5653            "empty-mem scope must report 0 entities: {empty}",
5654        );
5655        assert!(
5656            empty.contains("_cluster_count: 0"),
5657            "empty-mem scope must report 0 clusters, not the global count: {empty}",
5658        );
5659        assert!(
5660            empty.contains("_(no communities"),
5661            "empty-mem scope must render the no-communities section: {empty}",
5662        );
5663
5664        // Non-empty scope: the mem's own count and its one cluster.
5665        let scoped = overview(Some("specs"));
5666        assert!(
5667            scoped.contains("_entity_count: 2"),
5668            "specs scope entity count: {scoped}"
5669        );
5670        assert!(
5671            scoped.contains("_cluster_count: 1"),
5672            "specs scope cluster count: {scoped}"
5673        );
5674    }
5675
5676    /// `include=missing_required_outgoing`
5677    /// is a recognised include key (not `UNKNOWN_INCLUDE_KEY`-rejected)
5678    /// and surfaces an array on the response. The default-schema
5679    /// fixture declares no `required_outgoing`, so the array is empty —
5680    /// the test pins the wire-shape contract, not a violator count.
5681    #[test]
5682    fn health_missing_required_outgoing_include_key_is_recognised() {
5683        let (server, _tmp) = setup_dual_test_engine();
5684        let result = server.memstead_health(Parameters(HealthParams {
5685            include: Some(vec!["missing_required_outgoing".to_string()]),
5686            limit: None,
5687            mem: None,
5688            include_config: false,
5689            token_budget: None,
5690            chunk: None,
5691            target_schema: None,
5692        }));
5693        let text = extract_text(&result);
5694        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
5695        let arr = json
5696            .get("missing_required_outgoing")
5697            .and_then(|v| v.as_array())
5698            .expect("missing_required_outgoing must surface as array under the include key");
5699        assert!(
5700            arr.is_empty(),
5701            "default schema declares no required_outgoing — array must be empty; got {arr:?}"
5702        );
5703        // Must NOT carry an UNKNOWN_INCLUDE_KEY warning for the key.
5704        let warnings = json
5705            .get("warnings")
5706            .and_then(|v| v.as_array())
5707            .cloned()
5708            .unwrap_or_default();
5709        let has_unknown = warnings.iter().any(|w| {
5710            w.get("code").and_then(|c| c.as_str()) == Some("UNKNOWN_INCLUDE_KEY")
5711                && w.get("details")
5712                    .and_then(|d| d.get("key"))
5713                    .and_then(|k| k.as_str())
5714                    == Some("missing_required_outgoing")
5715        });
5716        assert!(
5717            !has_unknown,
5718            "include key must be on the allowlist; got warnings: {warnings:?}"
5719        );
5720    }
5721
5722    /// `include=["conformance"]` is a
5723    /// recognised key and surfaces a `findings` array in the pinned
5724    /// `{id, axis, code, detail}` shape. The standard fixture's
5725    /// entities conform to the pin, so the array's conformance
5726    /// entries are keyed to whatever genuinely fails — the shape, not
5727    /// a violator count, is the contract here.
5728    /// The `config` include key and the `include_config: true` boolean
5729    /// are aliases: each renders the identical projection, passing both
5730    /// renders it once, and a call with neither carries no config block.
5731    #[test]
5732    fn health_config_include_key_and_boolean_alias_render_identically() {
5733        let (server, _tmp) = setup_test_engine();
5734        let call = |include: Option<Vec<String>>, include_config: bool| {
5735            let r = server.memstead_health(Parameters(HealthParams {
5736                include,
5737                limit: None,
5738                mem: None,
5739                include_config,
5740                token_budget: None,
5741                chunk: None,
5742                target_schema: None,
5743            }));
5744            assert!(!r.is_error.unwrap_or(false), "health must succeed: {r:?}");
5745            r.structured_content.unwrap()
5746        };
5747
5748        let via_alias = call(None, true);
5749        let via_key = call(Some(vec!["config".to_string()]), false);
5750        let via_both = call(Some(vec!["config".to_string()]), true);
5751        for payload in [&via_alias, &via_key, &via_both] {
5752            for key in ["mems", "mutations", "plugin"] {
5753                assert!(
5754                    payload.get(key).is_some(),
5755                    "config projection must carry `{key}`: {payload}"
5756                );
5757            }
5758        }
5759        assert_eq!(
5760            via_alias, via_key,
5761            "alias and catalogue key must render the identical projection"
5762        );
5763        assert_eq!(via_key, via_both, "passing both renders it once");
5764        // The catalogue key is known — no UNKNOWN_INCLUDE_KEY warning.
5765        assert!(
5766            !via_key
5767                .get("warnings")
5768                .and_then(|w| w.as_array())
5769                .is_some_and(|w| w.iter().any(|e| e["code"] == "UNKNOWN_INCLUDE_KEY")),
5770            "`config` is a catalogue member, not an unknown key: {via_key}"
5771        );
5772
5773        // Refusal complement: without the token there is no config block.
5774        let plain = call(None, false);
5775        for key in ["mems", "mutations", "plugin"] {
5776            assert!(
5777                plain.get(key).is_none(),
5778                "no config block without the opt-in: {plain}"
5779            );
5780        }
5781    }
5782
5783    /// The `missing_fields` include carries WHICH condition each issue
5784    /// reports: a genuinely absent section and content under a
5785    /// non-deriving heading produce entries with distinct `code`s and
5786    /// their messages, while the legacy `missing` field-name array
5787    /// stays exactly as today for both. Exercised through the engine
5788    /// composer (`compose_health`) over a sealed-violator workspace.
5789    #[test]
5790    fn health_missing_fields_include_carries_issue_codes_additively() {
5791        use memstead_base::backend::MemBackend;
5792        use memstead_base::storage::FilesystemMemWriter;
5793        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5794
5795        let tmp = tempfile::TempDir::new().unwrap();
5796        let schemas_dir = tmp.path().join("schemas");
5797        let pkg = schemas_dir.join("debate");
5798        std::fs::create_dir_all(pkg.join("types")).unwrap();
5799        std::fs::write(
5800            pkg.join("schema.yaml"),
5801            "name: debate\nversion: 0.1.0\ndescription: fixture\nwhen_to_use: tests\ntypes:\n  - question\nrelationships:\n  mode: strict\n  definitions:\n    - name: PART_OF\n      description: hier\n      default_weight: 3.0\n    - name: _default\n      description: fallback\n      default_weight: 1.0\ncommunity:\n  resolution: 1.0\n  seed: 42\n",
5802        )
5803        .unwrap();
5804        std::fs::write(
5805            pkg.join("types").join("question.yaml"),
5806            "name: question\ndescription: t\nwhen_to_use: tests\nsections:\n  - key: answers\n    heading: Answers argued\n    required: true\n    search_weight: 10.0\n    write_rules: []\n  - key: notes\n    heading: Notes\n    required: false\n    search_weight: 3.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - answers\n  - notes\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - answers\nhealth_required_fields:\n  - answers\nstaleness_threshold_days: 90\nwrite_rules: []\n",
5807        )
5808        .unwrap();
5809        let mem_dir = tmp.path().join("mem");
5810        std::fs::create_dir_all(&mem_dir).unwrap();
5811        // Content present under the schema's non-deriving heading →
5812        // SECTION_HEADING_MISMATCH, never "missing".
5813        std::fs::write(
5814            mem_dir.join("mismatch.md"),
5815            "---\ntype: question\n---\n# Mismatch\n\n## Answers argued\n\nPresent.\n",
5816        )
5817        .unwrap();
5818        // Genuinely absent section → MISSING, exactly as today.
5819        std::fs::write(
5820            mem_dir.join("absent.md"),
5821            "---\ntype: question\n---\n# Absent\n",
5822        )
5823        .unwrap();
5824
5825        let writer = FilesystemMemWriter::new(mem_dir.clone());
5826        let mount = Mount {
5827            mem: "debate-mem".to_string(),
5828            schema: Some(memstead_schema::SchemaRef::new(
5829                "debate",
5830                semver::Version::new(0, 1, 0),
5831            )),
5832            storage: MountStorage::Folder { path: mem_dir },
5833            capability: MountCapability::Write,
5834            lifecycle: MountLifecycle::Eager,
5835            cross_linkable: true,
5836            migration_target: None,
5837        };
5838        let mut engine = memstead_base::Engine::from_mounts_with_schemas_dir(
5839            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
5840            Some(&schemas_dir),
5841        )
5842        .unwrap();
5843
5844        let include = vec!["missing_fields".to_string()];
5845        let args = memstead_engine::health::HealthArgs {
5846            mem: None,
5847            include: &include,
5848            limit: None,
5849            target_schema: None,
5850            include_config: false,
5851        };
5852        let config = memstead_engine::health::HealthConfig {
5853            mutations: serde_json::Value::Null,
5854            plugin: serde_json::Value::Object(Default::default()),
5855        };
5856        let payload =
5857            memstead_engine::health::compose_health(&mut engine, &args, Vec::new(), &config)
5858                .expect("compose_health succeeds");
5859
5860        let entries = payload["missing_fields"]
5861            .as_array()
5862            .expect("missing_fields include renders");
5863        let entry_for = |id: &str| {
5864            entries
5865                .iter()
5866                .find(|e| e["id"] == format!("debate-mem--{id}"))
5867                .unwrap_or_else(|| panic!("entry for {id}: {entries:?}"))
5868        };
5869
5870        let mismatch = entry_for("mismatch");
5871        // Legacy array byte-identical to today's: bare field names.
5872        assert_eq!(mismatch["missing"], serde_json::json!(["answers"]));
5873        assert_eq!(mismatch["issues"][0]["code"], "SECTION_HEADING_MISMATCH");
5874        assert_eq!(mismatch["issues"][0]["field"], "answers");
5875        assert!(
5876            mismatch["issues"][0]["message"]
5877                .as_str()
5878                .unwrap()
5879                .contains("is not missing"),
5880            "the message rides beside the code: {mismatch}"
5881        );
5882
5883        let absent = entry_for("absent");
5884        assert_eq!(absent["missing"], serde_json::json!(["answers"]));
5885        assert_eq!(absent["issues"][0]["code"], "MISSING");
5886        assert!(
5887            absent["issues"][0]["message"]
5888                .as_str()
5889                .unwrap()
5890                .contains("is empty"),
5891            "the message rides beside the code: {absent}"
5892        );
5893    }
5894
5895    /// Shared read-envelope contract — the MCP `memstead_health` path and a
5896    /// direct, rmcp-free call to `compose_health` emit identical bytes. Proves the
5897    /// health read-envelope is produced by one transport-neutral builder
5898    /// reachable with no rmcp type in the path, so a future `/api/health` is
5899    /// not a hand-mirrored copy. Several `include` detail sections are active
5900    /// so the comparison exercises the builder beyond the bare summary.
5901    #[test]
5902    fn memstead_health_mcp_path_matches_direct_composer_bytes() {
5903        let (server, _tmp) = setup_test_engine();
5904        let include = vec![
5905            "orphans".to_string(),
5906            "stubs".to_string(),
5907            "most_connected".to_string(),
5908            "missing_fields".to_string(),
5909            "stale".to_string(),
5910            "dangling_links".to_string(),
5911            "tags".to_string(),
5912        ];
5913
5914        // MCP path: structured_content of the memstead_health tool.
5915        let mcp = server.memstead_health(Parameters(HealthParams {
5916            include: Some(include.clone()),
5917            limit: None,
5918            mem: None,
5919            include_config: false,
5920            token_budget: None,
5921            chunk: None,
5922            target_schema: None,
5923        }));
5924        assert!(
5925            !mcp.is_error.unwrap_or(false),
5926            "memstead_health must succeed: {mcp:?}"
5927        );
5928        let mcp_payload = mcp
5929            .structured_content
5930            .clone()
5931            .expect("memstead_health returns structured_content");
5932
5933        // Direct rmcp-free path: mirror the handler's pre-call reload, then
5934        // call the composer with no rmcp type involved. `config` is unread
5935        // here (include_config = false) but supplied for shape.
5936        let direct_payload = {
5937            let unified = server.unified_engine();
5938            let mut engine = unified.lock().unwrap();
5939            let drift = engine.reload_if_stale(None);
5940            let _ = engine.take_mem_changed_notices();
5941            let args = memstead_engine::health::HealthArgs {
5942                mem: None,
5943                include: &include,
5944                limit: None,
5945                target_schema: None,
5946                include_config: false,
5947            };
5948            let config = memstead_engine::health::HealthConfig {
5949                mutations: serde_json::Value::Null,
5950                plugin: serde_json::Value::Object(Default::default()),
5951            };
5952            memstead_engine::health::compose_health(&mut engine, &args, drift, &config)
5953                .expect("compose_health succeeds")
5954        };
5955
5956        assert_eq!(
5957            mcp_payload, direct_payload,
5958            "MCP health structured_content must equal the direct composer payload"
5959        );
5960        // Byte-level identity, not just structural equality.
5961        assert_eq!(
5962            serde_json::to_string(&mcp_payload).unwrap(),
5963            serde_json::to_string(&direct_payload).unwrap(),
5964            "serialized bytes must be identical across MCP and direct call"
5965        );
5966    }
5967
5968    /// Migration wire surface: the migration
5969    /// trigger's stable five-field response, the dual-pin
5970    /// confirmation on `memstead_health`'s `mem_schemas`, and the typed
5971    /// refusals. The standard fixture's `spec` entities are
5972    /// non-conformant against the built-in `planning` schema (no `spec` type), so
5973    /// migrating to it enters dual-pin.
5974    #[test]
5975    fn mem_set_schema_wire_lifecycle_and_health_confirmation() {
5976        let (server, _tmp) = setup_dual_test_engine();
5977        let call = |schema: &str| {
5978            server.memstead_mem_set_schema(Parameters(crate::lifecycle::MemSetSchemaParams {
5979                mem: "specs".to_string(),
5980                schema: schema.to_string(),
5981                note: None,
5982            }))
5983        };
5984
5985        // noop — every field present, agent branches on `outcome`.
5986        let json: serde_json::Value =
5987            serde_json::from_str(&extract_text(&call("default@1.0.0"))).unwrap();
5988        assert_eq!(json["outcome"].as_str(), Some("noop"));
5989        assert_eq!(json["mem"].as_str(), Some("specs"));
5990        assert_eq!(json["schema_pin"].as_str(), Some("default@1.0.0"));
5991        assert!(json["migration_target"].is_null());
5992        assert_eq!(json["findings"].as_array().map(|a| a.len()), Some(0));
5993
5994        // Non-integral target → migration starts; findings ride the
5995        // response in the linter shape.
5996        let json: serde_json::Value =
5997            serde_json::from_str(&extract_text(&call("planning@0.1.0"))).unwrap();
5998        assert_eq!(json["outcome"].as_str(), Some("migration_started"));
5999        assert_eq!(json["schema_pin"].as_str(), Some("default@1.0.0"));
6000        assert_eq!(json["migration_target"].as_str(), Some("planning@0.1.0"));
6001        let findings = json["findings"].as_array().unwrap();
6002        assert!(!findings.is_empty());
6003        assert!(
6004            findings
6005                .iter()
6006                .all(|f| f["axis"].as_str() == Some("conformance"))
6007        );
6008
6009        // Dual-pin confirmation on memstead_health.
6010        let health = server.memstead_health(Parameters(HealthParams {
6011            include: None,
6012            limit: None,
6013            mem: Some("specs".to_string()),
6014            include_config: false,
6015            token_budget: None,
6016            chunk: None,
6017            target_schema: None,
6018        }));
6019        // #57: the text channel is now chunked markdown; the typed payload
6020        // lives in structured_content (always whole).
6021        let hj: serde_json::Value = health.structured_content.clone().unwrap();
6022        let entry = hj["mem_schemas"]
6023            .as_array()
6024            .unwrap()
6025            .iter()
6026            .find(|e| e["mem"].as_str() == Some("specs"))
6027            .expect("specs entry present")
6028            .clone();
6029        assert_eq!(entry["schema"].as_str(), Some("default@1.0.0"));
6030        assert_eq!(entry["migration_target"].as_str(), Some("planning@0.1.0"));
6031
6032        // Re-issue → pending.
6033        let json: serde_json::Value =
6034            serde_json::from_str(&extract_text(&call("planning@0.1.0"))).unwrap();
6035        assert_eq!(json["outcome"].as_str(), Some("migration_pending"));
6036
6037        // Typed refusals.
6038        let missing = call("no-such@9.9.9");
6039        assert!(extract_text(&missing).contains("SCHEMA_NOT_FOUND"));
6040        let malformed = call("not-a-ref");
6041        assert!(extract_text(&malformed).contains("INVALID_INPUT"));
6042    }
6043
6044    #[test]
6045    fn health_conformance_include_surfaces_findings_array() {
6046        let (server, _tmp) = setup_dual_test_engine();
6047        let result = server.memstead_health(Parameters(HealthParams {
6048            include: Some(vec!["conformance".to_string()]),
6049            limit: None,
6050            mem: None,
6051            include_config: false,
6052            token_budget: None,
6053            chunk: None,
6054            target_schema: None,
6055        }));
6056        let text = extract_text(&result);
6057        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6058        let findings = json
6059            .get("findings")
6060            .and_then(|v| v.as_array())
6061            .expect("findings must surface as array under include=conformance");
6062        for f in findings {
6063            assert!(f.get("id").is_some(), "finding carries id: {f}");
6064            assert_eq!(f["axis"].as_str(), Some("conformance"));
6065            assert!(f.get("code").is_some(), "finding carries code: {f}");
6066            assert!(f.get("detail").is_some(), "finding carries detail: {f}");
6067        }
6068        let warnings = json
6069            .get("warnings")
6070            .and_then(|v| v.as_array())
6071            .cloned()
6072            .unwrap_or_default();
6073        assert!(
6074            !warnings
6075                .iter()
6076                .any(|w| { w.get("code").and_then(|c| c.as_str()) == Some("UNKNOWN_INCLUDE_KEY") }),
6077            "conformance must be on the allowlist; got {warnings:?}"
6078        );
6079    }
6080
6081    /// `include=["integrity"]` returns both axes in one `findings`
6082    /// list, and conformance findings reuse the write-time typed
6083    /// codes. Fixture: one entity with an undeclared metadata field
6084    /// (conformance break, `UNKNOWN_METADATA_FIELD`) and a relation to
6085    /// an absent target (load-time stub → consistency finding
6086    /// `ORPHAN_STUB`). Two runs are byte-identical (determinism).
6087    #[test]
6088    fn health_integrity_include_returns_both_axes_with_write_time_codes() {
6089        let tmp = TempDir::new().unwrap();
6090        let mem_dir = tmp.path().join("specs");
6091        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6092        fs::write(
6093            mem_dir.join(".memstead/config.json"),
6094            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6095        )
6096        .unwrap();
6097        fs::write(
6098            mem_dir.join("drifted.md"),
6099            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nzzz_bogus_field: x\n---\n# Drifted\n\n## Identity\n\nCarries an undeclared metadata field.\n\n## Purpose\n\nConformance-break fixture.\n\n## Relationships\n\n- **USES**: [[never-created]]\n",
6100        )
6101        .unwrap();
6102        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
6103        let engine = setup_unified_test_engine(tmp.path());
6104        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
6105
6106        let call = || {
6107            let result = server.memstead_health(Parameters(HealthParams {
6108                include: Some(vec!["integrity".to_string()]),
6109                limit: None,
6110                mem: Some("specs".to_string()),
6111                include_config: false,
6112                token_budget: None,
6113                chunk: None,
6114                target_schema: None,
6115            }));
6116            let text = extract_text(&result);
6117            serde_json::from_str::<serde_json::Value>(&text).unwrap()
6118        };
6119        let json = call();
6120        let findings = json
6121            .get("findings")
6122            .and_then(|v| v.as_array())
6123            .expect("findings array present")
6124            .clone();
6125        let code_of = |axis: &str, code: &str| {
6126            findings
6127                .iter()
6128                .any(|f| f["axis"].as_str() == Some(axis) && f["code"].as_str() == Some(code))
6129        };
6130        assert!(
6131            code_of("conformance", "UNKNOWN_METADATA_FIELD"),
6132            "undeclared field must lint with the write-time code; got {findings:?}"
6133        );
6134        assert!(
6135            code_of("consistency", "ORPHAN_STUB"),
6136            "stub target must surface on the consistency axis; got {findings:?}"
6137        );
6138        // Determinism: a second identical call is byte-identical on
6139        // the findings list.
6140        let second = call();
6141        assert_eq!(
6142            json.get("findings"),
6143            second.get("findings"),
6144            "two runs over unchanged state must produce identical findings"
6145        );
6146    }
6147
6148    /// `target_schema` redirects the conformance lint: an unresolvable
6149    /// ref refuses with `SCHEMA_NOT_FOUND`; a malformed ref refuses
6150    /// with `INVALID_INPUT`. The valid case (the pin itself, spelled
6151    /// explicitly) succeeds and returns the same findings as the
6152    /// implicit-pin call.
6153    #[test]
6154    fn health_target_schema_resolution_and_refusals() {
6155        let (server, _tmp) = setup_dual_test_engine();
6156        let call = |target: Option<&str>| {
6157            server.memstead_health(Parameters(HealthParams {
6158                include: Some(vec!["conformance".to_string()]),
6159                limit: None,
6160                mem: None,
6161                include_config: false,
6162                token_budget: None,
6163                chunk: None,
6164                target_schema: target.map(|s| s.to_string()),
6165            }))
6166        };
6167        // Unresolvable → SCHEMA_NOT_FOUND.
6168        let missing = call(Some("no-such-schema@9.9.9"));
6169        let text = extract_text(&missing);
6170        assert!(
6171            missing.is_error.unwrap_or(false) && text.contains("SCHEMA_NOT_FOUND"),
6172            "unresolvable target_schema must refuse with SCHEMA_NOT_FOUND; got {text}"
6173        );
6174        // Malformed → INVALID_INPUT.
6175        let malformed = call(Some("default@^1.0"));
6176        let text = extract_text(&malformed);
6177        assert!(
6178            malformed.is_error.unwrap_or(false) && text.contains("INVALID_INPUT"),
6179            "malformed target_schema must refuse with INVALID_INPUT; got {text}"
6180        );
6181        // Explicit pin == implicit pin.
6182        let explicit = extract_text(&call(Some("default@1.0.0")));
6183        let implicit = extract_text(&call(None));
6184        let ej: serde_json::Value = serde_json::from_str(&explicit).unwrap();
6185        let ij: serde_json::Value = serde_json::from_str(&implicit).unwrap();
6186        assert_eq!(ej.get("findings"), ij.get("findings"));
6187    }
6188
6189    #[test]
6190    fn overview_surfaces_configured_lifecycle_namespaces() {
6191        // The `## Lifecycle Namespaces` section is rendered from
6192        // `engine.settings()`. Build an engine carrying two create
6193        // rules and one delete rule; the markdown response of
6194        // `memstead_overview` must carry the section listing each
6195        // rule's pattern, the actions it gates, and the allowed
6196        // schemas. Cross-reference: the `## Schemas` section's
6197        // entries carry a `**Reachable as:**` line naming every
6198        // pattern that can pin them.
6199        let tmp = TempDir::new().unwrap();
6200        let mem_dir = tmp.path().join("specs");
6201        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6202        fs::write(
6203            mem_dir.join(".memstead/config.json"),
6204            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6205        )
6206        .unwrap();
6207
6208        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
6209            tmp.path(),
6210            memstead_base::WorkspaceSettings {
6211                mem_create_rules: vec![
6212                    memstead_base::CreateRuleSetting {
6213                        pattern: "exec-*".to_string(),
6214                        schemas: vec!["default@1.0.0".to_string()],
6215                        default_cross_links: None,
6216                    },
6217                    memstead_base::CreateRuleSetting {
6218                        pattern: "plan-*".to_string(),
6219                        schemas: vec!["default@1.0.0".to_string()],
6220                        default_cross_links: None,
6221                    },
6222                ],
6223                mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
6224                    pattern: "exec-*".to_string(),
6225                }],
6226                ..Default::default()
6227            },
6228        );
6229        let unified_settings = settings.clone();
6230        let _ = (mem_dir, settings);
6231        let mut unified = setup_unified_test_engine(tmp.path());
6232        unified.set_settings(unified_settings);
6233        let canonical_root =
6234            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6235        unified.set_workspace_root(canonical_root);
6236        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6237
6238        let result = server.memstead_overview(Parameters(OverviewParams {
6239            rebuild: None,
6240            chunk: None,
6241            mem: None,
6242            include: None,
6243            token_budget: None,
6244        }));
6245        let text = extract_text(&result);
6246        assert!(
6247            text.contains("## Lifecycle Namespaces"),
6248            "overview must carry the lifecycle namespaces section: {text}"
6249        );
6250        assert!(
6251            text.contains("`exec-*`"),
6252            "exec-* rule must surface: {text}"
6253        );
6254        assert!(
6255            text.contains("`plan-*`"),
6256            "plan-* rule must surface: {text}"
6257        );
6258        assert!(
6259            text.contains("default@1.0.0"),
6260            "rule schemas must surface: {text}"
6261        );
6262        // Cross-reference: schema entry names the patterns that allow it.
6263        assert!(
6264            text.contains("**Reachable as:**"),
6265            "schema cross-reference must surface: {text}"
6266        );
6267    }
6268
6269    /// Lifecycle-Namespaces rendering surfaces rule schema pins
6270    /// verbatim — no `(unresolved)` annotation, even for schemas no
6271    /// registered mem currently pins. The annotation was previously
6272    /// fired for any pin not in `engine.schemas()` or
6273    /// `engine.workspace_schemas()`, mis-flagging built-in schemas as
6274    /// non-functional and causing agents to skip working namespaces
6275    /// (mem-lifecycle-audit Item 03). The `(invalid)` annotation
6276    /// still fires for malformed pins — that's a real operator-config
6277    /// bug worth flagging.
6278    #[test]
6279    fn lifecycle_section_does_not_label_resolvable_pins_as_unresolved() {
6280        let tmp = TempDir::new().unwrap();
6281        let mem_dir = tmp.path().join("specs");
6282        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6283        fs::write(
6284            mem_dir.join(".memstead/config.json"),
6285            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6286        )
6287        .unwrap();
6288
6289        // `planning@0.1.0` is a built-in schema, but no mem in this
6290        // fixture pins it. Pre-fix the lifecycle rendering would mark
6291        // it `(unresolved)`; post-fix the raw pin surfaces verbatim.
6292        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
6293            tmp.path(),
6294            memstead_base::WorkspaceSettings {
6295                mem_create_rules: vec![memstead_base::CreateRuleSetting {
6296                    pattern: "planning/plan-*".to_string(),
6297                    schemas: vec!["planning@0.1.0".to_string()],
6298                    default_cross_links: None,
6299                }],
6300                mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
6301                    pattern: "planning/plan-*".to_string(),
6302                }],
6303                ..Default::default()
6304            },
6305        );
6306        let unified_settings = settings.clone();
6307        let _ = (mem_dir, settings);
6308        let mut unified = setup_unified_test_engine(tmp.path());
6309        unified.set_settings(unified_settings);
6310        let canonical_root =
6311            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6312        unified.set_workspace_root(canonical_root);
6313        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6314
6315        let result = server.memstead_overview(Parameters(OverviewParams {
6316            rebuild: None,
6317            chunk: None,
6318            mem: None,
6319            include: None,
6320            token_budget: None,
6321        }));
6322        let text = extract_text(&result);
6323        assert!(
6324            text.contains("planning@0.1.0"),
6325            "lifecycle rendering must list the rule's schema pin: {text}"
6326        );
6327        assert!(
6328            !text.contains("planning@0.1.0 (unresolved)"),
6329            "(unresolved) must NOT decorate a resolvable built-in schema: {text}"
6330        );
6331    }
6332
6333    /// Companion: malformed pins still surface the `(invalid)`
6334    /// annotation. The lifecycle rendering preserves this signal so an
6335    /// operator typo (rule pattern with a non-parsing schema entry)
6336    /// stays visible to the agent at overview time.
6337    #[test]
6338    fn lifecycle_section_keeps_invalid_annotation_for_malformed_pins() {
6339        let tmp = TempDir::new().unwrap();
6340        let mem_dir = tmp.path().join("specs");
6341        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6342        fs::write(
6343            mem_dir.join(".memstead/config.json"),
6344            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6345        )
6346        .unwrap();
6347
6348        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
6349            tmp.path(),
6350            memstead_base::WorkspaceSettings {
6351                mem_create_rules: vec![memstead_base::CreateRuleSetting {
6352                    pattern: "exec-*".to_string(),
6353                    schemas: vec!["not a valid pin".to_string()],
6354                    default_cross_links: None,
6355                }],
6356                mem_delete_rules: vec![],
6357                ..Default::default()
6358            },
6359        );
6360        let unified_settings = settings.clone();
6361        let _ = (mem_dir, settings);
6362        let mut unified = setup_unified_test_engine(tmp.path());
6363        unified.set_settings(unified_settings);
6364        let canonical_root =
6365            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6366        unified.set_workspace_root(canonical_root);
6367        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6368
6369        let result = server.memstead_overview(Parameters(OverviewParams {
6370            rebuild: None,
6371            chunk: None,
6372            mem: None,
6373            include: None,
6374            token_budget: None,
6375        }));
6376        let text = extract_text(&result);
6377        assert!(
6378            text.contains("(invalid)"),
6379            "(invalid) annotation must survive for malformed pins: {text}"
6380        );
6381    }
6382
6383    /// A fresh workspace at engine defaults has nothing to say under
6384    /// `## Workspace policy` — the section stays absent and no `_policy`
6385    /// frontmatter slot is emitted. Default-suppress keeps the overview
6386    /// quiet for the common case.
6387    #[test]
6388    fn overview_workspace_policy_silent_on_defaults() {
6389        let tmp = TempDir::new().unwrap();
6390        let mem_dir = tmp.path().join("specs");
6391        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6392        fs::write(
6393            mem_dir.join(".memstead/config.json"),
6394            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6395        )
6396        .unwrap();
6397        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
6398            tmp.path(),
6399            memstead_base::WorkspaceSettings::default(),
6400        );
6401        let unified_settings = settings.clone();
6402        let _ = (mem_dir, settings);
6403        let mut unified = setup_unified_test_engine(tmp.path());
6404        unified.set_settings(unified_settings);
6405        let canonical_root =
6406            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6407        unified.set_workspace_root(canonical_root);
6408        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6409
6410        let result = server.memstead_overview(Parameters(OverviewParams {
6411            rebuild: None,
6412            chunk: None,
6413            mem: None,
6414            include: None,
6415            token_budget: None,
6416        }));
6417        let text = extract_text(&result);
6418        assert!(
6419            !text.contains("## Workspace policy"),
6420            "default workspace must not emit Workspace policy section: {text}"
6421        );
6422        assert!(
6423            !text.contains("_policy:"),
6424            "default workspace must not stamp a _policy frontmatter slot: {text}"
6425        );
6426    }
6427
6428    /// `require_notes = true` surfaces as a single-line entry in
6429    /// `## Workspace policy` and as a `_policy` frontmatter slot.
6430    #[test]
6431    fn overview_workspace_policy_surfaces_require_notes() {
6432        let tmp = TempDir::new().unwrap();
6433        let mem_dir = tmp.path().join("specs");
6434        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6435        fs::write(
6436            mem_dir.join(".memstead/config.json"),
6437            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6438        )
6439        .unwrap();
6440        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
6441            tmp.path(),
6442            memstead_base::WorkspaceSettings {
6443                mutations: memstead_base::workspace::MutationsSection {
6444                    require_notes: Some(true),
6445                },
6446                ..Default::default()
6447            },
6448        );
6449        let unified_settings = settings.clone();
6450        let _ = (mem_dir, settings);
6451        let mut unified = setup_unified_test_engine(tmp.path());
6452        unified.set_settings(unified_settings);
6453        let canonical_root =
6454            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6455        unified.set_workspace_root(canonical_root);
6456        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6457
6458        let result = server.memstead_overview(Parameters(OverviewParams {
6459            rebuild: None,
6460            chunk: None,
6461            mem: None,
6462            include: None,
6463            token_budget: None,
6464        }));
6465        let text = extract_text(&result);
6466        assert!(
6467            text.contains("## Workspace policy"),
6468            "Workspace policy section must appear: {text}"
6469        );
6470        assert!(
6471            text.contains("**require_notes:** true"),
6472            "require_notes entry must appear in the section body: {text}"
6473        );
6474        assert!(
6475            text.contains("_policy: {require_notes: true}"),
6476            "_policy frontmatter slot must carry the inline flow mapping: {text}"
6477        );
6478    }
6479
6480    /// Any schema referenced in a rule's `schemas[]` becomes visible
6481    /// in `## Schemas`, even when no mem pins it. Builds a
6482    /// workspace where the only pinned schema is `default@1.0.0` but
6483    /// a rule lists `tinyschema@0.1.0` (registered via the workspace-
6484    /// level schemas dir but not pinned by any mem). The overview
6485    /// must surface `tinyschema` as a full schema entry with the
6486    /// `**Reachable as:**` cross-reference pointing at the rule.
6487    #[test]
6488    fn overview_surfaces_rule_referenced_unpinned_schema() {
6489        // `memstead_overview_unified` enumerates rule-referenced
6490        // schemas; the workspace `schemas_dir` must be wired into
6491        // the engine so it resolves `tinyschema@0.1.0`. Use
6492        // Engine::from_mounts_with_schemas_dir to load
6493        // workspace-level schemas alongside the folder mount.
6494        let tmp = TempDir::new().unwrap();
6495
6496        // Stage a workspace-level schemas dir carrying a second
6497        // schema (mirrors the `MEM_SCHEMA_NOT_ALLOWED` engine test
6498        // fixture). Minimal manifest: identity + open relationships
6499        // with the engine-required `_default`.
6500        let schemas_dir = tmp.path().join("schemas");
6501        let tinyschema_dir = schemas_dir.join("tinyschema");
6502        fs::create_dir_all(&tinyschema_dir).unwrap();
6503        fs::write(
6504            tinyschema_dir.join("schema.yaml"),
6505            r#"name: tinyschema
6506version: 0.1.0
6507description: test fixture for rule-referenced surfacing
6508when_to_use: test only
6509types: []
6510relationships:
6511  mode: open
6512  definitions:
6513    - name: _default
6514      description: fallback weight required by the engine
6515      default_weight: 1.0
6516community:
6517  resolution: 1.0
6518  seed: 42
6519"#,
6520        )
6521        .unwrap();
6522
6523        let mem_dir = tmp.path().join("specs");
6524        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6525        fs::write(
6526            mem_dir.join(".memstead/config.json"),
6527            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6528        )
6529        .unwrap();
6530
6531        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
6532            tmp.path(),
6533            memstead_base::WorkspaceSettings {
6534                // Rule pins `tinyschema@0.1.0` — no mem references
6535                // this schema.
6536                mem_create_rules: vec![memstead_base::CreateRuleSetting {
6537                    pattern: "exec-*".to_string(),
6538                    schemas: vec!["tinyschema@0.1.0".to_string()],
6539                    default_cross_links: None,
6540                }],
6541                mem_delete_rules: vec![],
6542                ..Default::default()
6543            },
6544        );
6545        let unified_settings = settings.clone();
6546        let _ = (mem_dir.clone(), settings);
6547        // Build unified with the folder mount + workspace schemas_dir
6548        // so tinyschema is loaded into the unified engine's catalogue.
6549        let mount = memstead_base::workspace::Mount {
6550            mem: "specs".to_string(),
6551            schema: Some("default@1.0.0".parse().unwrap()),
6552            storage: memstead_base::workspace::MountStorage::Folder { path: mem_dir },
6553            capability: memstead_base::workspace::MountCapability::Write,
6554            lifecycle: memstead_base::workspace::MountLifecycle::Eager,
6555            cross_linkable: true,
6556            migration_target: None,
6557        };
6558        let backend = memstead_base::instantiate_lean_backend(&mount).unwrap();
6559        let mut unified = memstead_base::Engine::from_mounts_with_schemas_dir(
6560            vec![(mount, backend)],
6561            Some(&schemas_dir),
6562        )
6563        .unwrap();
6564        unified.set_settings(unified_settings);
6565        let canonical_root =
6566            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6567        unified.set_workspace_root(canonical_root);
6568        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6569
6570        let result = server.memstead_overview(Parameters(OverviewParams {
6571            rebuild: None,
6572            chunk: None,
6573            mem: None,
6574            include: None,
6575            token_budget: None,
6576        }));
6577        let text = extract_text(&result);
6578
6579        // tinyschema appears as a `### tinyschema@0.1.0` heading in
6580        // the `## Schemas` block — same surface as the mem-pinned
6581        // `default@1.0.0`, so the agent gets full description + types
6582        // + relationship vocabulary, not just a literal string in the
6583        // lifecycle namespaces section.
6584        assert!(
6585            text.contains("### tinyschema@0.1.0"),
6586            "rule-referenced unpinned schema must surface as ## Schemas entry: {text}"
6587        );
6588        // Description from the manifest reaches the markdown.
6589        assert!(
6590            text.contains("test fixture for rule-referenced surfacing"),
6591            "schema description must propagate: {text}"
6592        );
6593        // Cross-reference points at the rule's pattern.
6594        // The `**Reachable as:**` line under tinyschema must list `exec-*`.
6595        let tiny_section = text
6596            .split("### tinyschema@0.1.0")
6597            .nth(1)
6598            .expect("tinyschema section present");
6599        let tiny_until_next_h3 = tiny_section.split("\n### ").next().unwrap_or(tiny_section);
6600        assert!(
6601            tiny_until_next_h3.contains("**Reachable as:**"),
6602            "tinyschema must carry Reachable-as cross-reference: {tiny_until_next_h3}"
6603        );
6604        assert!(
6605            tiny_until_next_h3.contains("`exec-*`"),
6606            "tinyschema's Reachable-as must name the rule pattern: {tiny_until_next_h3}"
6607        );
6608    }
6609
6610    #[test]
6611    fn health_with_include_config_surfaces_writable_mem_origins() {
6612        // Under `include_config: true`, the response carries a
6613        // `mems` detail array with `{ name, origin }` per writable mem.
6614        // A freshly `Engine::init`ed mem from explicit `MemInit` input
6615        // is `ExplicitToml` → origin slug "explicit". Absent from the
6616        // response means the detail array wasn't emitted (regression
6617        // guard).
6618        let (server, _tmp) = setup_dual_test_engine();
6619        let result = server.memstead_health(Parameters(HealthParams {
6620            include: None,
6621            limit: None,
6622            mem: None,
6623            include_config: true,
6624            token_budget: None,
6625            chunk: None,
6626            target_schema: None,
6627        }));
6628        let text = extract_text(&result);
6629        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6630        let mems = json
6631            .get("mems")
6632            .and_then(|v| v.as_array())
6633            .expect("mems detail array present when include_config is true");
6634        assert!(!mems.is_empty(), "at least one writable mem expected");
6635        for entry in mems {
6636            assert!(entry.get("name").and_then(|n| n.as_str()).is_some());
6637            assert_eq!(
6638                entry.get("origin").and_then(|o| o.as_str()),
6639                Some("explicit"),
6640                "setup_test_engine mem must carry origin=explicit, entry={entry}",
6641            );
6642        }
6643    }
6644
6645    #[test]
6646    fn health_with_include_config_surfaces_per_mem_vcs_subobject() {
6647        // `include_config: true`
6648        // adds a `vcs: { gitdir, worktree }` subobject to each writable
6649        // mem entry. Paths must be absolute and canonical, and must
6650        // match what `Engine::gitdir_for` / `worktree_for` return when
6651        // called directly — the MCP payload is the public form of
6652        // those primitives for the Stop-hook flow.
6653        //
6654        // `memstead_health_unified` emits the vcs subobject for
6655        // git-branch mounts when the workspace root carries the
6656        // disk-shape mem folder (`worktree_for` resolves the
6657        // worktree via the disk-shape composition).
6658        let tmp = setup_test_workspace();
6659        let mut unified = setup_unified_test_engine_git_branch(tmp.path());
6660        let canonical_root =
6661            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6662        unified.set_workspace_root(canonical_root);
6663        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6664        let result = server.memstead_health(Parameters(HealthParams {
6665            include: None,
6666            limit: None,
6667            mem: None,
6668            include_config: true,
6669            token_budget: None,
6670            chunk: None,
6671            target_schema: None,
6672        }));
6673        let text = extract_text(&result);
6674        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6675        let mems = json
6676            .get("mems")
6677            .and_then(|v| v.as_array())
6678            .expect("mems detail array present when include_config is true");
6679        let unified = server.unified_engine().clone();
6680        let engine = unified.lock().unwrap();
6681        for entry in mems {
6682            let name = entry.get("name").and_then(|n| n.as_str()).unwrap();
6683            let vcs = entry
6684                .get("vcs")
6685                .and_then(|v| v.as_object())
6686                .expect("vcs subobject present on writable-mem entry");
6687            let gitdir = std::path::PathBuf::from(
6688                vcs.get("gitdir")
6689                    .and_then(|v| v.as_str())
6690                    .expect("gitdir is a string"),
6691            );
6692            let worktree = std::path::PathBuf::from(
6693                vcs.get("worktree")
6694                    .and_then(|v| v.as_str())
6695                    .expect("worktree is a string"),
6696            );
6697            assert!(gitdir.is_absolute(), "gitdir must be absolute: {gitdir:?}");
6698            assert!(
6699                worktree.is_absolute(),
6700                "worktree must be absolute: {worktree:?}"
6701            );
6702            assert!(gitdir.exists(), "gitdir must exist on disk: {gitdir:?}");
6703            assert_eq!(
6704                gitdir,
6705                engine.gitdir_for(name).expect("gitdir_for resolves"),
6706                "MCP-surfaced gitdir must match Engine::gitdir_for",
6707            );
6708            assert_eq!(
6709                worktree,
6710                engine.worktree_for(name).expect("worktree_for resolves"),
6711                "MCP-surfaced worktree must match Engine::worktree_for",
6712            );
6713        }
6714    }
6715
6716    #[test]
6717    fn health_with_include_config_surfaces_mutations_and_plugin() {
6718        // `mutations` and `plugin` arrive on the MCP
6719        // response verbatim from `EffectiveSettings`. Default-empty
6720        // values (section absent from config) surface as
6721        // `require_notes: null` and `plugin: {}` — the absence of a
6722        // value is communicated explicitly rather than via absent key.
6723        let (server_default, _tmp1) = setup_dual_test_engine();
6724        let result = server_default.memstead_health(Parameters(HealthParams {
6725            include: None,
6726            limit: None,
6727            mem: None,
6728            include_config: true,
6729            token_budget: None,
6730            chunk: None,
6731            target_schema: None,
6732        }));
6733        let text = extract_text(&result);
6734        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6735        assert_eq!(json["mutations"]["require_notes"], serde_json::Value::Null);
6736        assert_eq!(json["plugin"], serde_json::json!({}));
6737
6738        // Now with non-default values threaded through the full-surface
6739        // constructor. Both must round-trip.
6740        let tmp = TempDir::new().unwrap();
6741        let mem_dir = tmp.path().join("specs");
6742        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
6743        fs::write(
6744            mem_dir.join(".memstead/config.json"),
6745            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
6746        )
6747        .unwrap();
6748        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
6749
6750        let mutations = crate::config::MutationsSection {
6751            require_notes: Some(true),
6752        };
6753        let mut plugin = HashMap::new();
6754        // Any opaque sub-table proves the pass-through — the engine
6755        // never inspects the keys.
6756        let mut sub_table = toml::Table::new();
6757        sub_table.insert("enabled".into(), toml::Value::Boolean(true));
6758        sub_table.insert("mode".into(), toml::Value::String("session_bundle".into()));
6759        let mut claude_code = toml::Table::new();
6760        claude_code.insert("custom_setting".into(), toml::Value::Table(sub_table));
6761        plugin.insert("claude_code".into(), claude_code);
6762
6763        let server = McpServer::new_with_config(
6764            setup_unified_test_engine(tmp.path()),
6765            crate::config::DEFAULT_TOKEN_BUDGET,
6766            HashSet::new(),
6767            None,
6768            mutations,
6769            plugin,
6770        );
6771        let result = server.memstead_health(Parameters(HealthParams {
6772            include: None,
6773            limit: None,
6774            mem: None,
6775            include_config: true,
6776            token_budget: None,
6777            chunk: None,
6778            target_schema: None,
6779        }));
6780        let text = extract_text(&result);
6781        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6782        assert_eq!(json["mutations"]["require_notes"], serde_json::json!(true));
6783        assert_eq!(
6784            json["plugin"]["claude_code"]["custom_setting"]["enabled"],
6785            serde_json::json!(true)
6786        );
6787        assert_eq!(
6788            json["plugin"]["claude_code"]["custom_setting"]["mode"],
6789            serde_json::json!("session_bundle")
6790        );
6791    }
6792
6793    #[test]
6794    fn health_without_include_config_omits_mutations_and_plugin() {
6795        // Absent opt-in → neither `mutations` nor `plugin` appear.
6796        // Zero-bytes contract for clients that never ask for config.
6797        let (server, _tmp) = setup_dual_test_engine();
6798        let result = server.memstead_health(Parameters(HealthParams {
6799            include: None,
6800            limit: None,
6801            mem: None,
6802            include_config: false,
6803            token_budget: None,
6804            chunk: None,
6805            target_schema: None,
6806        }));
6807        let text = extract_text(&result);
6808        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6809        assert!(json.get("mutations").is_none());
6810        assert!(json.get("plugin").is_none());
6811    }
6812
6813    #[test]
6814    fn health_without_include_config_omits_mems_detail() {
6815        // Absent opt-in → the `mems` detail array is not emitted.
6816        // Clients that never call with `include_config: true` pay zero
6817        // extra bytes and the default-posture contract is preserved.
6818        let (server, _tmp) = setup_dual_test_engine();
6819        let result = server.memstead_health(Parameters(HealthParams {
6820            include: None,
6821            limit: None,
6822            mem: None,
6823            include_config: false,
6824            token_budget: None,
6825            chunk: None,
6826            target_schema: None,
6827        }));
6828        let text = extract_text(&result);
6829        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
6830        assert!(
6831            json.get("mems").is_none(),
6832            "mems detail array must be absent when include_config is false"
6833        );
6834    }
6835
6836    #[test]
6837    fn health_after_runtime_create_lists_new_mem_with_origin_runtime_created() {
6838        // A mem registered via `memstead_mem_create` surfaces with
6839        // `origin: "runtime_created"` on a subsequent `memstead_health
6840        // { include_config: true }` call. Pins the provenance path
6841        // skills rely on to distinguish explicit-init and
6842        // runtime-created registrations without a trial-create
6843        // round-trip.
6844        let tmp = TempDir::new().unwrap();
6845        let settings = memstead_base::WorkspaceSettings {
6846            mem_create_rules: vec![
6847                memstead_base::CreateRuleSetting {
6848                    pattern: "*".to_string(),
6849                    schemas: vec!["default@1.0.0".to_string()],
6850                    default_cross_links: None,
6851                },
6852                memstead_base::CreateRuleSetting {
6853                    pattern: "**".to_string(),
6854                    schemas: vec!["default@1.0.0".to_string()],
6855                    default_cross_links: None,
6856                },
6857            ],
6858            mem_delete_rules: vec![],
6859            ..Default::default()
6860        };
6861        memstead_git_branch::test_support::auto_seed_with_settings(tmp.path(), settings.clone());
6862        let mut unified = setup_unified_test_engine(tmp.path());
6863        unified.set_settings(settings);
6864        let canonical_root =
6865            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
6866        unified.set_workspace_root(canonical_root);
6867        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
6868        let target = tmp.path().join("runtime-born");
6869        let create_result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
6870            title: None,
6871            description: None,
6872            subject: None,
6873            schema_verbosity: None,
6874            write_guidance: Default::default(),
6875            name: "runtime-born".to_string(),
6876            location: target.to_string_lossy().into_owned(),
6877            schema: "default@1.0.0".to_string(),
6878
6879            vcs: None,
6880            note: Some("origin surface test".to_string()),
6881            recovery: None,
6882            include_schema: false,
6883        }));
6884        assert!(
6885            create_result.is_error.is_none() || create_result.is_error == Some(false),
6886            "create must succeed: {:?}",
6887            create_result,
6888        );
6889
6890        let health_result = server.memstead_health(Parameters(HealthParams {
6891            include: None,
6892            limit: None,
6893            mem: None,
6894            include_config: true,
6895            token_budget: None,
6896            chunk: None,
6897            target_schema: None,
6898        }));
6899        // #57: typed payload from structured_content (text is now markdown).
6900        let json: serde_json::Value = health_result.structured_content.clone().unwrap();
6901        let mems = json
6902            .get("mems")
6903            .and_then(|v| v.as_array())
6904            .expect("mems detail array present");
6905        let entry = mems
6906            .iter()
6907            .find(|v| v.get("name").and_then(|n| n.as_str()) == Some("runtime-born"))
6908            .expect("runtime-born mem must be listed");
6909        assert_eq!(
6910            entry.get("origin").and_then(|o| o.as_str()),
6911            Some("runtime_created"),
6912            "runtime-created mem must carry origin=runtime_created",
6913        );
6914    }
6915
6916    #[test]
6917    fn test_memstead_entity_found() {
6918        let (server, _tmp) = setup_dual_test_engine();
6919        let result = server.memstead_entity(Parameters(EntityParams {
6920            id: "specs--entity-a".to_string(),
6921            include_relations: None,
6922            include_context: None,
6923            sections: None,
6924            token_budget: None,
6925            chunk: None,
6926            include_provenance: None,
6927        }));
6928        assert!(!result.is_error.unwrap_or(false));
6929        let text = extract_text(&result);
6930        assert!(text.contains("# Entity A"));
6931        assert!(text.contains("_hash:"));
6932    }
6933
6934    #[test]
6935    fn test_memstead_entity_not_found() {
6936        let (server, _tmp) = setup_dual_test_engine();
6937        let result = server.memstead_entity(Parameters(EntityParams {
6938            id: "specs--nonexistent".to_string(),
6939            include_relations: None,
6940            include_context: None,
6941            sections: None,
6942            token_budget: None,
6943            chunk: None,
6944            include_provenance: None,
6945        }));
6946        assert!(result.is_error.unwrap_or(false));
6947        let text = extract_text(&result);
6948        assert!(text.contains("not found"));
6949    }
6950
6951    /// Data-origin labelling: `memstead_entity` and `memstead_search`
6952    /// stamp `origin` on the served content. An entity/hit from a writable
6953    /// mount is `first-party`; one from a read-only mount (a stand-in for
6954    /// a registry-installed read-mem or an adopted foreign folder/clone)
6955    /// is `third-party` so the consuming agent treats it as quoted,
6956    /// untrusted data.
6957    #[test]
6958    fn entity_and_search_stamp_data_origin_by_mount_capability() {
6959        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
6960
6961        let tmp = TempDir::new().unwrap();
6962        let writable_dir = tmp.path().join("writable");
6963        let readonly_dir = tmp.path().join("readonly");
6964        std::fs::create_dir_all(&writable_dir).unwrap();
6965        std::fs::create_dir_all(&readonly_dir).unwrap();
6966        std::fs::write(
6967            writable_dir.join("note.md"),
6968            "---\ntype: spec\n---\n# Note\n\n## Identity\n\nA labeltest entity.\n",
6969        )
6970        .unwrap();
6971        std::fs::write(
6972            readonly_dir.join("ext.md"),
6973            "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nA labeltest entity.\n",
6974        )
6975        .unwrap();
6976
6977        let mk = |mem: &str, dir: std::path::PathBuf, cap: MountCapability| Mount {
6978            mem: mem.to_string(),
6979            schema: Some("default@1.0.0".parse().unwrap()),
6980            storage: MountStorage::Folder { path: dir },
6981            capability: cap,
6982            lifecycle: MountLifecycle::Eager,
6983            cross_linkable: true,
6984            migration_target: None,
6985        };
6986        let mounts: Vec<(Mount, Box<dyn memstead_base::backend::MemBackend>)> = vec![
6987            {
6988                let m = mk("local", writable_dir, MountCapability::Write);
6989                let b = memstead_base::instantiate_lean_backend(&m).unwrap();
6990                (m, b)
6991            },
6992            {
6993                let m = mk("external", readonly_dir, MountCapability::ReadOnly);
6994                let b = memstead_base::instantiate_lean_backend(&m).unwrap();
6995                (m, b)
6996            },
6997        ];
6998        let engine = memstead_base::Engine::from_mounts(mounts).unwrap();
6999        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
7000
7001        // Single-entity reads carry the data-origin label.
7002        let writable_entity = server.memstead_entity(Parameters(EntityParams {
7003            id: "local--note".to_string(),
7004            include_relations: None,
7005            include_context: None,
7006            sections: None,
7007            token_budget: None,
7008            chunk: None,
7009            include_provenance: None,
7010        }));
7011        let sc = writable_entity.structured_content.clone().unwrap();
7012        assert_eq!(
7013            sc.get("origin").and_then(|o| o.as_str()),
7014            Some("first-party"),
7015            "writable-mount entity is first-party"
7016        );
7017
7018        let readonly_entity = server.memstead_entity(Parameters(EntityParams {
7019            id: "external--ext".to_string(),
7020            include_relations: None,
7021            include_context: None,
7022            sections: None,
7023            token_budget: None,
7024            chunk: None,
7025            include_provenance: None,
7026        }));
7027        let sc = readonly_entity.structured_content.clone().unwrap();
7028        assert_eq!(
7029            sc.get("origin").and_then(|o| o.as_str()),
7030            Some("third-party"),
7031            "read-only-mount entity is third-party"
7032        );
7033
7034        // Search hits carry per-hit data-origin labels keyed on each
7035        // hit's source mem.
7036        let search = server.memstead_search(Parameters(search_params_defaults()));
7037        let sc = search.structured_content.clone().unwrap();
7038        let hits = sc.get("hits").and_then(|h| h.as_array()).expect("hits[]");
7039        let origin_of = |mem: &str| -> Option<String> {
7040            hits.iter()
7041                .find(|h| h.get("mem").and_then(|v| v.as_str()) == Some(mem))
7042                .and_then(|h| h.get("origin").and_then(|o| o.as_str()))
7043                .map(|s| s.to_string())
7044        };
7045        assert_eq!(
7046            origin_of("local").as_deref(),
7047            Some("first-party"),
7048            "writable-mem hit is first-party"
7049        );
7050        assert_eq!(
7051            origin_of("external").as_deref(),
7052            Some("third-party"),
7053            "read-only-mem hit is third-party"
7054        );
7055    }
7056
7057    /// `memstead_overview` marks a read-only (third-party) mem's data
7058    /// origin at the cold-start surface so an agent learns which mems
7059    /// are untrusted before reading their content; a writable (first-party)
7060    /// mem stays unmarked.
7061    #[test]
7062    fn overview_marks_read_only_mem_third_party_origin() {
7063        use memstead_base::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
7064
7065        let tmp = TempDir::new().unwrap();
7066        let writable_dir = tmp.path().join("writable");
7067        let readonly_dir = tmp.path().join("readonly");
7068        std::fs::create_dir_all(&writable_dir).unwrap();
7069        std::fs::create_dir_all(&readonly_dir).unwrap();
7070        std::fs::write(
7071            writable_dir.join("note.md"),
7072            "---\ntype: spec\n---\n# Note\n\n## Identity\n\nLocal.\n",
7073        )
7074        .unwrap();
7075        std::fs::write(
7076            readonly_dir.join("ext.md"),
7077            "---\ntype: spec\n---\n# Ext\n\n## Identity\n\nForeign.\n",
7078        )
7079        .unwrap();
7080
7081        let mk = |mem: &str, dir: std::path::PathBuf, cap: MountCapability| Mount {
7082            mem: mem.to_string(),
7083            schema: Some("default@1.0.0".parse().unwrap()),
7084            storage: MountStorage::Folder { path: dir },
7085            capability: cap,
7086            lifecycle: MountLifecycle::Eager,
7087            cross_linkable: true,
7088            migration_target: None,
7089        };
7090        let mounts: Vec<(Mount, Box<dyn memstead_base::backend::MemBackend>)> = vec![
7091            {
7092                let m = mk("local", writable_dir, MountCapability::Write);
7093                let b = memstead_base::instantiate_lean_backend(&m).unwrap();
7094                (m, b)
7095            },
7096            {
7097                let m = mk("external", readonly_dir, MountCapability::ReadOnly);
7098                let b = memstead_base::instantiate_lean_backend(&m).unwrap();
7099                (m, b)
7100            },
7101        ];
7102        let engine = memstead_base::Engine::from_mounts(mounts).unwrap();
7103        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
7104
7105        let text = extract_text(&server.memstead_overview(Parameters(OverviewParams {
7106            rebuild: None,
7107            chunk: None,
7108            mem: None,
7109            include: None,
7110            token_budget: None,
7111        })));
7112
7113        // The read-only mem carries the third-party origin marker.
7114        let external_section = text
7115            .split("### external")
7116            .nth(1)
7117            .expect("overview lists the external mem");
7118        let external_block = external_section.split("### ").next().unwrap();
7119        assert!(
7120            external_block.contains("**Origin:** third-party"),
7121            "read-only mem must be marked third-party in overview; got:\n{external_block}"
7122        );
7123
7124        // The writable mem stays unmarked (first-party, common case).
7125        let local_section = text
7126            .split("### local")
7127            .nth(1)
7128            .expect("overview lists the local mem");
7129        let local_block = local_section.split("### ").next().unwrap();
7130        assert!(
7131            !local_block.contains("**Origin:**"),
7132            "writable mem must not carry an origin marker; got:\n{local_block}"
7133        );
7134    }
7135
7136    /// `memstead_mem_create` end-to-end through the unified engine.
7137    #[test]
7138    fn test_memstead_mem_create_via_unified_engine_path() {
7139        let tmp = setup_test_workspace();
7140        let seed_dir = tmp.path().join("seed");
7141        let writer = memstead_base::storage::FilesystemMemWriter::new(seed_dir.clone());
7142        let mount = memstead_base::Mount {
7143            mem: "seed".to_string(),
7144            schema: Some("default@1.0.0".parse().unwrap()),
7145            storage: memstead_base::MountStorage::Folder { path: seed_dir },
7146            capability: memstead_base::MountCapability::Write,
7147            lifecycle: memstead_base::MountLifecycle::Eager,
7148            cross_linkable: true,
7149            migration_target: None,
7150        };
7151        let mut unified = memstead_base::Engine::from_mounts(vec![(
7152            mount,
7153            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7154        )])
7155        .unwrap();
7156        // Install the full backend factory so create_mem's
7157        // git-branch path can materialise a writer when the
7158        // workspace-shape heuristic fires.
7159        unified.set_backend_factory(memstead_git_branch::storage::instantiate_full_backend);
7160        // Canonicalise the workspace_root so the outside_workspace
7161        // check inside create_mem compares canonical paths
7162        // consistently (macOS resolves `/var/...` → `/private/var/...`).
7163        let workspace_root = tmp.path().canonicalize().unwrap();
7164        unified.set_workspace_root(workspace_root.clone());
7165        unified.set_settings(memstead_base::WorkspaceSettings {
7166            mem_create_rules: vec![memstead_base::CreateRuleSetting {
7167                pattern: "*".to_string(),
7168                schemas: vec!["*".to_string()],
7169                default_cross_links: None,
7170            }],
7171            mem_delete_rules: Vec::new(),
7172            cross_mem_links: Default::default(),
7173            ..Default::default()
7174        });
7175        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7176
7177        let result = server.memstead_mem_create(Parameters(crate::lifecycle::MemCreateParams {
7178            title: None,
7179            description: None,
7180            subject: None,
7181            schema_verbosity: None,
7182            write_guidance: Default::default(),
7183            name: "alpha".to_string(),
7184            location: workspace_root.join("alpha").to_string_lossy().into_owned(),
7185            schema: "default@1.0.0".to_string(),
7186            vcs: None,
7187            note: Some("seed".to_string()),
7188            recovery: None,
7189            // Opt in so this end-to-end test still
7190            // sees the inlined schema body it asserts on below.
7191            include_schema: true,
7192        }));
7193        assert!(
7194            !result.is_error.unwrap_or(false),
7195            "expected success; got {}",
7196            extract_text(&result)
7197        );
7198        let text = extract_text(&result);
7199        // Response carries the load-bearing fields.
7200        assert!(text.contains("\"name\""));
7201        assert!(text.contains("\"location\""));
7202        assert!(text.contains("\"schema_ref\""));
7203        assert!(text.contains("\"seed_commit_sha\""));
7204        // The new mem's name surfaces.
7205        assert!(text.contains("alpha"));
7206        // Schema priming payload is folded in.
7207        assert!(text.contains("\"schema\""));
7208
7209        // Surface parity (Plan 01): the include_schema inline path honours
7210        // `schema_verbosity: "lite"` — a first-mem create can prime on the
7211        // cheap skeleton instead of the ~25 KB full body.
7212        let lite_create =
7213            server.memstead_mem_create(Parameters(crate::lifecycle::MemCreateParams {
7214                title: None,
7215                description: None,
7216                subject: None,
7217                schema_verbosity: Some("lite".to_string()),
7218                write_guidance: Default::default(),
7219                name: "beta".to_string(),
7220                location: workspace_root.join("beta").to_string_lossy().into_owned(),
7221                schema: "default@1.0.0".to_string(),
7222                vcs: None,
7223                note: Some("seed".to_string()),
7224                recovery: None,
7225                include_schema: true,
7226            }));
7227        assert!(
7228            !lite_create.is_error.unwrap_or(false),
7229            "lite create must succeed; got {}",
7230            extract_text(&lite_create)
7231        );
7232        let inlined = lite_create.structured_content.unwrap();
7233        let schema_body = &inlined["schema"];
7234        assert!(
7235            schema_body["types_summary"].is_array(),
7236            "include_schema=lite inlines the skeleton, got {schema_body}"
7237        );
7238        assert!(
7239            schema_body.get("types").is_none(),
7240            "lite inline drops the rich types[] array"
7241        );
7242
7243        // An unknown schema_verbosity refuses up front (before the mem
7244        // lands) rather than silently inlining full.
7245        let bad = server.memstead_mem_create(Parameters(crate::lifecycle::MemCreateParams {
7246            title: None,
7247            description: None,
7248            subject: None,
7249            schema_verbosity: Some("brief".to_string()),
7250            write_guidance: Default::default(),
7251            name: "gamma".to_string(),
7252            location: workspace_root.join("gamma").to_string_lossy().into_owned(),
7253            schema: "default@1.0.0".to_string(),
7254            vcs: None,
7255            note: Some("seed".to_string()),
7256            recovery: None,
7257            include_schema: true,
7258        }));
7259        assert!(
7260            bad.is_error.unwrap_or(false),
7261            "unknown schema_verbosity refuses"
7262        );
7263        assert_eq!(bad.structured_content.unwrap()["code"], "INVALID_INPUT");
7264    }
7265
7266    /// `memstead_mem_delete` end-to-end through the unified engine.
7267    #[test]
7268    fn test_memstead_mem_delete_via_unified_engine_path() {
7269        let tmp = setup_test_workspace();
7270        let target_dir = tmp.path().join("specs");
7271        let writer = memstead_base::storage::FilesystemMemWriter::new(target_dir.clone());
7272        let mount = memstead_base::Mount {
7273            mem: "specs".to_string(),
7274            schema: Some("default@1.0.0".parse().unwrap()),
7275            storage: memstead_base::MountStorage::Folder {
7276                path: target_dir.clone(),
7277            },
7278            capability: memstead_base::MountCapability::Write,
7279            lifecycle: memstead_base::MountLifecycle::Eager,
7280            cross_linkable: true,
7281            migration_target: None,
7282        };
7283        let mut unified = memstead_base::Engine::from_mounts(vec![(
7284            mount,
7285            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7286        )])
7287        .unwrap();
7288        unified.set_settings(memstead_base::WorkspaceSettings {
7289            mem_create_rules: Vec::new(),
7290            mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
7291                pattern: "*".to_string(),
7292            }],
7293            cross_mem_links: Default::default(),
7294            ..Default::default()
7295        });
7296        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7297
7298        let result = server.memstead_mem_delete(Parameters(crate::lifecycle::MemDeleteParams {
7299            name: "specs".to_string(),
7300            note: None,
7301        }));
7302        assert!(
7303            !result.is_error.unwrap_or(false),
7304            "expected success; got {}",
7305            extract_text(&result)
7306        );
7307        let text = extract_text(&result);
7308        assert!(text.contains("\"deleted_from_router\""));
7309        assert!(text.contains("\"files_deleted\""));
7310        assert!(text.contains("\"name\""));
7311        assert!(text.contains("specs"));
7312    }
7313
7314    /// `memstead_overview` end-to-end through the unified engine.
7315    /// Verifies the response carries the load-bearing markdown
7316    /// sections (`## Schemas`, `## Mems`, `## Communities`,
7317    /// `## Lifecycle Namespaces`).
7318    #[test]
7319    fn test_memstead_overview_via_unified_engine_path() {
7320        let tmp = setup_test_workspace();
7321        let mem_dir = tmp.path().join("specs");
7322        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7323        let mount = memstead_base::Mount {
7324            mem: "specs".to_string(),
7325            schema: Some("default@1.0.0".parse().unwrap()),
7326            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7327            capability: memstead_base::MountCapability::Write,
7328            lifecycle: memstead_base::MountLifecycle::Eager,
7329            cross_linkable: true,
7330            migration_target: None,
7331        };
7332        let unified = memstead_base::Engine::from_mounts(vec![(
7333            mount,
7334            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7335        )])
7336        .unwrap();
7337        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7338
7339        let result = server.memstead_overview(Parameters(OverviewParams {
7340            mem: None,
7341            include: None,
7342            token_budget: None,
7343            chunk: None,
7344            rebuild: None,
7345        }));
7346        assert!(!result.is_error.unwrap_or(false));
7347        let text = extract_text(&result);
7348        // Frontmatter present.
7349        assert!(text.starts_with("---\n"));
7350        assert!(text.contains("_overview_mode:"));
7351        assert!(text.contains("_cluster_count:"));
7352        // Load-bearing markdown sections.
7353        assert!(text.contains("## Lifecycle Namespaces"));
7354        assert!(text.contains("## Schemas"));
7355        assert!(text.contains("## Mems"));
7356        assert!(text.contains("## Communities"));
7357        // The fixture mem should surface.
7358        assert!(text.contains("### specs"));
7359        // The fixture schema ref should surface.
7360        assert!(text.contains("default@1.0.0"));
7361    }
7362
7363    /// `memstead_health` default body end-to-end through the unified
7364    /// engine. Verifies the default-shape response carries
7365    /// `writable_mems`, `read_mems`, `mem_schemas`, summary
7366    /// counts, and edge totals.
7367    #[test]
7368    fn test_memstead_health_default_body_via_unified_engine_path() {
7369        let tmp = setup_test_workspace();
7370        let mem_dir = tmp.path().join("specs");
7371        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7372        let mount = memstead_base::Mount {
7373            mem: "specs".to_string(),
7374            schema: Some("default@1.0.0".parse().unwrap()),
7375            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7376            capability: memstead_base::MountCapability::Write,
7377            lifecycle: memstead_base::MountLifecycle::Eager,
7378            cross_linkable: true,
7379            migration_target: None,
7380        };
7381        let unified = memstead_base::Engine::from_mounts(vec![(
7382            mount,
7383            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7384        )])
7385        .unwrap();
7386        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7387
7388        // Default body (no include_config) routes through unified.
7389        let result = server.memstead_health(Parameters(HealthParams {
7390            include: None,
7391            limit: None,
7392            mem: None,
7393            include_config: false,
7394            token_budget: None,
7395            chunk: None,
7396            target_schema: None,
7397        }));
7398        assert!(!result.is_error.unwrap_or(false));
7399        let text = extract_text(&result);
7400        // Default-shape fields present.
7401        assert!(text.contains("\"writable_mems\""));
7402        assert!(text.contains("\"read_mems\""));
7403        assert!(text.contains("\"mem_schemas\""));
7404        assert!(text.contains("\"summary\""));
7405        assert!(text.contains("\"total_entities\""));
7406        assert!(text.contains("\"edge_types\""));
7407        // The fixture mem should surface.
7408        assert!(text.contains("\"specs\""));
7409    }
7410
7411    /// `memstead_health` with `include_config: true` end-to-end. The
7412    /// per-mem `mems` detail block surfaces `origin`, the
7413    /// optional `vcs` block, and the parsed
7414    /// `.memstead/config.json` (`write_guidance` + `extra`). F6
7415    /// renamed the wire-facing key from camelCase `writeGuidance`
7416    /// to snake_case `write_guidance` (the on-disk JSON key stays
7417    /// `writeGuidance` — that file format is human-authored and
7418    /// retains its existing shape).
7419    #[test]
7420    fn test_memstead_health_include_config_via_unified_engine_path() {
7421        let tmp = setup_test_workspace();
7422        let mem_dir = tmp.path().join("specs");
7423
7424        // Drop a `.memstead/config.json` so `mem_config_for` surfaces
7425        // a non-empty `write_guidance` block in the response.
7426        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
7427        let config_body = r#"{
7428            "format": 1,
7429            "schema": "default@1.0.0",
7430            "writeGuidance": { "tone": "formal" }
7431        }"#;
7432        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
7433
7434        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7435        let mount = memstead_base::Mount {
7436            mem: "specs".to_string(),
7437            schema: Some("default@1.0.0".parse().unwrap()),
7438            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7439            capability: memstead_base::MountCapability::Write,
7440            lifecycle: memstead_base::MountLifecycle::Eager,
7441            cross_linkable: true,
7442            migration_target: None,
7443        };
7444        let unified = memstead_base::Engine::from_mounts(vec![(
7445            mount,
7446            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7447        )])
7448        .unwrap();
7449        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7450
7451        let result = server.memstead_health(Parameters(HealthParams {
7452            include: None,
7453            limit: None,
7454            mem: None,
7455            include_config: true,
7456            token_budget: None,
7457            chunk: None,
7458            target_schema: None,
7459        }));
7460        assert!(!result.is_error.unwrap_or(false));
7461        let payload = result
7462            .structured_content
7463            .as_ref()
7464            .expect("structured_content present");
7465        assert!(
7466            payload.get("mutations").is_some(),
7467            "mutations present: {payload}"
7468        );
7469        assert!(payload.get("plugin").is_some(), "plugin present: {payload}");
7470        let mems = payload["mems"].as_array().expect("mems[] array");
7471        let entry = mems
7472            .iter()
7473            .find(|v| v["name"] == "specs")
7474            .expect("specs entry");
7475        // Folder mount: vcs.worktree must be present (gitdir is
7476        // git-branch-only; head may be absent on a fresh mem).
7477        let vcs = entry["vcs"]
7478            .as_object()
7479            .expect("vcs block present for folder mount");
7480        assert!(
7481            vcs.contains_key("worktree"),
7482            "vcs.worktree present: {vcs:?}"
7483        );
7484        // Snake_case rename — old camelCase must NOT appear on the wire.
7485        assert!(
7486            entry.get("write_guidance").is_some(),
7487            "write_guidance present"
7488        );
7489        assert!(
7490            entry.get("writeGuidance").is_none(),
7491            "legacy camelCase writeGuidance must be gone: {entry}",
7492        );
7493        assert_eq!(entry["write_guidance"]["tone"], "formal");
7494        // `extra` is documented as the forward-compat catch-all; the
7495        // fixture supplies no unknown keys, so it serialises as an
7496        // empty object.
7497        assert!(entry.get("extra").is_some(), "extra present: {entry}");
7498    }
7499
7500    /// `memstead_entity` end-to-end through the unified engine —
7501    /// found-path + not-found.
7502    #[test]
7503    fn test_memstead_entity_via_unified_engine_path() {
7504        let tmp = setup_test_workspace();
7505        let mem_dir = tmp.path().join("specs");
7506        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7507        let mount = memstead_base::Mount {
7508            mem: "specs".to_string(),
7509            schema: Some("default@1.0.0".parse().unwrap()),
7510            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7511            capability: memstead_base::MountCapability::Write,
7512            lifecycle: memstead_base::MountLifecycle::Eager,
7513            cross_linkable: true,
7514            migration_target: None,
7515        };
7516        let unified = memstead_base::Engine::from_mounts(vec![(
7517            mount,
7518            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7519        )])
7520        .unwrap();
7521        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7522
7523        // Found-path: same shape as test_memstead_entity_found.
7524        let result = server.memstead_entity(Parameters(EntityParams {
7525            id: "specs--entity-a".to_string(),
7526            include_relations: None,
7527            include_context: None,
7528            sections: None,
7529            token_budget: None,
7530            chunk: None,
7531            include_provenance: None,
7532        }));
7533        assert!(!result.is_error.unwrap_or(false));
7534        let text = extract_text(&result);
7535        assert!(text.contains("# Entity A"));
7536        assert!(text.contains("_hash:"));
7537
7538        // Not-found path on the unified branch.
7539        let missing = server.memstead_entity(Parameters(EntityParams {
7540            id: "specs--nope".to_string(),
7541            include_relations: None,
7542            include_context: None,
7543            sections: None,
7544            token_budget: None,
7545            chunk: None,
7546            include_provenance: None,
7547        }));
7548        assert!(missing.is_error.unwrap_or(false));
7549        assert!(extract_text(&missing).contains("not found"));
7550    }
7551
7552    /// `memstead_schema` end-to-end. The engine's per-mem HashMap
7553    /// shape iterates values for name+version lookup; the not-found
7554    /// envelope ships an empty suggestions list. `used_by` derives
7555    /// from `engine.mounts()`.
7556    #[test]
7557    fn test_memstead_schema_via_unified_engine_path() {
7558        let tmp = setup_test_workspace();
7559        let mem_dir = tmp.path().join("specs");
7560        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7561        let mount = memstead_base::Mount {
7562            mem: "specs".to_string(),
7563            schema: Some("default@1.0.0".parse().unwrap()),
7564            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7565            capability: memstead_base::MountCapability::Write,
7566            lifecycle: memstead_base::MountLifecycle::Eager,
7567            cross_linkable: true,
7568            migration_target: None,
7569        };
7570        let unified = memstead_base::Engine::from_mounts(vec![(
7571            mount,
7572            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7573        )])
7574        .unwrap();
7575        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7576
7577        // Found path: bare-name lookup picks the first matching schema.
7578        let result = server.memstead_schema(Parameters(SchemaParams {
7579            verbosity: None,
7580            name: Some("default".to_string()),
7581            mem: None,
7582        }));
7583        assert!(!result.is_error.unwrap_or(false));
7584        let text = extract_text(&result);
7585        assert!(text.contains("\"ref\""));
7586        assert!(text.contains("\"used_by\""));
7587        // The fixture mem "specs" pins default@1.0.0 → it appears in used_by.
7588        assert!(text.contains("\"specs\""));
7589
7590        // Intra-mem
7591        // relationship entries surface `allowed_sources` and
7592        // `allowed_targets` so agents can pre-filter rel-types for
7593        // their `(from_type, to_type)` pair without trial-and-error
7594        // against `INVALID_REL_SHAPE`. The field names mirror the
7595        // error envelope's `allowed_source_types` /
7596        // `allowed_target_types` payload (modulo the trimmed
7597        // `_types` suffix). Empty arrays = "any type admitted"
7598        // (no pinning).
7599        assert!(
7600            text.contains("\"allowed_sources\""),
7601            "schema response must surface allowed_sources per relationship; got:\n{text}"
7602        );
7603        assert!(
7604            text.contains("\"allowed_targets\""),
7605            "schema response must surface allowed_targets per relationship; got:\n{text}"
7606        );
7607
7608        // Not-found path: text content carries the message. The
7609        // empty suggestions array lives on structured_content (the
7610        // envelope payload) — not asserted in the text body.
7611        let missing = server.memstead_schema(Parameters(SchemaParams {
7612            verbosity: None,
7613            name: Some("no-such-schema".to_string()),
7614            mem: None,
7615        }));
7616        assert!(missing.is_error.unwrap_or(false));
7617        let text = extract_text(&missing);
7618        assert!(text.contains("schema not found"));
7619        // Unified path drops suggest_name → no "Did you mean" suffix.
7620        assert!(!text.contains("Did you mean"));
7621    }
7622
7623    /// `memstead_schema` honours the `verbosity` toggle: an absent value
7624    /// is exactly the lite structural skeleton (the cold-start default —
7625    /// a fresh session pays the skeleton price), `full` returns the
7626    /// complete prose payload, and an unrecognized value refuses typed
7627    /// rather than silently falling back.
7628    #[test]
7629    fn test_memstead_schema_verbosity_toggle() {
7630        let tmp = setup_test_workspace();
7631        let mem_dir = tmp.path().join("specs");
7632        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7633        let mount = memstead_base::Mount {
7634            mem: "specs".to_string(),
7635            schema: Some("default@1.0.0".parse().unwrap()),
7636            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7637            capability: memstead_base::MountCapability::Write,
7638            lifecycle: memstead_base::MountLifecycle::Eager,
7639            cross_linkable: true,
7640            migration_target: None,
7641        };
7642        let unified = memstead_base::Engine::from_mounts(vec![(
7643            mount,
7644            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7645        )])
7646        .unwrap();
7647        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7648
7649        let call = |verbosity: Option<&str>| {
7650            server.memstead_schema(Parameters(SchemaParams {
7651                verbosity: verbosity.map(|s| s.to_string()),
7652                name: Some("default".to_string()),
7653                mem: None,
7654            }))
7655        };
7656
7657        // Lite: structural skeleton under the distinct keys, prose dropped,
7658        // alias pointer + endpoints retained.
7659        let lite = call(Some("lite"));
7660        assert!(!lite.is_error.unwrap_or(false));
7661        let lite_body = lite.structured_content.clone().unwrap();
7662        assert!(
7663            lite_body["types_summary"].is_array(),
7664            "lite has types_summary"
7665        );
7666        assert!(
7667            lite_body["relationships_summary"].is_array(),
7668            "lite has relationships_summary"
7669        );
7670        assert!(lite_body.get("types").is_none(), "lite omits rich types");
7671        assert!(
7672            lite_body.get("description").is_none(),
7673            "lite drops schema description prose"
7674        );
7675        assert_eq!(lite_body["alias_target_rel_type"], "REFERENCES");
7676
7677        // Absent verbosity == lite == explicit "lite" (byte-identical) —
7678        // the schema cold-start costs the skeleton price by default.
7679        let default_body = call(None).structured_content.unwrap();
7680        assert_eq!(default_body, lite_body, "absent verbosity must equal lite");
7681        let full_body = call(Some("full")).structured_content.unwrap();
7682        assert!(full_body["types"].is_array(), "full keeps rich types");
7683        assert!(full_body["description"].is_string(), "full keeps prose");
7684
7685        // Lite is smaller than full on the same mem.
7686        let lite_len = serde_json::to_string(&lite_body).unwrap().len();
7687        let full_len = serde_json::to_string(&full_body).unwrap().len();
7688        assert!(lite_len < full_len, "lite ({lite_len}) < full ({full_len})");
7689
7690        // Unknown value: typed INVALID_INPUT naming the bad value, no
7691        // silent fallback.
7692        let bogus = call(Some("brief"));
7693        assert!(
7694            bogus.is_error.unwrap_or(false),
7695            "unknown verbosity must error"
7696        );
7697        let env = bogus.structured_content.clone().unwrap();
7698        assert_eq!(env["code"], "INVALID_INPUT");
7699        assert_eq!(env["details"]["value"], "brief");
7700        assert!(
7701            extract_text(&bogus).contains("brief"),
7702            "error names the bad value"
7703        );
7704    }
7705
7706    /// `memstead_schema` resolves built-in schemas even when no mem pins
7707    /// them. Closes the documented discovery contract:
7708    /// `memstead_overview` advertises lifecycle namespaces whose schemas
7709    /// (`default@1.0.0`, `planning@0.1.0`) the workspace does not
7710    /// necessarily pin yet; an agent must still be able to introspect
7711    /// the schema by name before committing to `memstead_mem_create`.
7712    /// The unified engine's catalogue cascade (mem-pinned →
7713    /// workspace → built-ins) walks all three on every call.
7714    #[test]
7715    fn test_memstead_schema_resolves_builtin_when_no_mem_pins_it() {
7716        let tmp = setup_test_workspace();
7717        let mem_dir = tmp.path().join("specs");
7718        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7719        // Pin a builtin that's NOT `planning@0.1.0` so the planning
7720        // resolution path must go through the builtin catalogue.
7721        let mount = memstead_base::Mount {
7722            mem: "specs".to_string(),
7723            schema: Some("default@1.0.0".parse().unwrap()),
7724            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7725            capability: memstead_base::MountCapability::Write,
7726            lifecycle: memstead_base::MountLifecycle::Eager,
7727            cross_linkable: true,
7728            migration_target: None,
7729        };
7730        let unified = memstead_base::Engine::from_mounts(vec![(
7731            mount,
7732            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7733        )])
7734        .unwrap();
7735        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7736
7737        // `default@1.0.0` is also a builtin; pinned-by-mem resolves
7738        // first but the canonical-pin path is what agents call.
7739        let result = server.memstead_schema(Parameters(SchemaParams {
7740            verbosity: None,
7741            name: Some("default@1.0.0".to_string()),
7742            mem: None,
7743        }));
7744        assert!(!result.is_error.unwrap_or(false));
7745        let text = extract_text(&result);
7746        assert!(text.contains("\"ref\""));
7747        assert!(text.contains("\"default@1.0.0\""));
7748
7749        // `planning@0.1.0` — no mem pins this; resolution must fall
7750        // through to the builtin catalogue. Prior to the fix this
7751        // returned ENTITY_NOT_FOUND.
7752        let result = server.memstead_schema(Parameters(SchemaParams {
7753            verbosity: None,
7754            name: Some("planning@0.1.0".to_string()),
7755            mem: None,
7756        }));
7757        assert!(
7758            !result.is_error.unwrap_or(false),
7759            "planning@0.1.0 must resolve via the builtin catalogue: {}",
7760            extract_text(&result)
7761        );
7762        let text = extract_text(&result);
7763        assert!(text.contains("\"ref\""));
7764        assert!(text.contains("\"planning@0.1.0\""));
7765        // No mem pins planning, so used_by[] is empty.
7766        assert!(text.contains("\"used_by\": []"));
7767
7768        // Bare-name lookup also routes through the cascade.
7769        let result = server.memstead_schema(Parameters(SchemaParams {
7770            verbosity: None,
7771            name: Some("planning".to_string()),
7772            mem: None,
7773        }));
7774        assert!(!result.is_error.unwrap_or(false));
7775        let text = extract_text(&result);
7776        assert!(text.contains("\"planning@0.1.0\""));
7777    }
7778
7779    /// `memstead_schema(mem=<name>)` resolves the mem's pinned
7780    /// `schema_ref` from the mount roster — closes the one-hop
7781    /// round-trip an agent would otherwise pay when it cold-starts
7782    /// through `memstead_overview` and wants to write against a specific
7783    /// mem. Same wire shape as the `name`-driven path; the only
7784    /// difference is the lookup key.
7785    #[test]
7786    fn test_memstead_schema_mem_shortcut() {
7787        let tmp = setup_test_workspace();
7788        let mem_dir = tmp.path().join("specs");
7789        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7790        let mount = memstead_base::Mount {
7791            mem: "specs".to_string(),
7792            schema: Some("default@1.0.0".parse().unwrap()),
7793            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7794            capability: memstead_base::MountCapability::Write,
7795            lifecycle: memstead_base::MountLifecycle::Eager,
7796            cross_linkable: true,
7797            migration_target: None,
7798        };
7799        let unified = memstead_base::Engine::from_mounts(vec![(
7800            mount,
7801            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7802        )])
7803        .unwrap();
7804        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7805
7806        // Happy path: mem → pinned schema.
7807        let result = server.memstead_schema(Parameters(SchemaParams {
7808            verbosity: None,
7809            name: None,
7810            mem: Some("specs".to_string()),
7811        }));
7812        assert!(
7813            !result.is_error.unwrap_or(false),
7814            "{:?}",
7815            extract_text(&result)
7816        );
7817        let text = extract_text(&result);
7818        assert!(text.contains("\"default@1.0.0\""));
7819        assert!(text.contains("\"specs\""));
7820
7821        // Unknown mem: typed UNKNOWN_MEM with `details.known_mems`.
7822        let result = server.memstead_schema(Parameters(SchemaParams {
7823            verbosity: None,
7824            name: None,
7825            mem: Some("not-a-mem".to_string()),
7826        }));
7827        assert!(result.is_error.unwrap_or(false));
7828        let text = extract_text(&result);
7829        assert!(text.contains("UNKNOWN_MEM"), "got: {text}");
7830        let envelope = result.structured_content.as_ref().unwrap();
7831        assert_eq!(envelope["code"], "UNKNOWN_MEM");
7832        let known = envelope["details"]["known_mems"].as_array().unwrap();
7833        assert!(known.iter().any(|v| v == "specs"));
7834
7835        // Conflict: both name and mem → INVALID_INPUT.
7836        let result = server.memstead_schema(Parameters(SchemaParams {
7837            verbosity: None,
7838            name: Some("default".to_string()),
7839            mem: Some("specs".to_string()),
7840        }));
7841        assert!(result.is_error.unwrap_or(false));
7842        let envelope = result.structured_content.as_ref().unwrap();
7843        assert_eq!(envelope["code"], "INVALID_INPUT");
7844
7845        // Neither: also INVALID_INPUT.
7846        let result = server.memstead_schema(Parameters(SchemaParams {
7847            verbosity: None,
7848            name: None,
7849            mem: None,
7850        }));
7851        assert!(result.is_error.unwrap_or(false));
7852        let envelope = result.structured_content.as_ref().unwrap();
7853        assert_eq!(envelope["code"], "INVALID_INPUT");
7854    }
7855
7856    /// `memstead_reload` end-to-end through the unified engine. Asserts
7857    /// the rich-shape `reports[]` wire contract.
7858    #[test]
7859    fn test_memstead_reload_via_unified_engine_path() {
7860        let tmp = setup_test_workspace();
7861        let mem_dir = tmp.path().join("specs");
7862        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
7863        let mount = memstead_base::Mount {
7864            mem: "specs".to_string(),
7865            schema: Some("default@1.0.0".parse().unwrap()),
7866            storage: memstead_base::MountStorage::Folder { path: mem_dir },
7867            capability: memstead_base::MountCapability::Write,
7868            lifecycle: memstead_base::MountLifecycle::Eager,
7869            cross_linkable: true,
7870            migration_target: None,
7871        };
7872        let unified = memstead_base::Engine::from_mounts(vec![(
7873            mount,
7874            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
7875        )])
7876        .unwrap();
7877        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
7878
7879        let result = server.memstead_reload(Parameters(ReloadParams {
7880            mem: Some("specs".to_string()),
7881            full: None,
7882        }));
7883        assert!(!result.is_error.unwrap_or(false));
7884        let text = extract_text(&result);
7885        // Rich-shape response: each report carries mem, head_before,
7886        // head_after, entities_loaded, changed_entity_ids.
7887        assert!(text.contains("\"reports\""));
7888        assert!(text.contains("\"mem\""));
7889        assert!(text.contains("\"head_before\""));
7890        assert!(text.contains("\"head_after\""));
7891        assert!(text.contains("\"entities_loaded\""));
7892        assert!(text.contains("\"changed_entity_ids\""));
7893        // Default form carries NO refresh block — the content reload
7894        // is unchanged for callers that don't ask for the full mode.
7895        assert!(
7896            !text.contains("\"refresh\""),
7897            "default reload must not carry a refresh block: {text}"
7898        );
7899        // `full` is workspace-scoped: combining it with `mem` refuses.
7900        let result = server.memstead_reload(Parameters(ReloadParams {
7901            mem: Some("specs".to_string()),
7902            full: Some(true),
7903        }));
7904        assert!(result.is_error.unwrap_or(false));
7905        let envelope = result.structured_content.as_ref().unwrap();
7906        assert_eq!(envelope["code"], "INVALID_INPUT");
7907    }
7908
7909    /// Plan 12 end-to-end: the EXACT sequence the plenum channel
7910    /// reported blocked — install a new schema out of band, create a
7911    /// mem pinned to it in-band, write an entity into it — completes
7912    /// warm, with `memstead_reload full=true` as the only extra step
7913    /// and no process restart. Pre-refresh, the same `mem_create`
7914    /// still fails with `SCHEMA_NOT_FOUND` (the refresh changes the
7915    /// outcome, not incidental timing).
7916    #[test]
7917    fn test_memstead_reload_full_closes_the_out_of_band_schema_gap() {
7918        use memstead_base::workspace_store::WorkspaceStoreAdapter;
7919
7920        let tmp = TempDir::new().unwrap();
7921        // Canonicalize so the mem-create location gate (which
7922        // canonicalizes candidates) sees paths inside the root even
7923        // on macOS's symlinked tmp.
7924        let root = tmp.path().canonicalize().unwrap();
7925        let root = root.as_path();
7926        let mem_dir = root.join("specs");
7927        fs::create_dir_all(&mem_dir).unwrap();
7928        fs::create_dir_all(root.join(".memstead")).unwrap();
7929        // Workspace policy on disk: wildcard create rule, so the
7930        // in-band `memstead_mem_create` is admitted (and survives the
7931        // refresh's settings re-read).
7932        fs::write(
7933            root.join(".memstead").join("workspace.toml"),
7934            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n\n[[mem_management.create]]\npattern = \"*\"\nschemas = [\"*\"]\n",
7935        )
7936        .unwrap();
7937        let mount = memstead_base::Mount {
7938            mem: "specs".to_string(),
7939            schema: Some("default@1.0.0".parse().unwrap()),
7940            storage: memstead_base::MountStorage::Folder {
7941                path: mem_dir.clone(),
7942            },
7943            capability: memstead_base::MountCapability::Write,
7944            lifecycle: memstead_base::MountLifecycle::Eager,
7945            cross_linkable: true,
7946            migration_target: None,
7947        };
7948        memstead_base::FileWorkspaceStore::new()
7949            .save_state(
7950                root,
7951                &memstead_base::workspace::Workspace {
7952                    mounts: vec![mount],
7953                    settings: memstead_base::workspace::WorkspaceSettings::default(),
7954                },
7955            )
7956            .unwrap();
7957        let engine = memstead_base::Engine::from_workspace_root(root).expect("workspace boots");
7958        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
7959
7960        // Out of band, while the server runs: install a schema the
7961        // way `memstead schema install` does on a folder workspace —
7962        // the package lands under `.memstead/schemas/<name>@<ver>/`.
7963        let pkg = root
7964            .join(".memstead")
7965            .join("schemas")
7966            .join("authored@0.1.0");
7967        fs::create_dir_all(pkg.join("types")).unwrap();
7968        fs::write(
7969            pkg.join("schema.yaml"),
7970            r#"name: authored
7971version: 0.1.0
7972description: out-of-band installed schema
7973when_to_use: tests
7974types:
7975  - doc
7976relationships:
7977  mode: strict
7978  definitions:
7979    - name: _default
7980      description: fallback
7981      default_weight: 1.0
7982community:
7983  resolution: 1.0
7984  seed: 42
7985"#,
7986        )
7987        .unwrap();
7988        fs::write(
7989            pkg.join("types").join("doc.yaml"),
7990            r#"name: doc
7991description: t
7992when_to_use: tests
7993sections:
7994  - key: body
7995    heading: Body
7996    required: true
7997    search_weight: 10.0
7998    catch_all: true
7999    write_rules: []
8000metadata_fields: []
8001title_weight: 100.0
8002text_fields:
8003  - body
8004hierarchy_relationship: _default
8005no_self_loop_relationships: []
8006updatable_fields:
8007  - title
8008  - body
8009health_required_fields:
8010  - body
8011staleness_threshold_days: 90
8012write_rules: []
8013"#,
8014        )
8015        .unwrap();
8016
8017        let mem_create = |name: &str| {
8018            server.memstead_mem_create(Parameters(crate::lifecycle::MemCreateParams {
8019                title: None,
8020                description: None,
8021                subject: None,
8022                name: name.to_string(),
8023                location: name.to_string(),
8024                schema: "authored@0.1.0".to_string(),
8025                vcs: None,
8026                note: None,
8027                recovery: None,
8028                include_schema: false,
8029                schema_verbosity: None,
8030                write_guidance: Default::default(),
8031            }))
8032        };
8033
8034        // Refusal complement: BEFORE the refresh, the in-band create
8035        // still fails with SCHEMA_NOT_FOUND — this is the reported
8036        // five-round blockage.
8037        let result = mem_create("notes");
8038        assert!(result.is_error.unwrap_or(false));
8039        let envelope = result.structured_content.as_ref().unwrap();
8040        assert_eq!(envelope["code"], "SCHEMA_NOT_FOUND", "{envelope}");
8041
8042        // The full refresh makes the schema resolvable, warm.
8043        let result = server.memstead_reload(Parameters(ReloadParams {
8044            mem: None,
8045            full: Some(true),
8046        }));
8047        assert!(!result.is_error.unwrap_or(false), "{result:?}");
8048        let text = extract_text(&result);
8049        assert!(text.contains("\"refresh\""), "{text}");
8050        assert!(text.contains("authored@0.1.0"), "{text}");
8051
8052        // In-band mem create pinned to the fresh schema now succeeds…
8053        let result = mem_create("notes");
8054        assert!(
8055            !result.is_error.unwrap_or(false),
8056            "mem_create must succeed after the refresh: {result:?}"
8057        );
8058
8059        // …and an entity lands in it. End-to-end closure, no restart.
8060        let result = server.memstead_create(Parameters(crate::tools::mutation::CreateParams {
8061            title: "First Entry".to_string(),
8062            entity_type: "doc".to_string(),
8063            mem: Some("notes".to_string()),
8064            sections: Some(indexmap::IndexMap::from_iter([(
8065                "body".to_string(),
8066                "written warm".to_string(),
8067            )])),
8068            metadata: None,
8069            relations: None,
8070            anchors: None,
8071            dry_run: None,
8072            note: None,
8073            role: None,
8074        }));
8075        assert!(
8076            !result.is_error.unwrap_or(false),
8077            "entity create into the fresh mem must succeed: {result:?}"
8078        );
8079    }
8080
8081    /// `memstead_changes_since` end-to-end with `include_notes = false`.
8082    /// Folder-backed mem with no changelog returns an empty
8083    /// `changes` array but still produces a well-formed response
8084    /// carrying `mem`, `since`, `head`.
8085    #[test]
8086    fn test_memstead_changes_since_via_unified_engine_path() {
8087        let tmp = setup_test_workspace();
8088        let mem_dir = tmp.path().join("specs");
8089        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
8090        let mount = memstead_base::Mount {
8091            mem: "specs".to_string(),
8092            schema: Some("default@1.0.0".parse().unwrap()),
8093            storage: memstead_base::MountStorage::Folder { path: mem_dir },
8094            capability: memstead_base::MountCapability::Write,
8095            lifecycle: memstead_base::MountLifecycle::Eager,
8096            cross_linkable: true,
8097            migration_target: None,
8098        };
8099        let unified = memstead_base::Engine::from_mounts(vec![(
8100            mount,
8101            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
8102        )])
8103        .unwrap();
8104        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
8105
8106        let result = server.memstead_changes_since(Parameters(ChangesSinceParams {
8107            mem: "specs".to_string(),
8108            since: memstead_base::ops::EMPTY_TREE_SHA.to_string(),
8109            rename_similarity: None,
8110            include_notes: false,
8111        }));
8112        assert!(!result.is_error.unwrap_or(false));
8113        let text = extract_text(&result);
8114        // Unified ChangesReport shape: mem + since + head + changes
8115        // (notes / memstead_ref absent — those are git-branch-specific).
8116        assert!(text.contains("\"mem\""));
8117        assert!(text.contains("\"since\""));
8118        assert!(text.contains("\"head\""));
8119        assert!(text.contains("\"changes\""));
8120    }
8121
8122    /// Validation envelopes from the mutation handlers carry the
8123    /// recovery payload: UNKNOWN_SECTION ships
8124    /// `details.declared` + `suggestion`, INVALID_REL_TYPE ships
8125    /// `details.allowed[]` + `suggestion`, etc.
8126    #[test]
8127    fn test_unified_validation_envelopes_carry_recovery_payload() {
8128        let tmp = setup_test_workspace();
8129        let mem_dir = tmp.path().join("specs");
8130        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
8131        let mount = memstead_base::Mount {
8132            mem: "specs".to_string(),
8133            schema: Some("default@1.0.0".parse().unwrap()),
8134            storage: memstead_base::MountStorage::Folder { path: mem_dir },
8135            capability: memstead_base::MountCapability::Write,
8136            lifecycle: memstead_base::MountLifecycle::Eager,
8137            cross_linkable: true,
8138            migration_target: None,
8139        };
8140        let unified = memstead_base::Engine::from_mounts(vec![(
8141            mount,
8142            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
8143        )])
8144        .unwrap();
8145        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
8146
8147        // INVALID_REL_TYPE: relate with a vocabulary that doesn't
8148        // exist on the strict-mode default schema.
8149        let bad_rel = server.memstead_relate(Parameters(RelateParams {
8150            relations: vec![RelateOpInput {
8151                from: "specs--entity-a".to_string(),
8152                to: "specs--entity-b".to_string(),
8153                r#type: "TOTALLY_MADE_UP_REL".to_string(),
8154                remove: None,
8155                description: None,
8156            }],
8157            note: None,
8158            role: None,
8159            dry_run: None,
8160        }));
8161        assert!(bad_rel.is_error.unwrap_or(false));
8162        let body = bad_rel.structured_content.unwrap();
8163        assert_eq!(body["code"], "INVALID_REL_TYPE");
8164        assert_eq!(body["details"]["input"], "TOTALLY_MADE_UP_REL");
8165        assert!(
8166            body["details"]["allowed"].is_array(),
8167            "details.allowed must list the schema's declared rel types: {body}",
8168        );
8169
8170        // UNKNOWN_SECTION: update with a section key the schema
8171        // does not declare.
8172        let entity_hash = {
8173            let unified = server.unified_engine().lock().unwrap();
8174            unified
8175                .get_entity(&EntityId("specs--entity-a".to_string()))
8176                .unwrap()
8177                .content_hash
8178                .clone()
8179        };
8180        let mut sections = IndexMap::new();
8181        sections.insert("totally-fake-section".to_string(), "x".to_string());
8182        let bad_section = server.memstead_update(Parameters(UpdateParams {
8183            anchors: None,
8184            relations_unset: None,
8185            anchors_unset: None,
8186            id: "specs--entity-a".to_string(),
8187            expected_hash: entity_hash,
8188            sections: Some(sections),
8189            append_sections: None,
8190            patch_sections: None,
8191            metadata: None,
8192            metadata_unset: None,
8193            dry_run: None,
8194            note: None,
8195            role: None,
8196            declare_relations: None,
8197        }));
8198        assert!(bad_section.is_error.unwrap_or(false));
8199        let body = bad_section.structured_content.unwrap();
8200        assert_eq!(body["code"], "UNKNOWN_SECTION");
8201        assert_eq!(body["details"]["key"], "totally-fake-section");
8202        assert!(
8203            body["details"]["declared"].is_array(),
8204            "details.declared must list the schema's section keys: {body}",
8205        );
8206    }
8207
8208    /// Mutation handlers surface typed `{code, message, details}`
8209    /// envelopes via `engine_err_unified`. Verifies the high-value
8210    /// error paths produce the canonical codes (HASH_MISMATCH
8211    /// carrying current, ENTITY_NOT_FOUND carrying id).
8212    #[test]
8213    fn test_unified_mutation_handlers_emit_typed_error_envelopes() {
8214        let tmp = setup_test_workspace();
8215        let mem_dir = tmp.path().join("specs");
8216        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
8217        let mount = memstead_base::Mount {
8218            mem: "specs".to_string(),
8219            schema: Some("default@1.0.0".parse().unwrap()),
8220            storage: memstead_base::MountStorage::Folder { path: mem_dir },
8221            capability: memstead_base::MountCapability::Write,
8222            lifecycle: memstead_base::MountLifecycle::Eager,
8223            cross_linkable: true,
8224            migration_target: None,
8225        };
8226        let unified = memstead_base::Engine::from_mounts(vec![(
8227            mount,
8228            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
8229        )])
8230        .unwrap();
8231        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
8232
8233        // HASH_MISMATCH: update with a bogus expected_hash.
8234        let mut sections = IndexMap::new();
8235        sections.insert("identity".to_string(), "edited".to_string());
8236        let bad_hash = server.memstead_update(Parameters(UpdateParams {
8237            anchors: None,
8238            relations_unset: None,
8239            anchors_unset: None,
8240            id: "specs--entity-a".to_string(),
8241            expected_hash: "definitely-wrong".to_string(),
8242            sections: Some(sections),
8243            append_sections: None,
8244            patch_sections: None,
8245            metadata: None,
8246            metadata_unset: None,
8247            dry_run: None,
8248            note: None,
8249            role: None,
8250            declare_relations: None,
8251        }));
8252        assert!(bad_hash.is_error.unwrap_or(false));
8253        let body = bad_hash.structured_content.unwrap();
8254        assert_eq!(body["code"], "HASH_MISMATCH");
8255        assert!(body["details"]["current"].is_string());
8256
8257        // ENTITY_NOT_FOUND: rename a non-existent id.
8258        let missing = server.memstead_rename(Parameters(RenameParams {
8259            id: "specs--definitely-not-here".to_string(),
8260            new_title: "New Name".to_string(),
8261            expected_hash: "anything".to_string(),
8262            note: None,
8263            role: None,
8264        }));
8265        assert!(missing.is_error.unwrap_or(false));
8266        let body = missing.structured_content.unwrap();
8267        assert_eq!(body["code"], "ENTITY_NOT_FOUND");
8268        assert!(
8269            body["details"]["id"]
8270                .as_str()
8271                .unwrap()
8272                .contains("definitely-not-here")
8273        );
8274    }
8275
8276    /// #55: a not-found from the *generic* `engine_err_unified` mapper
8277    /// carries the same recovery details as the dedicated handlers —
8278    /// `suggestions` for ENTITY_NOT_FOUND, `known_mems` for
8279    /// UNKNOWN_MEM — so the envelope is uniform regardless of which
8280    /// internal path raised it.
8281    #[test]
8282    fn generic_not_found_envelopes_carry_recovery_details() {
8283        let (server, _tmp) = setup_dual_test_engine();
8284
8285        // ENTITY_NOT_FOUND via the rename handler's generic mapper.
8286        let missing = server.memstead_rename(Parameters(RenameParams {
8287            id: "specs--definitely-not-here".to_string(),
8288            new_title: "X".to_string(),
8289            expected_hash: "anything".to_string(),
8290            note: None,
8291            role: None,
8292        }));
8293        let body = missing.structured_content.unwrap();
8294        assert_eq!(body["code"], "ENTITY_NOT_FOUND");
8295        assert!(
8296            body["details"]["suggestions"].is_array(),
8297            "generic ENTITY_NOT_FOUND must carry suggestions: {body}"
8298        );
8299
8300        // UNKNOWN_MEM via the reload handler's generic mapper.
8301        let bad_mem = server.memstead_reload(Parameters(ReloadParams {
8302            mem: Some("no-such-mem".to_string()),
8303            full: None,
8304        }));
8305        let body2 = bad_mem.structured_content.unwrap();
8306        assert_eq!(body2["code"], "UNKNOWN_MEM");
8307        assert!(
8308            body2["details"]["known_mems"].is_array(),
8309            "generic UNKNOWN_MEM must carry known_mems: {body2}"
8310        );
8311    }
8312
8313    /// Text-channel mirror of the structured-error envelope: every
8314    /// typed-error response prefixes the UPPER_SNAKE_CASE code into
8315    /// the text channel as `ERROR [<CODE>]: <message>`. A consumer
8316    /// that only reads `result.content[0].text` (Claude Code's
8317    /// default rendering, CLI dump path, log scrapes) still recovers
8318    /// the code with one regex match — the prior cycle's resolution
8319    /// pinned the structured envelope only, leaving the text channel
8320    /// emitting `"ERROR: <prose>"` with no programmatic handle.
8321    /// Exercises a representative slice of the error vocabulary
8322    /// across `memstead_update`, `memstead_relate`, `memstead_rename`,
8323    /// `memstead_create`, and `memstead_delete` so the consolidation point
8324    /// (`tool_error_with_payload`) is locked across every mutation
8325    /// tool, not just one.
8326    #[test]
8327    fn text_channel_carries_typed_error_code_inline() {
8328        let tmp = setup_test_workspace();
8329        let mem_dir = tmp.path().join("specs");
8330        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
8331        let mount = memstead_base::Mount {
8332            mem: "specs".to_string(),
8333            schema: Some("default@1.0.0".parse().unwrap()),
8334            storage: memstead_base::MountStorage::Folder { path: mem_dir },
8335            capability: memstead_base::MountCapability::Write,
8336            lifecycle: memstead_base::MountLifecycle::Eager,
8337            cross_linkable: true,
8338            migration_target: None,
8339        };
8340        let unified = memstead_base::Engine::from_mounts(vec![(
8341            mount,
8342            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
8343        )])
8344        .unwrap();
8345        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
8346
8347        /// Assert the text channel begins with `ERROR [<expected_code>]: `
8348        /// AND the structured envelope's code matches. Both halves must
8349        /// agree — drift between the two channels is the regression
8350        /// this guard catches.
8351        #[track_caller]
8352        fn assert_text_carries_code(result: &CallToolResult, expected_code: &str) {
8353            assert!(
8354                result.is_error.unwrap_or(false),
8355                "expected an error response, got success: {:?}",
8356                result.structured_content,
8357            );
8358            let text = extract_text(result);
8359            let prefix = format!("ERROR [{expected_code}]: ");
8360            assert!(
8361                text.starts_with(&prefix),
8362                "text channel must start with `{prefix}` (got: {text:?})",
8363            );
8364            let payload = result
8365                .structured_content
8366                .as_ref()
8367                .expect("typed error must carry structured_content");
8368            assert_eq!(
8369                payload["code"], expected_code,
8370                "structured `code` must match the text-channel code: {payload}",
8371            );
8372        }
8373
8374        // UNKNOWN_MEM — create against a mem that isn't mounted.
8375        let unknown_mem = server.memstead_create(Parameters(CreateParams {
8376            anchors: None,
8377            mem: Some("nonexistent-mem".to_string()),
8378            title: "Anything".to_string(),
8379            entity_type: "spec".to_string(),
8380            sections: None,
8381            metadata: None,
8382            relations: None,
8383            dry_run: None,
8384            note: None,
8385            role: None,
8386        }));
8387        assert_text_carries_code(&unknown_mem, "UNKNOWN_MEM");
8388
8389        // UNKNOWN_ENTITY_TYPE — create with an undeclared type.
8390        let unknown_type = server.memstead_create(Parameters(CreateParams {
8391            anchors: None,
8392            mem: Some("specs".to_string()),
8393            title: "Misshapen".to_string(),
8394            entity_type: "definitely-not-a-real-type".to_string(),
8395            sections: None,
8396            metadata: None,
8397            relations: None,
8398            dry_run: None,
8399            note: None,
8400            role: None,
8401        }));
8402        assert_text_carries_code(&unknown_type, "UNKNOWN_ENTITY_TYPE");
8403
8404        // ENTITY_NOT_FOUND — rename a missing entity.
8405        let missing = server.memstead_rename(Parameters(RenameParams {
8406            id: "specs--definitely-not-here".to_string(),
8407            new_title: "New Name".to_string(),
8408            expected_hash: "anything".to_string(),
8409            note: None,
8410            role: None,
8411        }));
8412        assert_text_carries_code(&missing, "ENTITY_NOT_FOUND");
8413
8414        // ENTITY_NOT_FOUND — read a missing entity through `memstead_entity`.
8415        // Pre-fix the read path short-circuited through `tool_error`
8416        // and emitted `"ERROR: Entity not found: …"` with no code
8417        // prefix, leaving the documented `ERROR [<CODE>]: <message>`
8418        // contract broken for exactly one tool. The fix routes
8419        // `not_found_error` through `tool_error_with_payload` so the
8420        // text channel matches every other tool's not-found return.
8421        let missing_read = server.memstead_entity(Parameters(EntityParams {
8422            id: "specs--definitely-not-here".to_string(),
8423            include_relations: None,
8424            include_context: None,
8425            sections: None,
8426            token_budget: None,
8427            chunk: None,
8428            include_provenance: None,
8429        }));
8430        assert_text_carries_code(&missing_read, "ENTITY_NOT_FOUND");
8431
8432        // HASH_MISMATCH — update with a bogus expected_hash.
8433        let mut sections = IndexMap::new();
8434        sections.insert("identity".to_string(), "edited".to_string());
8435        let hash_mismatch = server.memstead_update(Parameters(UpdateParams {
8436            anchors: None,
8437            relations_unset: None,
8438            anchors_unset: None,
8439            id: "specs--entity-a".to_string(),
8440            expected_hash: "definitely-wrong".to_string(),
8441            sections: Some(sections),
8442            append_sections: None,
8443            patch_sections: None,
8444            metadata: None,
8445            metadata_unset: None,
8446            dry_run: None,
8447            note: None,
8448            role: None,
8449            declare_relations: None,
8450        }));
8451        assert_text_carries_code(&hash_mismatch, "HASH_MISMATCH");
8452
8453        // INVALID_REL_TYPE — relate with a vocabulary the schema rejects.
8454        let bad_rel_type = server.memstead_relate(Parameters(RelateParams {
8455            relations: vec![RelateOpInput {
8456                from: "specs--entity-a".to_string(),
8457                to: "specs--entity-b".to_string(),
8458                r#type: "TOTALLY_MADE_UP_REL".to_string(),
8459                remove: None,
8460                description: None,
8461            }],
8462            note: None,
8463            role: None,
8464            dry_run: None,
8465        }));
8466        assert_text_carries_code(&bad_rel_type, "INVALID_REL_TYPE");
8467
8468        // INVALID_ENTITY_ID — relate with a target id that violates
8469        // the wiki-link grammar.
8470        let bad_id = server.memstead_relate(Parameters(RelateParams {
8471            relations: vec![RelateOpInput {
8472                from: "specs--entity-a".to_string(),
8473                to: "specs--bad target with spaces!!".to_string(),
8474                r#type: "USES".to_string(),
8475                remove: None,
8476                description: None,
8477            }],
8478            note: None,
8479            role: None,
8480            dry_run: None,
8481        }));
8482        assert_text_carries_code(&bad_id, "INVALID_ENTITY_ID");
8483
8484        // ENTITY_ALREADY_EXISTS — create a duplicate. The engine
8485        // refuses on missing required sections, so the seed needs
8486        // identity + purpose to land before the duplicate-check fires.
8487        let mut dup_sections = IndexMap::new();
8488        dup_sections.insert("identity".to_string(), "the identity".to_string());
8489        dup_sections.insert("purpose".to_string(), "the purpose".to_string());
8490        let _seed = server.memstead_create(Parameters(CreateParams {
8491            anchors: None,
8492            mem: Some("specs".to_string()),
8493            title: "Duplicate Probe".to_string(),
8494            entity_type: "spec".to_string(),
8495            sections: Some(dup_sections.clone()),
8496            metadata: None,
8497            relations: None,
8498            dry_run: None,
8499            note: None,
8500            role: None,
8501        }));
8502        let dup = server.memstead_create(Parameters(CreateParams {
8503            anchors: None,
8504            mem: Some("specs".to_string()),
8505            title: "Duplicate Probe".to_string(),
8506            entity_type: "spec".to_string(),
8507            sections: Some(dup_sections),
8508            metadata: None,
8509            relations: None,
8510            dry_run: None,
8511            note: None,
8512            role: None,
8513        }));
8514        assert_text_carries_code(&dup, "ENTITY_ALREADY_EXISTS");
8515    }
8516
8517    /// E3a: create with `anchors[]` persists them, `memstead_entity`
8518    /// surfaces them additively on `structured_content`, a malformed
8519    /// anchor refuses `INVALID_ANCHOR` without writing the entity, and
8520    /// `memstead_relate` rejects an `anchors` field (deny_unknown_fields).
8521    #[test]
8522    fn anchors_create_read_and_refuse_through_mcp_surface() {
8523        let (server, _tmp) = setup_test_engine();
8524
8525        let ok: CreateParams = serde_json::from_value(serde_json::json!({
8526            "mem": "specs",
8527            "title": "Anchored Spec",
8528            "entity_type": "spec",
8529            "sections": { "identity": "id", "purpose": "purpose" },
8530            "anchors": [{
8531                "artifact": "src/lib.rs", "grain": "file", "class": "anchored",
8532                "hash": "h1", "hash_stability": "stable"
8533            }],
8534        }))
8535        .unwrap();
8536        let created = server.memstead_create(Parameters(ok));
8537        assert!(
8538            !created.is_error.unwrap_or(false),
8539            "create with anchors must succeed: {:?}",
8540            created.structured_content
8541        );
8542
8543        // Read back — structured_content carries anchors + composition.
8544        let read = server.memstead_entity(Parameters(EntityParams {
8545            id: "specs--anchored-spec".to_string(),
8546            include_relations: None,
8547            include_context: None,
8548            sections: None,
8549            token_budget: None,
8550            chunk: None,
8551            include_provenance: None,
8552        }));
8553        let sc = read
8554            .structured_content
8555            .expect("entity ships structured_content");
8556        let anchors = sc.get("anchors").expect("anchors field present");
8557        assert_eq!(anchors.as_array().unwrap().len(), 1);
8558        assert_eq!(anchors[0]["artifact"], "src/lib.rs");
8559        assert_eq!(anchors[0]["class"], "anchored");
8560        assert!(
8561            sc.get("anchor_composition").is_some(),
8562            "composition surfaces additively"
8563        );
8564
8565        // Malformed anchor refuses INVALID_ANCHOR; entity not written.
8566        let bad: CreateParams = serde_json::from_value(serde_json::json!({
8567            "mem": "specs",
8568            "title": "Bad Anchor Spec",
8569            "entity_type": "spec",
8570            "sections": { "identity": "id", "purpose": "purpose" },
8571            "anchors": [{ "artifact": "x", "grain": "paragraph", "class": "anchored" }],
8572        }))
8573        .unwrap();
8574        let refused = server.memstead_create(Parameters(bad));
8575        assert!(refused.is_error.unwrap_or(false));
8576        assert_eq!(
8577            refused.structured_content.as_ref().unwrap()["code"],
8578            "INVALID_ANCHOR"
8579        );
8580        let missing = server.memstead_entity(Parameters(EntityParams {
8581            id: "specs--bad-anchor-spec".to_string(),
8582            include_relations: None,
8583            include_context: None,
8584            sections: None,
8585            token_budget: None,
8586            chunk: None,
8587            include_provenance: None,
8588        }));
8589        assert!(missing.is_error.unwrap_or(false), "entity was not written");
8590
8591        // memstead_relate rejects an `anchors` field (deny_unknown_fields).
8592        let relate_parse =
8593            serde_json::from_value::<crate::tools::mutation::RelateParams>(serde_json::json!({
8594                "from": "specs--anchored-spec",
8595                "to": "specs--other",
8596                "type": "REFERENCES",
8597                "anchors": [],
8598            }));
8599        assert!(
8600            relate_parse.is_err(),
8601            "memstead_relate must reject an `anchors` field via deny_unknown_fields"
8602        );
8603    }
8604
8605    /// `memstead_relate(remove=true)` on a relation whose source body
8606    /// still wiki-links the target must surface
8607    /// `RELATION_HAS_BODY_LINKS` through the unified server's
8608    /// envelope projection. Prior to F3 the variant fell through to
8609    /// the wildcard `INTERNAL` arm in `engine_err_unified`, hiding
8610    /// the typed code from agents branching on `structured_content`.
8611    /// The filesystem-server projection already carries the variant;
8612    /// this test exercises the mem-repo path that was diverging.
8613    #[test]
8614    fn relation_has_body_links_surfaces_on_unified_server() {
8615        let (server, _tmp) = setup_test_engine();
8616
8617        let mut sections = IndexMap::new();
8618        // Seed
8619        // `identity` alongside `purpose` so the spec lands.
8620        sections.insert("identity".to_string(), "source identity".to_string());
8621        sections.insert(
8622            "purpose".to_string(),
8623            "discussion stems from [[entity-b]]".to_string(),
8624        );
8625        // Body wiki-link `[[entity-b]]` is auto-emitted as REFERENCES
8626        // via the alias-synthesis pass — explicit `relations:` for
8627        // REFERENCES is refused under the default schema's
8628        // `manual_authoring: forbidden` posture, so the declaration
8629        // stays empty and the synthesis path produces the relation.
8630        let created = server.memstead_create(Parameters(CreateParams {
8631            anchors: None,
8632            mem: Some("specs".to_string()),
8633            title: "Body Link Source".to_string(),
8634            entity_type: "spec".to_string(),
8635            sections: Some(sections),
8636            metadata: None,
8637            relations: None,
8638            dry_run: None,
8639            note: None,
8640            role: None,
8641        }));
8642        assert!(
8643            !created.is_error.unwrap_or(false),
8644            "create must succeed: {}",
8645            extract_text(&created),
8646        );
8647
8648        let remove = server.memstead_relate(Parameters(RelateParams {
8649            relations: vec![RelateOpInput {
8650                from: "specs--body-link-source".to_string(),
8651                to: "specs--entity-b".to_string(),
8652                r#type: "REFERENCES".to_string(),
8653                remove: Some(true),
8654                description: None,
8655            }],
8656            note: None,
8657            role: None,
8658            dry_run: None,
8659        }));
8660        assert!(
8661            remove.is_error.unwrap_or(false),
8662            "remove must refuse while body wiki-link survives: {}",
8663            extract_text(&remove),
8664        );
8665        let text = extract_text(&remove);
8666        assert!(
8667            text.starts_with("ERROR [RELATION_HAS_BODY_LINKS]: "),
8668            "text channel must carry the typed code: {text}",
8669        );
8670        let payload = remove
8671            .structured_content
8672            .as_ref()
8673            .expect("typed error must carry structured_content");
8674        assert_eq!(payload["code"], "RELATION_HAS_BODY_LINKS");
8675        let body_links = payload["details"]["body_links"]
8676            .as_array()
8677            .expect("details.body_links must be an array");
8678        assert!(
8679            body_links.iter().any(|v| v.as_str() == Some("purpose")),
8680            "details.body_links must name the surviving section: {payload}",
8681        );
8682        assert_eq!(payload["details"]["from_id"], "specs--body-link-source");
8683        assert_eq!(payload["details"]["to_id"], "specs--entity-b");
8684        assert_eq!(payload["details"]["rel_type"], "REFERENCES");
8685    }
8686
8687    /// F2 + F4: write-side title length is now capped at the same
8688    /// `memstead_base::ENTITY_ID_MAX_LEN` the read-path validator
8689    /// enforces, so an `memstead_create` whose derived id would exceed
8690    /// the limit refuses with an `INVALID_TITLE` envelope carrying
8691    /// `details.length` / `details.max` for recovery. Boundary: a
8692    /// title that lands exactly at the cap succeeds; one character
8693    /// over fails. Read-path symmetry: the surviving accepted entity
8694    /// is reachable via `memstead_entity` (a previously-permissive write
8695    /// followed by a strict read would be the asymmetric bug this
8696    /// test guards against).
8697    #[test]
8698    fn create_title_length_capped_in_sync_with_read_path() {
8699        let (server, _tmp) = setup_test_engine();
8700        let max = memstead_base::ENTITY_ID_MAX_LEN;
8701        let mem = "specs";
8702        // mem.len()=5 + "--"=2 ⇒ 7-char prefix. Slug-friendly title
8703        // entirely of letters; lowercase already, so slug == title.
8704        let prefix_len = mem.len() + "--".len();
8705
8706        // Title whose derived id sits at the cap → accepted. Seed
8707        // identity + purpose so the spec lands.
8708        let just_fits_title = "a".repeat(max - prefix_len);
8709        let mut seed_sections = indexmap::IndexMap::new();
8710        seed_sections.insert("identity".to_string(), "the identity".to_string());
8711        seed_sections.insert("purpose".to_string(), "the purpose".to_string());
8712        let ok = server.memstead_create(Parameters(CreateParams {
8713            anchors: None,
8714            mem: Some(mem.to_string()),
8715            title: just_fits_title.clone(),
8716            entity_type: "spec".to_string(),
8717            sections: Some(seed_sections.clone()),
8718            metadata: None,
8719            relations: None,
8720            dry_run: None,
8721            note: None,
8722            role: None,
8723        }));
8724        assert!(
8725            !ok.is_error.unwrap_or(false),
8726            "at-cap title must succeed: {}",
8727            extract_text(&ok),
8728        );
8729
8730        // Read-path symmetry — the surviving entity is reachable.
8731        let read = server.memstead_entity(Parameters(EntityParams {
8732            id: format!("{mem}--{just_fits_title}"),
8733            include_relations: None,
8734            include_context: None,
8735            sections: None,
8736            token_budget: None,
8737            chunk: None,
8738            include_provenance: None,
8739        }));
8740        assert!(
8741            !read.is_error.unwrap_or(false),
8742            "read at the cap must succeed (write-read symmetry): {}",
8743            extract_text(&read),
8744        );
8745
8746        // One character over → INVALID_TITLE envelope with recovery payload.
8747        let over_title = "a".repeat(max - prefix_len + 1);
8748        let too_long = server.memstead_create(Parameters(CreateParams {
8749            anchors: None,
8750            mem: Some(mem.to_string()),
8751            title: over_title.clone(),
8752            entity_type: "spec".to_string(),
8753            sections: None,
8754            metadata: None,
8755            relations: None,
8756            dry_run: None,
8757            note: None,
8758            role: None,
8759        }));
8760        assert!(
8761            too_long.is_error.unwrap_or(false),
8762            "over-cap title must refuse",
8763        );
8764        let text = extract_text(&too_long);
8765        assert!(
8766            text.starts_with("ERROR [INVALID_TITLE]: "),
8767            "text channel must carry typed code: {text}",
8768        );
8769        let payload = too_long
8770            .structured_content
8771            .as_ref()
8772            .expect("INVALID_TITLE must carry structured_content");
8773        assert_eq!(payload["code"], "INVALID_TITLE");
8774        // The budget bounds the composed id, so the
8775        // payload describes the id throughout — `reason`, `input`, and
8776        // `length` agree (no `input.len() != length` contradiction).
8777        assert_eq!(payload["details"]["reason"], "id_too_long");
8778        assert_eq!(
8779            payload["details"]["length"].as_u64(),
8780            Some((max + 1) as u64)
8781        );
8782        assert_eq!(payload["details"]["max"].as_u64(), Some(max as u64));
8783        let echoed_id = format!("{mem}--{over_title}");
8784        assert_eq!(
8785            payload["details"]["input"].as_str(),
8786            Some(echoed_id.as_str())
8787        );
8788        assert_eq!(
8789            payload["details"]["input"]
8790                .as_str()
8791                .unwrap()
8792                .chars()
8793                .count() as u64,
8794            payload["details"]["length"].as_u64().unwrap(),
8795            "echoed input and reported length must measure the same quantity (the id)",
8796        );
8797    }
8798
8799    /// F1 (B+A): non-Latin titles round-trip end-to-end through
8800    /// `memstead_create` → store → `memstead_entity` → `memstead_relate` with
8801    /// an Obsidian-style `[[title]]` reference. Covers three
8802    /// non-Latin scripts (CJK,
8803    /// End-to-end: a NUL (and other C0 control bytes) in a section
8804    /// body is refused on both create and update with
8805    /// `SECTION_CONTENT_INVALID`, and nothing is persisted — no binary
8806    /// blob reaches disk. Mirrors the CLI campaign that created an
8807    /// entity via `--from` JSON whose section content carried a raw NUL.
8808    #[test]
8809    fn section_body_nul_refused_on_create_and_update_nothing_persists() {
8810        let (server, _tmp) = setup_test_engine();
8811
8812        // --- create with a NUL in a section body → refused ---
8813        let mut bad = indexmap::IndexMap::new();
8814        bad.insert("identity".to_string(), "ok".to_string());
8815        bad.insert("purpose".to_string(), "line1\u{0}line2".to_string());
8816        let create = server.memstead_create(Parameters(CreateParams {
8817            anchors: None,
8818            mem: Some("specs".to_string()),
8819            title: "Nul Carrier".to_string(),
8820            entity_type: "spec".to_string(),
8821            sections: Some(bad),
8822            metadata: None,
8823            relations: None,
8824            dry_run: None,
8825            note: None,
8826            role: None,
8827        }));
8828        assert!(
8829            create.is_error.unwrap_or(false),
8830            "create with NUL must be refused"
8831        );
8832        let text = extract_text(&create);
8833        assert!(
8834            text.contains("SECTION_CONTENT_INVALID"),
8835            "refusal must carry SECTION_CONTENT_INVALID: {text}",
8836        );
8837
8838        // Nothing persisted — the entity does not exist.
8839        let read = server.memstead_entity(Parameters(EntityParams {
8840            id: "specs--nul-carrier".to_string(),
8841            include_relations: None,
8842            include_context: None,
8843            sections: None,
8844            token_budget: None,
8845            chunk: None,
8846            include_provenance: None,
8847        }));
8848        assert!(
8849            read.is_error.unwrap_or(false),
8850            "refused create must not persist the entity: {}",
8851            extract_text(&read),
8852        );
8853
8854        // --- update an existing clean entity with a NUL → refused ---
8855        let mut clean = indexmap::IndexMap::new();
8856        clean.insert("identity".to_string(), "ok".to_string());
8857        clean.insert("purpose".to_string(), "clean body".to_string());
8858        let ok = server.memstead_create(Parameters(CreateParams {
8859            anchors: None,
8860            mem: Some("specs".to_string()),
8861            title: "Clean Carrier".to_string(),
8862            entity_type: "spec".to_string(),
8863            sections: Some(clean),
8864            metadata: None,
8865            relations: None,
8866            dry_run: None,
8867            note: None,
8868            role: None,
8869        }));
8870        assert!(
8871            !ok.is_error.unwrap_or(false),
8872            "clean create must succeed: {}",
8873            extract_text(&ok)
8874        );
8875
8876        // Pass the live hash so the test exercises the section-content
8877        // gate, not HASH_MISMATCH (update always honours expected_hash).
8878        let hash = {
8879            let unified = server.unified_engine().lock().unwrap();
8880            unified
8881                .get_entity(&EntityId("specs--clean-carrier".to_string()))
8882                .unwrap()
8883                .content_hash
8884                .clone()
8885        };
8886        let mut bad_update = indexmap::IndexMap::new();
8887        bad_update.insert(
8888            "purpose".to_string(),
8889            "tab\tok but bell\u{7}bad".to_string(),
8890        );
8891        let update = server.memstead_update(Parameters(crate::tools::mutation::UpdateParams {
8892            anchors: None,
8893            relations_unset: None,
8894            anchors_unset: None,
8895            id: "specs--clean-carrier".to_string(),
8896            expected_hash: hash,
8897            sections: Some(bad_update),
8898            append_sections: None,
8899            patch_sections: None,
8900            metadata: None,
8901            metadata_unset: None,
8902            dry_run: None,
8903            declare_relations: None,
8904            note: None,
8905            role: None,
8906        }));
8907        assert!(
8908            update.is_error.unwrap_or(false),
8909            "update with control byte must be refused"
8910        );
8911        assert!(
8912            extract_text(&update).contains("SECTION_CONTENT_INVALID"),
8913            "update refusal must carry SECTION_CONTENT_INVALID: {}",
8914            extract_text(&update),
8915        );
8916    }
8917
8918    /// RTL Hebrew, Cyrillic). Verifies (a) the create succeeds,
8919    /// (b) the id surfaces in its native script, (c) the read path
8920    /// finds the entity at the same id, (d) a relate to a target
8921    /// id in the same script grammar succeeds (i.e. the wiki-link
8922    /// validator's widened character class accepts it).
8923    #[test]
8924    fn non_latin_titles_round_trip_create_and_relate() {
8925        let (server, _tmp) = setup_test_engine();
8926
8927        for (title, expected_slug, label) in [
8928            ("日本語のタイトル", "日本語のタイトル", "CJK"),
8929            ("שלום עולם", "שלום-עולם", "Hebrew (no niqqud)"),
8930            ("Москва-проект", "москва-проект", "Cyrillic"),
8931        ] {
8932            // Seed identity + purpose so the spec lands.
8933            let mut native_sections = indexmap::IndexMap::new();
8934            native_sections.insert("identity".to_string(), "identity".to_string());
8935            native_sections.insert("purpose".to_string(), "purpose".to_string());
8936            let create = server.memstead_create(Parameters(CreateParams {
8937                anchors: None,
8938                mem: Some("specs".to_string()),
8939                title: title.to_string(),
8940                entity_type: "spec".to_string(),
8941                sections: Some(native_sections),
8942                metadata: None,
8943                relations: None,
8944                dry_run: None,
8945                note: None,
8946                role: None,
8947            }));
8948            assert!(
8949                !create.is_error.unwrap_or(false),
8950                "{label} create must succeed: {}",
8951                extract_text(&create),
8952            );
8953            let expected_id = format!("specs--{expected_slug}");
8954            let create_body = create
8955                .structured_content
8956                .as_ref()
8957                .expect("create response has structured_content");
8958            assert_eq!(
8959                create_body["id"].as_str(),
8960                Some(expected_id.as_str()),
8961                "{label} id must match the expected slug",
8962            );
8963
8964            // Read-path symmetry: the entity is reachable at its
8965            // native-script id.
8966            let read = server.memstead_entity(Parameters(EntityParams {
8967                id: expected_id.clone(),
8968                include_relations: None,
8969                include_context: None,
8970                sections: None,
8971                token_budget: None,
8972                chunk: None,
8973                include_provenance: None,
8974            }));
8975            assert!(
8976                !read.is_error.unwrap_or(false),
8977                "{label} read at native id must succeed: {}",
8978                extract_text(&read),
8979            );
8980
8981            // Relate-path symmetry: a typed edge to this id (here,
8982            // we relate from the seeded `entity-a` to the new entity)
8983            // does not trip `INVALID_ENTITY_ID` — the wiki-link
8984            // grammar regex accepts the wider character class.
8985            let relate = server.memstead_relate(Parameters(RelateParams {
8986                relations: vec![RelateOpInput {
8987                    from: "specs--entity-a".to_string(),
8988                    to: expected_id.clone(),
8989                    r#type: "USES".to_string(),
8990                    remove: None,
8991                    description: None,
8992                }],
8993                note: None,
8994                role: None,
8995                dry_run: None,
8996            }));
8997            assert!(
8998                !relate.is_error.unwrap_or(false),
8999                "{label} relate to native-script id must succeed: {}",
9000                extract_text(&relate),
9001            );
9002        }
9003    }
9004
9005    /// F4 / F10: the strict mutation-entry gate refuses titles whose
9006    /// alphanumeric filter would strip every character (all-emoji,
9007    /// all-symbol, all-punctuation). Pre-gate the create used to fall
9008    /// back to a `entity-<hash>` slug; the gate surfaces
9009    /// `INVALID_TITLE` with `reason: empty` (everything drops, so the
9010    /// slug derives empty). The loader-path `title_to_slug` keeps the
9011    /// hash backstop so pre-gate entities remain readable — see
9012    /// `entity::id::title_to_slug` unit tests.
9013    /// The widened title grammar: a title with characters outside the
9014    /// slug alphabet lands, the title round-trips verbatim on the
9015    /// entity read, and the divergence rides as the typed
9016    /// `TITLE_CHARS_DROPPED_FROM_SLUG` warning naming the dropped
9017    /// characters and the derived slug. Complement: a clean title
9018    /// carries no such warning.
9019    #[test]
9020    fn title_chars_dropped_warning_rides_create_and_clean_titles_stay_silent() {
9021        let (server, _tmp) = setup_test_engine();
9022        let mut sections = IndexMap::new();
9023        sections.insert("identity".to_string(), "I.".to_string());
9024        sections.insert("purpose".to_string(), "P.".to_string());
9025        let create = server.memstead_create(Parameters(CreateParams {
9026            anchors: None,
9027            mem: Some("specs".to_string()),
9028            title: "Bösenberg Grundstücks GmbH & Co. KG".to_string(),
9029            entity_type: "spec".to_string(),
9030            sections: Some(sections.clone()),
9031            metadata: None,
9032            relations: None,
9033            dry_run: None,
9034            note: None,
9035            role: None,
9036        }));
9037        assert!(
9038            !create.is_error.unwrap_or(false),
9039            "widened grammar admits the title: {}",
9040            extract_text(&create),
9041        );
9042        let payload = create.structured_content.as_ref().expect("payload");
9043        assert_eq!(payload["id"], "specs--bösenberg-grundstücks-gmbh-co-kg");
9044        let warning = payload["warnings"]
9045            .as_array()
9046            .expect("warnings array")
9047            .iter()
9048            .find(|w| w["code"] == "TITLE_CHARS_DROPPED_FROM_SLUG")
9049            .expect("divergence warning present")
9050            .clone();
9051        let dropped: Vec<&str> = warning["details"]["dropped_chars"]
9052            .as_array()
9053            .expect("dropped_chars array")
9054            .iter()
9055            .filter_map(|v| v.as_str())
9056            .collect();
9057        assert!(
9058            dropped.contains(&"&") && dropped.contains(&"."),
9059            "{warning}"
9060        );
9061        assert_eq!(
9062            warning["details"]["slug"],
9063            "bösenberg-grundstücks-gmbh-co-kg"
9064        );
9065        // Verbatim title on the entity read.
9066        let read = server.memstead_entity(Parameters(EntityParams {
9067            id: "specs--bösenberg-grundstücks-gmbh-co-kg".to_string(),
9068            sections: None,
9069            token_budget: None,
9070            chunk: None,
9071            include_relations: None,
9072            include_context: None,
9073            include_provenance: None,
9074        }));
9075        let entity = read.structured_content.as_ref().expect("entity payload");
9076        assert_eq!(entity["title"], "Bösenberg Grundstücks GmbH & Co. KG");
9077
9078        // Complement: a clean title carries no divergence warning.
9079        let clean = server.memstead_create(Parameters(CreateParams {
9080            anchors: None,
9081            mem: Some("specs".to_string()),
9082            title: "Plain Title".to_string(),
9083            entity_type: "spec".to_string(),
9084            sections: Some(sections),
9085            metadata: None,
9086            relations: None,
9087            dry_run: None,
9088            note: None,
9089            role: None,
9090        }));
9091        assert!(!clean.is_error.unwrap_or(false));
9092        let clean_payload = clean.structured_content.as_ref().expect("payload");
9093        assert!(
9094            clean_payload["warnings"]
9095                .as_array()
9096                .map(|ws| ws
9097                    .iter()
9098                    .all(|w| w["code"] != "TITLE_CHARS_DROPPED_FROM_SLUG"))
9099                .unwrap_or(true),
9100            "clean title must not warn: {clean_payload}"
9101        );
9102    }
9103
9104    #[test]
9105    fn all_emoji_title_refuses_with_invalid_title_envelope() {
9106        let (server, _tmp) = setup_test_engine();
9107        let create = server.memstead_create(Parameters(CreateParams {
9108            anchors: None,
9109            mem: Some("specs".to_string()),
9110            title: "🚀✨".to_string(),
9111            entity_type: "spec".to_string(),
9112            sections: None,
9113            metadata: None,
9114            relations: None,
9115            dry_run: Some(true),
9116            note: None,
9117            role: None,
9118        }));
9119        assert!(
9120            create.is_error.unwrap_or(false),
9121            "all-emoji title must refuse under the strict gate: {}",
9122            extract_text(&create),
9123        );
9124        let text = extract_text(&create);
9125        assert!(
9126            text.starts_with("ERROR [INVALID_TITLE]: "),
9127            "text channel must carry typed code: {text}",
9128        );
9129        let payload = create
9130            .structured_content
9131            .as_ref()
9132            .expect("INVALID_TITLE must carry structured_content");
9133        assert_eq!(payload["code"], "INVALID_TITLE");
9134        // Under the widened grammar the emoji are admitted as display
9135        // text, but everything drops from the slug — the refusal is
9136        // the empty-derivation case, not a character-class one.
9137        assert_eq!(payload["details"]["reason"], "empty");
9138    }
9139
9140    /// F8: a title with embedded control
9141    /// characters (tab, newline) is refused with `INVALID_TITLE` /
9142    /// `reason: control_chars` and a `proposed_slug` — not accepted then
9143    /// silently truncated at the newline (which split the stored `# H1`
9144    /// and dropped every word after the newline from search). The
9145    /// control chars are escaped in the wire payload so the JSON stays
9146    /// single-line.
9147    #[test]
9148    fn control_char_title_refuses_with_invalid_title_envelope() {
9149        let (server, _tmp) = setup_test_engine();
9150        let create = server.memstead_create(Parameters(CreateParams {
9151            anchors: None,
9152            mem: Some("specs".to_string()),
9153            title: "Tab\tand\nnewline title".to_string(),
9154            entity_type: "spec".to_string(),
9155            sections: None,
9156            metadata: None,
9157            relations: None,
9158            dry_run: Some(true),
9159            note: None,
9160            role: None,
9161        }));
9162        assert!(
9163            create.is_error.unwrap_or(false),
9164            "control-char title must refuse: {}",
9165            extract_text(&create),
9166        );
9167        let payload = create
9168            .structured_content
9169            .as_ref()
9170            .expect("INVALID_TITLE must carry structured_content");
9171        assert_eq!(payload["code"], "INVALID_TITLE");
9172        assert_eq!(payload["details"]["reason"], "control_chars");
9173        let control_chars = payload["details"]["control_chars"]
9174            .as_array()
9175            .expect("control_chars must be an array");
9176        assert!(
9177            control_chars.iter().any(|v| v.as_str() == Some("\\t"))
9178                && control_chars.iter().any(|v| v.as_str() == Some("\\n")),
9179            "control_chars must enumerate the escaped offenders: {payload}",
9180        );
9181        assert_eq!(
9182            payload["details"]["proposed_slug"].as_str(),
9183            Some("tab-and-newline-title"),
9184            "proposed_slug must offer the single-line retry: {payload}",
9185        );
9186    }
9187
9188    /// `memstead_relate` end-to-end through the unified engine. Wire
9189    /// JSON shape (no `action` field — consumers branch on
9190    /// `commit_sha.is_empty()`).
9191    #[test]
9192    fn test_memstead_relate_via_unified_engine_path() {
9193        let tmp = setup_test_workspace();
9194        let mem_dir = tmp.path().join("specs");
9195        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
9196        let mount = memstead_base::Mount {
9197            mem: "specs".to_string(),
9198            schema: Some("default@1.0.0".parse().unwrap()),
9199            storage: memstead_base::MountStorage::Folder { path: mem_dir },
9200            capability: memstead_base::MountCapability::Write,
9201            lifecycle: memstead_base::MountLifecycle::Eager,
9202            cross_linkable: true,
9203            migration_target: None,
9204        };
9205        let unified = memstead_base::Engine::from_mounts(vec![(
9206            mount,
9207            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
9208        )])
9209        .unwrap();
9210        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
9211
9212        // Add path: relate two existing entities. setup_test_engine
9213        // seeds entity-a and entity-b, both with USES in the schema's
9214        // declared vocabulary.
9215        let result = server.memstead_relate(Parameters(RelateParams {
9216            relations: vec![RelateOpInput {
9217                from: "specs--entity-a".to_string(),
9218                to: "specs--entity-b".to_string(),
9219                r#type: "USES".to_string(),
9220                remove: None,
9221                description: None,
9222            }],
9223            note: None,
9224            role: None,
9225            dry_run: None,
9226        }));
9227        assert!(
9228            !result.is_error.unwrap_or(false),
9229            "{}",
9230            extract_text(&result)
9231        );
9232        let text = extract_text(&result);
9233        // Full-shape wire fields: from/to/rel_type/source/content_hash/commit_sha.
9234        assert!(text.contains("\"from\""));
9235        assert!(text.contains("\"to\""));
9236        assert!(text.contains("\"rel_type\""));
9237        assert!(text.contains("\"source\": \"explicit\""));
9238        assert!(text.contains("\"_hash\""));
9239        assert!(text.contains("\"commit_sha\""));
9240        // Schema anchor injected via mem_schema_ref_unified.
9241        assert!(text.contains("\"_mem_schema\""));
9242
9243        // Stub-creation path: relate to a non-existent target. The
9244        // unified engine creates the stub and surfaces it as an
9245        // `AUTO_STUB_CREATED` entry in `warnings[]` (Item 03 retired
9246        // the bespoke top-level `stub_warning` field). The text body
9247        // must carry both the code and the stub id so an agent
9248        // reading the text channel can recover the recovery payload
9249        // without parsing structured_content.
9250        let stub_relate = server.memstead_relate(Parameters(RelateParams {
9251            relations: vec![RelateOpInput {
9252                from: "specs--entity-a".to_string(),
9253                to: "specs--ghost-x".to_string(),
9254                r#type: "USES".to_string(),
9255                remove: None,
9256                description: None,
9257            }],
9258            note: None,
9259            role: None,
9260            dry_run: None,
9261        }));
9262        assert!(!stub_relate.is_error.unwrap_or(false));
9263        let stub_text = extract_text(&stub_relate);
9264        assert!(
9265            stub_text.contains("\"AUTO_STUB_CREATED\""),
9266            "AUTO_STUB_CREATED code must surface in warnings[]: {stub_text}",
9267        );
9268        assert!(stub_text.contains("ghost-x"));
9269        // Old top-level field must be gone — uniform diagnostic shape.
9270        assert!(
9271            !stub_text.contains("\"stub_warning\""),
9272            "stub_warning field must not appear on the wire: {stub_text}",
9273        );
9274    }
9275
9276    /// `memstead_update` end-to-end through the unified engine on a
9277    /// section-replace request. Wire JSON ships nested
9278    /// `modified_sections` / `modified_metadata` envelopes.
9279    #[test]
9280    fn test_memstead_update_via_unified_engine_path() {
9281        let tmp = setup_test_workspace();
9282        let mem_dir = tmp.path().join("specs");
9283        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
9284        let mount = memstead_base::Mount {
9285            mem: "specs".to_string(),
9286            schema: Some("default@1.0.0".parse().unwrap()),
9287            storage: memstead_base::MountStorage::Folder { path: mem_dir },
9288            capability: memstead_base::MountCapability::Write,
9289            lifecycle: memstead_base::MountLifecycle::Eager,
9290            cross_linkable: true,
9291            migration_target: None,
9292        };
9293        let unified = memstead_base::Engine::from_mounts(vec![(
9294            mount,
9295            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
9296        )])
9297        .unwrap();
9298        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
9299
9300        // Read entity-a's hash through the unified engine.
9301        let entity_hash = {
9302            let unified = server.unified_engine().lock().unwrap();
9303            unified
9304                .get_entity(&EntityId("specs--entity-a".to_string()))
9305                .expect("entity-a must exist")
9306                .content_hash
9307                .clone()
9308        };
9309
9310        // Section-replace path (the unified-supported subset).
9311        let mut sections = IndexMap::new();
9312        sections.insert("identity".to_string(), "edited body".to_string());
9313        let result = server.memstead_update(Parameters(UpdateParams {
9314            anchors: None,
9315            relations_unset: None,
9316            anchors_unset: None,
9317            id: "specs--entity-a".to_string(),
9318            expected_hash: entity_hash,
9319            sections: Some(sections),
9320            append_sections: None,
9321            patch_sections: None,
9322            metadata: None,
9323            metadata_unset: None,
9324            dry_run: None,
9325            note: None,
9326            role: None,
9327            declare_relations: None,
9328        }));
9329        assert!(
9330            !result.is_error.unwrap_or(false),
9331            "{}",
9332            extract_text(&result)
9333        );
9334        let text = extract_text(&result);
9335        // Full-shape wire fields: id, title, nested
9336        // modified_sections/modified_metadata, content_hash,
9337        // commit_sha, _mem_schema.
9338        assert!(text.contains("\"id\""));
9339        assert!(text.contains("\"title\""));
9340        assert!(text.contains("\"modified_sections\""));
9341        assert!(text.contains("\"replaced\""));
9342        assert!(text.contains("\"identity\""));
9343        assert!(text.contains("\"modified_metadata\""));
9344        assert!(text.contains("\"_hash\""));
9345        assert!(text.contains("\"commit_sha\""));
9346        assert!(text.contains("\"_mem_schema\""));
9347        // Empty append/patch slots stripped to match full's
9348        // skip_serializing_if convention.
9349        assert!(
9350            !text.contains("\"appended\""),
9351            "empty appended must be stripped",
9352        );
9353        assert!(
9354            !text.contains("\"patched\""),
9355            "empty patched must be stripped",
9356        );
9357    }
9358
9359    /// `memstead_create` end-to-end through the unified engine.
9360    /// Asserts the `CreateResult` wire shape for the greenfield +
9361    /// stub-adoption paths.
9362    #[test]
9363    fn test_memstead_create_via_unified_engine_path() {
9364        let tmp = setup_test_workspace();
9365        let mem_dir = tmp.path().join("specs");
9366        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
9367        let mount = memstead_base::Mount {
9368            mem: "specs".to_string(),
9369            schema: Some("default@1.0.0".parse().unwrap()),
9370            storage: memstead_base::MountStorage::Folder { path: mem_dir },
9371            capability: memstead_base::MountCapability::Write,
9372            lifecycle: memstead_base::MountLifecycle::Eager,
9373            cross_linkable: true,
9374            migration_target: None,
9375        };
9376        let unified = memstead_base::Engine::from_mounts(vec![(
9377            mount,
9378            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
9379        )])
9380        .unwrap();
9381        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
9382
9383        // Greenfield create — no relations, no dry_run, no
9384        // pre-existing stub. Routes through the unified engine.
9385        // The
9386        // engine refuses on missing required sections; seed both.
9387        let mut sections = IndexMap::new();
9388        sections.insert("identity".to_string(), "the identity".to_string());
9389        sections.insert("purpose".to_string(), "the purpose".to_string());
9390        let result = server.memstead_create(Parameters(CreateParams {
9391            anchors: None,
9392            title: "Brand New Spec".to_string(),
9393            entity_type: "spec".to_string(),
9394            mem: None,
9395            sections: Some(sections),
9396            metadata: None,
9397            relations: None,
9398            dry_run: None,
9399            note: None,
9400            role: None,
9401        }));
9402        assert!(
9403            !result.is_error.unwrap_or(false),
9404            "{}",
9405            extract_text(&result)
9406        );
9407        let text = extract_text(&result);
9408        // Full-shape wire fields: id, title, mem, file_path,
9409        // created_date, content_hash, commit_sha, _mem_schema.
9410        assert!(text.contains("\"id\""));
9411        assert!(text.contains("\"title\": \"Brand New Spec\""));
9412        assert!(text.contains("\"mem\": \"specs\""));
9413        assert!(text.contains("\"file_path\": \"brand-new-spec.md\""));
9414        assert!(text.contains("\"created_date\""));
9415        assert!(text.contains("\"_hash\""));
9416        assert!(text.contains("\"commit_sha\""));
9417        assert!(text.contains("\"_mem_schema\""));
9418        // Greenfield: incoming_count + incoming skip-serialised.
9419        assert!(
9420            !text.contains("\"incoming_count\""),
9421            "greenfield create must skip-empty incoming_count",
9422        );
9423
9424        // dry_run path falls back to the full engine — verified by
9425        // observing the response carries the same full shape but the
9426        // unified engine wasn't touched (we'd see two entities if
9427        // it had been). With no full mem wired, dry_run on the
9428        // unified branch is unreachable in this test fixture; we
9429        // skip exercising it here (covered by full-side tests).
9430
9431        // Stub-adoption path: pre-existing stub created via
9432        // memstead_relate, then memstead_create at the same id promotes it.
9433        // Use a separate title that slugifies to a new id, then
9434        // a relate to a different stub target.
9435        let stub_id = "specs--future-decision";
9436        let _stub_relate = server.memstead_relate(Parameters(RelateParams {
9437            relations: vec![RelateOpInput {
9438                from: "specs--entity-a".to_string(),
9439                to: stub_id.to_string(),
9440                r#type: "USES".to_string(),
9441                remove: None,
9442                description: None,
9443            }],
9444            note: None,
9445            role: None,
9446            dry_run: None,
9447        }));
9448        // Seed
9449        // identity + purpose so the spec lands; stub adoption still
9450        // surfaces via the typed `incoming` shape below.
9451        let mut adopt_sections = IndexMap::new();
9452        adopt_sections.insert("identity".to_string(), "future identity".to_string());
9453        adopt_sections.insert("purpose".to_string(), "future purpose".to_string());
9454        let adopt = server.memstead_create(Parameters(CreateParams {
9455            anchors: None,
9456            title: "Future Decision".to_string(),
9457            entity_type: "spec".to_string(),
9458            mem: None,
9459            sections: Some(adopt_sections),
9460            metadata: None,
9461            relations: None,
9462            dry_run: None,
9463            note: None,
9464            role: None,
9465        }));
9466        assert!(!adopt.is_error.unwrap_or(false), "{}", extract_text(&adopt));
9467        let adopt_text = extract_text(&adopt);
9468        // Stub adoption: incoming_count + incoming surface the
9469        // adopted edge from entity-a.
9470        assert!(adopt_text.contains("\"incoming_count\": 1"));
9471        assert!(adopt_text.contains("\"from\""));
9472        assert!(adopt_text.contains("entity-a"));
9473    }
9474
9475    /// `memstead_delete` end-to-end through the unified engine. Wire
9476    /// JSON ships `id` / `relations_removed` / `commit_sha` and
9477    /// skips `file_path` / `removed_incoming`.
9478    #[test]
9479    fn test_memstead_delete_via_unified_engine_path() {
9480        let tmp = setup_test_workspace();
9481        let mem_dir = tmp.path().join("specs");
9482        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
9483        let mount = memstead_base::Mount {
9484            mem: "specs".to_string(),
9485            schema: Some("default@1.0.0".parse().unwrap()),
9486            storage: memstead_base::MountStorage::Folder { path: mem_dir },
9487            capability: memstead_base::MountCapability::Write,
9488            lifecycle: memstead_base::MountLifecycle::Eager,
9489            cross_linkable: true,
9490            migration_target: None,
9491        };
9492        let unified = memstead_base::Engine::from_mounts(vec![(
9493            mount,
9494            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
9495        )])
9496        .unwrap();
9497        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
9498
9499        // Read entity-a's hash through the unified engine so the
9500        // hash check passes.
9501        let entity_hash = {
9502            let unified = server.unified_engine().lock().unwrap();
9503            unified
9504                .get_entity(&EntityId("specs--entity-a".to_string()))
9505                .expect("entity-a must exist")
9506                .content_hash
9507                .clone()
9508        };
9509
9510        let result = server.memstead_delete(Parameters(DeleteParams {
9511            id: "specs--entity-a".to_string(),
9512            expected_hash: entity_hash,
9513            note: None,
9514            role: None,
9515        }));
9516        assert!(
9517            !result.is_error.unwrap_or(false),
9518            "{}",
9519            extract_text(&result)
9520        );
9521        let text = extract_text(&result);
9522        // Full-shape wire fields: id + relations_removed + commit_sha.
9523        assert!(text.contains("\"id\""));
9524        assert!(text.contains("\"relations_removed\""));
9525        assert!(text.contains("\"commit_sha\""));
9526        // Unified-only fields stay engine-side, not wire.
9527        assert!(
9528            !text.contains("\"file_path\""),
9529            "wire shape must skip file_path (full DeleteResult omits)",
9530        );
9531        assert!(
9532            !text.contains("\"removed_incoming\""),
9533            "wire shape must skip removed_incoming",
9534        );
9535        // Schema anchor injected.
9536        assert!(text.contains("\"_mem_schema\""));
9537
9538        // Confirm the entity is gone from the unified store.
9539        {
9540            let unified = server.unified_engine().lock().unwrap();
9541            assert!(
9542                unified
9543                    .get_entity(&EntityId("specs--entity-a".to_string()))
9544                    .is_none(),
9545                "entity-a must be deleted from the unified store"
9546            );
9547        }
9548    }
9549
9550    /// `memstead_rename` end-to-end through the unified engine.
9551    /// Outcome carries `old_path` / `new_path` directly; slug-noop
9552    /// surfaces as `TitleNormalizedToSlugNoop`.
9553    #[test]
9554    fn test_memstead_rename_via_unified_engine_path() {
9555        let tmp = setup_test_workspace();
9556        let mem_dir = tmp.path().join("specs");
9557        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
9558        let mount = memstead_base::Mount {
9559            mem: "specs".to_string(),
9560            schema: Some("default@1.0.0".parse().unwrap()),
9561            storage: memstead_base::MountStorage::Folder { path: mem_dir },
9562            capability: memstead_base::MountCapability::Write,
9563            lifecycle: memstead_base::MountLifecycle::Eager,
9564            cross_linkable: true,
9565            migration_target: None,
9566        };
9567        let unified = memstead_base::Engine::from_mounts(vec![(
9568            mount,
9569            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
9570        )])
9571        .unwrap();
9572        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
9573
9574        // Read the entity through the unified engine to grab its
9575        // current content_hash (handler requires expected_hash).
9576        let entity_hash = {
9577            let unified = server.unified_engine().lock().unwrap();
9578            unified
9579                .get_entity(&EntityId("specs--entity-a".to_string()))
9580                .expect("entity-a must exist in fixture")
9581                .content_hash
9582                .clone()
9583        };
9584
9585        // Real-rename path.
9586        let result = server.memstead_rename(Parameters(RenameParams {
9587            id: "specs--entity-a".to_string(),
9588            new_title: "Renamed Entity A".to_string(),
9589            expected_hash: entity_hash.clone(),
9590            note: None,
9591            role: None,
9592        }));
9593        assert!(
9594            !result.is_error.unwrap_or(false),
9595            "{}",
9596            extract_text(&result)
9597        );
9598        let text = extract_text(&result);
9599        // Full-shape wire fields: old_id/new_id/old_path/new_path/
9600        // content_hash/commit_sha. Schema anchor present.
9601        assert!(text.contains("\"old_id\""));
9602        assert!(text.contains("\"new_id\""));
9603        assert!(text.contains("\"old_path\""));
9604        assert!(text.contains("\"new_path\""));
9605        assert!(text.contains("\"_hash\""));
9606        assert!(text.contains("\"commit_sha\""));
9607        assert!(text.contains("\"_mem_schema\""));
9608        // The new title's slug should appear in the new id/path.
9609        assert!(text.contains("renamed-entity-a"));
9610
9611        // Slug-noop path: rename entity-b to a title that normalises
9612        // to the same slug. Hash is fresh from the unified store
9613        // (entity-b unchanged by the previous rename).
9614        let entity_b_hash = {
9615            let unified = server.unified_engine().lock().unwrap();
9616            unified
9617                .get_entity(&EntityId("specs--entity-b".to_string()))
9618                .expect("entity-b must exist in fixture")
9619                .content_hash
9620                .clone()
9621        };
9622        let noop = server.memstead_rename(Parameters(RenameParams {
9623            id: "specs--entity-b".to_string(),
9624            new_title: "Entity  B".to_string(), // collapses to entity-b
9625            expected_hash: entity_b_hash,
9626            note: None,
9627            role: None,
9628        }));
9629        assert!(!noop.is_error.unwrap_or(false), "{}", extract_text(&noop));
9630        let noop_text = extract_text(&noop);
9631        // Slug-noop wire shape: old_id == new_id, commit_sha empty,
9632        // warnings carries TITLE_NORMALIZED_TO_SLUG_NOOP.
9633        assert!(noop_text.contains("\"commit_sha\": \"\""));
9634        assert!(noop_text.contains("TITLE_NORMALIZED_TO_SLUG_NOOP"));
9635    }
9636
9637    #[test]
9638    fn test_memstead_entity_with_sections_filter() {
9639        let (server, _tmp) = setup_dual_test_engine();
9640        let result = server.memstead_entity(Parameters(EntityParams {
9641            id: "specs--entity-a".to_string(),
9642            include_relations: None,
9643            include_context: None,
9644            sections: Some(vec!["identity".to_string()]),
9645            token_budget: None,
9646            chunk: None,
9647            include_provenance: None,
9648        }));
9649        let text = extract_text(&result);
9650        assert!(text.contains("## Identity"));
9651        // Purpose section should not appear since we only asked for identity
9652        assert!(!text.contains("## Purpose"));
9653    }
9654
9655    #[test]
9656    fn entity_with_include_relations_renders_markdown_section() {
9657        let (server, _tmp) = setup_dual_test_engine();
9658        let result = server.memstead_entity(Parameters(EntityParams {
9659            id: "specs--entity-a".to_string(),
9660            include_relations: Some(true),
9661            include_context: None,
9662            sections: None,
9663            token_budget: None,
9664            chunk: None,
9665            include_provenance: None,
9666        }));
9667        let text = extract_text(&result);
9668        assert!(
9669            text.contains("## Relations"),
9670            "expected `## Relations` heading in entity output"
9671        );
9672        assert!(
9673            !text.contains("## Relations (JSON)"),
9674            "old JSON-code-block render form must be gone"
9675        );
9676        assert!(
9677            text.contains("### Outgoing") || text.contains("(no relations"),
9678            "expected outgoing subsection or the empty-relations marker"
9679        );
9680        // Structured envelope
9681        // populated alongside the text channel. The text channel is
9682        // the rendered markdown (asserted above); the structured
9683        // envelope carries the typed Entity shape — agents branch on
9684        // it without parsing the text channel.
9685        let sc = result
9686            .structured_content
9687            .as_ref()
9688            .expect("memstead_entity must populate structured_content");
9689        assert!(sc.get("_hash").and_then(|v| v.as_str()).is_some());
9690        assert!(sc.get("relationships").and_then(|v| v.as_array()).is_some());
9691    }
9692
9693    #[test]
9694    fn entity_with_include_context_appends_community_section() {
9695        let (server, _tmp) = setup_dual_test_engine();
9696        // Build a community cache first so context has something to surface.
9697        let _ = server.memstead_overview(Parameters(OverviewParams {
9698            rebuild: Some(true),
9699            chunk: None,
9700            mem: None,
9701            include: None,
9702            token_budget: None,
9703        }));
9704        let result = server.memstead_entity(Parameters(EntityParams {
9705            id: "specs--entity-a".to_string(),
9706            include_relations: None,
9707            include_context: Some(true),
9708            sections: None,
9709            token_budget: None,
9710            chunk: None,
9711            include_provenance: None,
9712        }));
9713        let text = extract_text(&result);
9714        assert!(
9715            text.contains("## Community Context"),
9716            "expected `## Community Context` heading in entity output, got: {text}"
9717        );
9718    }
9719
9720    #[test]
9721    fn entity_with_both_flags_returns_all_sections() {
9722        let (server, _tmp) = setup_dual_test_engine();
9723        let _ = server.memstead_overview(Parameters(OverviewParams {
9724            rebuild: Some(true),
9725            chunk: None,
9726            mem: None,
9727            include: None,
9728            token_budget: None,
9729        }));
9730        let result = server.memstead_entity(Parameters(EntityParams {
9731            id: "specs--entity-a".to_string(),
9732            include_relations: Some(true),
9733            include_context: Some(true),
9734            sections: None,
9735            token_budget: None,
9736            chunk: None,
9737            include_provenance: None,
9738        }));
9739        let text = extract_text(&result);
9740        assert!(text.contains("# Entity A"));
9741        assert!(text.contains("## Relations"));
9742        assert!(text.contains("## Community Context"));
9743    }
9744
9745    #[test]
9746    fn test_memstead_search_text() {
9747        let (server, _tmp) = setup_dual_test_engine();
9748        let result = server.memstead_search(Parameters(SearchParams {
9749            query: Some(Query {
9750                any: vec!["Entity".into(), "A".into()],
9751                ..Default::default()
9752            }),
9753            direction: None,
9754            mem: None,
9755            entity_type: None,
9756            expand_via: None,
9757            expand_depth: None,
9758            related_to: None,
9759            depth: None,
9760            edge_type: None,
9761            limit: None,
9762            offset: None,
9763            filters: None,
9764            range_filters: None,
9765            stub: None,
9766            token_budget: None,
9767        }));
9768        let text = extract_text(&result);
9769        assert!(text.contains("_total:"));
9770        assert!(text.contains("entity-a"));
9771    }
9772
9773    /// With no `query`, `memstead_search` behaves as a pure
9774    /// metadata/structural filter — the path that replaces the removed
9775    /// `memstead_list` tool.
9776    #[test]
9777    fn test_memstead_search_no_text_filters_by_schema() {
9778        let (server, _tmp) = setup_dual_test_engine();
9779        let result = server.memstead_search(Parameters(SearchParams {
9780            query: None,
9781            mem: None,
9782            entity_type: Some("spec".to_string()),
9783            expand_via: None,
9784            expand_depth: None,
9785            related_to: None,
9786            depth: None,
9787            edge_type: None,
9788            limit: None,
9789            offset: None,
9790            filters: None,
9791            range_filters: None,
9792            stub: None,
9793            token_budget: None,
9794            direction: None,
9795        }));
9796        let text = extract_text(&result);
9797        assert!(text.contains("_total:"));
9798        assert!(text.contains("_total_tokens:"));
9799    }
9800
9801    /// Both text-query and no-text paths must populate `structured_content`
9802    /// with precomputed summary fields. Absorbed memstead_list's coverage.
9803    #[test]
9804    fn test_memstead_search_markdown_shape_in_both_modes() {
9805        let (server, _tmp) = setup_dual_test_engine();
9806
9807        // Text-query mode — at least one hit, markdown surfaces every
9808        // per-result and per-hit field the tool description promises.
9809        let search = server.memstead_search(Parameters(SearchParams {
9810            query: Some(Query {
9811                any: vec!["Entity".into(), "A".into()],
9812                ..Default::default()
9813            }),
9814            direction: None,
9815            mem: None,
9816            entity_type: None,
9817            expand_via: None,
9818            expand_depth: None,
9819            related_to: None,
9820            depth: None,
9821            edge_type: None,
9822            limit: None,
9823            offset: None,
9824            filters: None,
9825            range_filters: None,
9826            stub: None,
9827            token_budget: None,
9828        }));
9829        // Structured envelope
9830        // populated on the wire — the test channel below still pins
9831        // the markdown render shape (rendered prose with frontmatter
9832        // markers) so consumers of the text channel see the same
9833        // human-readable form as before.
9834        let sc = search
9835            .structured_content
9836            .as_ref()
9837            .expect("memstead_search must populate structured_content");
9838        assert!(sc.get("_total").and_then(|v| v.as_u64()).is_some());
9839        assert!(sc.get("hits").and_then(|v| v.as_array()).is_some());
9840        let text = extract_text(&search);
9841        assert!(
9842            text.contains("_total:"),
9843            "frontmatter must carry _total; got:\n{text}"
9844        );
9845        assert!(
9846            text.contains("_offset: 0"),
9847            "frontmatter must carry _offset; got:\n{text}"
9848        );
9849        assert!(
9850            text.contains("_total_tokens:"),
9851            "frontmatter must carry _total_tokens; got:\n{text}"
9852        );
9853        // At least one `### <id> — <title> (_score: ..., _tokens: ...)` heading.
9854        assert!(
9855            text.contains("### specs--") || text.contains("### memos--"),
9856            "expected at least one hit heading in markdown; got:\n{text}"
9857        );
9858        // One of the summary labels for spec/memo/concept appears.
9859        assert!(
9860            text.contains("**Identity**")
9861                || text.contains("**Claim**")
9862                || text.contains("**Definition**"),
9863            "expected schema-driven summary label in markdown; got:\n{text}"
9864        );
9865
9866        // Filter-only mode — absorbed-list path.
9867        let filter_only = server.memstead_search(Parameters(SearchParams {
9868            query: None,
9869            mem: None,
9870            entity_type: Some("spec".to_string()),
9871            expand_via: None,
9872            expand_depth: None,
9873            related_to: None,
9874            depth: None,
9875            edge_type: None,
9876            limit: None,
9877            offset: None,
9878            filters: None,
9879            range_filters: None,
9880            stub: None,
9881            token_budget: None,
9882            direction: None,
9883        }));
9884        let filter_sc = filter_only
9885            .structured_content
9886            .as_ref()
9887            .expect("filter-only memstead_search must populate structured_content");
9888        assert!(filter_sc.get("_total").and_then(|v| v.as_u64()).is_some());
9889        let filter_text = extract_text(&filter_only);
9890        assert!(filter_text.contains("_total:"));
9891        assert!(filter_text.contains("_total_tokens:"));
9892        assert!(
9893            filter_text.contains("### specs--"),
9894            "expected at least one spec hit heading; got:\n{filter_text}"
9895        );
9896    }
9897
9898    #[test]
9899    fn test_memstead_search_no_text_pagination() {
9900        let (server, _tmp) = setup_dual_test_engine();
9901
9902        // First page: limit=1, offset=0
9903        let page1 = extract_text(&server.memstead_search(Parameters(SearchParams {
9904            query: None,
9905            mem: None,
9906            entity_type: Some("spec".to_string()),
9907            expand_via: None,
9908            expand_depth: None,
9909            related_to: None,
9910            depth: None,
9911            edge_type: None,
9912            limit: Some(1),
9913            offset: Some(0),
9914            filters: None,
9915            range_filters: None,
9916            stub: None,
9917            token_budget: None,
9918            direction: None,
9919        })));
9920        assert!(page1.contains("_returned: 1"));
9921        assert!(page1.contains("_offset: 0"));
9922
9923        // Second page: limit=1, offset=1
9924        let page2 = extract_text(&server.memstead_search(Parameters(SearchParams {
9925            query: None,
9926            mem: None,
9927            entity_type: Some("spec".to_string()),
9928            expand_via: None,
9929            expand_depth: None,
9930            related_to: None,
9931            depth: None,
9932            edge_type: None,
9933            limit: Some(1),
9934            offset: Some(1),
9935            filters: None,
9936            range_filters: None,
9937            stub: None,
9938            token_budget: None,
9939            direction: None,
9940        })));
9941        assert!(page2.contains("_returned: 1"));
9942        assert!(page2.contains("_offset: 1"));
9943
9944        // With no text, all scores tie at 0.0; stable-sort falls back to
9945        // title-ascending — entity-a before entity-b.
9946        let has_a_p1 = page1.contains("specs--entity-a");
9947        let has_a_p2 = page2.contains("specs--entity-a");
9948        assert_ne!(has_a_p1, has_a_p2, "Pages should not overlap");
9949    }
9950
9951    /// `memstead_search` end-to-end through the unified engine.
9952    #[test]
9953    fn test_memstead_search_via_unified_engine_path() {
9954        let tmp = setup_test_workspace();
9955
9956        // Construct a unified engine reading the same mem directory
9957        // the full engine reads. The folder backend trait impl on
9958        // FilesystemMemWriter walks the mem tree on `from_mounts`,
9959        // populating the unified store with the same entities full
9960        // already loaded.
9961        let mem_dir = tmp.path().join("specs");
9962        let writer = memstead_base::storage::FilesystemMemWriter::new(mem_dir.clone());
9963        let mount = memstead_base::Mount {
9964            mem: "specs".to_string(),
9965            schema: Some("default@1.0.0".parse().unwrap()),
9966            storage: memstead_base::MountStorage::Folder { path: mem_dir },
9967            capability: memstead_base::MountCapability::Write,
9968            lifecycle: memstead_base::MountLifecycle::Eager,
9969            cross_linkable: true,
9970            migration_target: None,
9971        };
9972        let unified = memstead_base::Engine::from_mounts(vec![(
9973            mount,
9974            Box::new(writer) as Box<dyn memstead_base::backend::MemBackend>,
9975        )])
9976        .unwrap();
9977        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
9978
9979        // The handler now reads through the unified engine.
9980        let result = server.memstead_search(Parameters(SearchParams {
9981            query: Some(Query {
9982                any: vec!["Entity".into(), "A".into()],
9983                ..Default::default()
9984            }),
9985            direction: None,
9986            mem: None,
9987            entity_type: None,
9988            expand_via: None,
9989            expand_depth: None,
9990            related_to: None,
9991            depth: None,
9992            edge_type: None,
9993            limit: None,
9994            offset: None,
9995            filters: None,
9996            range_filters: None,
9997            stub: None,
9998            token_budget: None,
9999        }));
10000        let text = extract_text(&result);
10001        // Same shape and content as the legacy-path test
10002        // `test_memstead_search_text` — confirms behavioural equivalence.
10003        assert!(text.contains("_total:"));
10004        assert!(text.contains("entity-a"));
10005    }
10006
10007    #[test]
10008    fn test_memstead_overview() {
10009        let (server, _tmp) = setup_dual_test_engine();
10010        let result = server.memstead_overview(Parameters(OverviewParams {
10011            rebuild: None,
10012            chunk: None,
10013            mem: None,
10014            include: None,
10015            token_budget: None,
10016        }));
10017        let text = extract_text(&result);
10018        assert!(text.contains("_cluster_count:"));
10019    }
10020
10021    /// Overview carries schemas, mems, communities, budget metadata
10022    /// as Markdown (no JSON sidecar). Schema bodies live on
10023    /// `memstead_schema(name=...)` — overview lists `{ref, description}` only.
10024    #[test]
10025    fn overview_includes_schemas_mems_communities() {
10026        let (server, _tmp) = setup_dual_test_engine();
10027        let result = server.memstead_overview(Parameters(OverviewParams {
10028            rebuild: Some(true),
10029            chunk: None,
10030            mem: None,
10031            include: None,
10032            token_budget: Some(16000),
10033        }));
10034        assert!(
10035            result.structured_content.is_none(),
10036            "read tools must not emit structured_content on success"
10037        );
10038        let parsed = ParsedOverview::from(&result);
10039
10040        // Schemas block — one writable mem → one schema entry.
10041        assert_eq!(parsed.schema_refs(), vec!["default@1.0.0".to_string()]);
10042
10043        // Schema bodies are no longer in overview — the lite catalogue
10044        // names the schema and points at memstead_schema for full bodies.
10045        assert!(
10046            parsed.text.contains("memstead_schema(name="),
10047            "Schemas block must point at the new memstead_schema reader; got:\n{}",
10048            parsed.text
10049        );
10050        assert!(
10051            !parsed.text.contains("**Types:**"),
10052            "Types must NOT render under overview's Schemas block — full bodies live on memstead_schema; got:\n{}",
10053            parsed.text
10054        );
10055        assert!(
10056            !parsed.text.contains("**Relationships:**"),
10057            "Relationship vocabulary must NOT render under overview — call memstead_schema; got:\n{}",
10058            parsed.text
10059        );
10060
10061        // Mems block.
10062        assert_eq!(parsed.mem_names(), vec!["specs".to_string()]);
10063        assert!(parsed.text.contains("- **Schema:** default@1.0.0"));
10064        // F1: per-mem `version` surfaces under the Mems block so
10065        // an agent reading the overview sees the publish version
10066        // without a separate `memstead_health include_config` round-trip.
10067        // The test fixture seeds `version: "0.1.0"`.
10068        assert!(
10069            parsed.text.contains("- **Version:** 0.1.0"),
10070            "Mems block must surface the per-mem version; got:\n{}",
10071            parsed.text,
10072        );
10073
10074        // Budget + mode.
10075        assert_eq!(parsed.overview_mode(), "complete");
10076        assert!(parsed.budget_used() > 0);
10077        assert!(parsed.text.contains("_cluster_count:"));
10078    }
10079
10080    /// Cold-start path: empty graph, but the schema block must still be full.
10081    /// Guards against the failure mode where an agent hits a fresh mem, has
10082    /// nothing to read-before-write, and lacks any template to author from.
10083    #[test]
10084    fn overview_on_empty_graph_still_returns_schema() {
10085        let tmp = TempDir::new().unwrap();
10086        let mem_dir = tmp.path().join("empty");
10087        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
10088        fs::write(
10089            mem_dir.join(".memstead/config.json"),
10090            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10091        )
10092        .unwrap();
10093
10094        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10095        let server = McpServer::new(
10096            setup_unified_test_engine(tmp.path()),
10097            crate::config::DEFAULT_TOKEN_BUDGET,
10098        );
10099
10100        // See `overview_includes_schemas_mems_communities` for the
10101        // budget rationale.
10102        let result = server.memstead_overview(Parameters(OverviewParams {
10103            rebuild: Some(true),
10104            chunk: None,
10105            mem: None,
10106            include: None,
10107            token_budget: Some(16000),
10108        }));
10109        assert!(result.structured_content.is_none());
10110        let parsed = ParsedOverview::from(&result);
10111
10112        assert_eq!(parsed.schema_refs(), vec!["default@1.0.0".to_string()]);
10113        // Schema bodies (types, sections) live on memstead_schema; overview
10114        // surfaces the catalogue pointer so the agent knows where to drill.
10115        assert!(
10116            parsed.text.contains("memstead_schema(name="),
10117            "empty-graph schema catalogue must still point at memstead_schema; got:\n{}",
10118            parsed.text
10119        );
10120
10121        assert_eq!(parsed.mem_names(), vec!["empty".to_string()]);
10122        assert!(parsed.text.contains("- **Entities:** 0"));
10123
10124        // Empty graph: the heavy-content pool is trivial — nothing to drop.
10125        assert_eq!(parsed.overview_mode(), "complete");
10126        assert!(
10127            parsed.hint_keys().is_empty(),
10128            "empty graph must not produce hints; got {:?}",
10129            parsed.hint_keys()
10130        );
10131    }
10132
10133    /// Two writable mems pinned to the same schema collapse into one
10134    /// `schemas[]` entry — deduplicated by `ref`, with `used_by` carrying
10135    /// both mem names in sorted order. Keeps the cold-start payload
10136    /// tractable when a workspace has many mems on a shared schema.
10137    #[test]
10138    fn overview_dedups_shared_schema() {
10139        let tmp = TempDir::new().unwrap();
10140
10141        let alpha_dir = tmp.path().join("alpha");
10142        fs::create_dir_all(alpha_dir.join(".memstead")).unwrap();
10143        fs::write(
10144            alpha_dir.join(".memstead/config.json"),
10145            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10146        )
10147        .unwrap();
10148
10149        let beta_dir = tmp.path().join("beta");
10150        fs::create_dir_all(beta_dir.join(".memstead")).unwrap();
10151        fs::write(
10152            beta_dir.join(".memstead/config.json"),
10153            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10154        )
10155        .unwrap();
10156
10157        let _ = (alpha_dir, beta_dir);
10158        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10159        let server = McpServer::new(
10160            setup_unified_test_engine(tmp.path()),
10161            crate::config::DEFAULT_TOKEN_BUDGET,
10162        );
10163
10164        let result = server.memstead_overview(Parameters(OverviewParams {
10165            rebuild: Some(true),
10166            chunk: None,
10167            mem: None,
10168            include: None,
10169            token_budget: None,
10170        }));
10171        assert!(result.structured_content.is_none());
10172        let parsed = ParsedOverview::from(&result);
10173
10174        assert_eq!(
10175            parsed.schema_refs(),
10176            vec!["default@1.0.0".to_string()],
10177            "two mems on the same schema → exactly one schema heading"
10178        );
10179        // `used_by` left overview's `## Schemas` section with the schema-tool
10180        // split; both mems still appear under `## Mems`, and an agent
10181        // resolves the pinning by calling `memstead_schema(name=default@1.0.0)`.
10182        assert!(
10183            !parsed.text.contains("**Used by:**"),
10184            "Used by must NOT render in overview's schema entries; got:\n{}",
10185            parsed.text
10186        );
10187        assert_eq!(
10188            parsed.mem_names(),
10189            vec!["alpha".to_string(), "beta".to_string()],
10190            "both writable mems must appear"
10191        );
10192        assert!(!parsed.overview_mode().is_empty());
10193    }
10194
10195    /// Build a two-mem engine (alpha + beta, both pinned to `default@1.0.0`)
10196    /// with one entity each — enough to exercise the `mem` filter
10197    /// without fighting per-test fixture boilerplate. Returns the server
10198    /// plus the tempdir so callers can keep it alive for the duration
10199    /// of the test.
10200    fn setup_two_mem_engine() -> (McpServer, TempDir) {
10201        let tmp = TempDir::new().unwrap();
10202
10203        let alpha_dir = tmp.path().join("alpha");
10204        fs::create_dir_all(alpha_dir.join(".memstead")).unwrap();
10205        fs::write(
10206            alpha_dir.join(".memstead/config.json"),
10207            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10208        )
10209        .unwrap();
10210        fs::write(
10211            alpha_dir.join("alpha-root.md"),
10212            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Alpha Root\n\n## Identity\n\nAlpha mem's seed entity.\n\n## Purpose\n\nFilter test fixture.\n",
10213        )
10214        .unwrap();
10215
10216        let beta_dir = tmp.path().join("beta");
10217        fs::create_dir_all(beta_dir.join(".memstead")).unwrap();
10218        fs::write(
10219            beta_dir.join(".memstead/config.json"),
10220            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10221        )
10222        .unwrap();
10223        fs::write(
10224            beta_dir.join("beta-root.md"),
10225            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Beta Root\n\n## Identity\n\nBeta mem's seed entity.\n\n## Purpose\n\nFilter test fixture.\n",
10226        )
10227        .unwrap();
10228
10229        let _ = (alpha_dir, beta_dir);
10230        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10231
10232        let server = McpServer::new(
10233            setup_unified_test_engine(tmp.path()),
10234            crate::config::DEFAULT_TOKEN_BUDGET,
10235        );
10236        (server, tmp)
10237    }
10238
10239    /// `mem` filter narrows `mems[]` to one entry and `schemas[]` to the
10240    /// ref that mem uses — but `used_by` inside each schema still lists
10241    /// every mem sharing it, so the agent keeps global context.
10242    #[test]
10243    fn overview_filtered_to_one_mem() {
10244        let (server, _tmp) = setup_two_mem_engine();
10245
10246        let result = server.memstead_overview(Parameters(OverviewParams {
10247            rebuild: Some(true),
10248            chunk: None,
10249            mem: Some("alpha".to_string()),
10250            include: None,
10251            token_budget: None,
10252        }));
10253        assert!(result.structured_content.is_none());
10254        let parsed = ParsedOverview::from(&result);
10255
10256        // Filtered mem — only `alpha` appears in the mem block.
10257        assert_eq!(parsed.mem_names(), vec!["alpha".to_string()]);
10258        assert_eq!(parsed.schema_refs(), vec!["default@1.0.0".to_string()]);
10259        // `used_by` moved to memstead_schema's response; overview no longer
10260        // ships it. Agents fetch the pinning list with one
10261        // `memstead_schema(name=default@1.0.0)` call.
10262        assert!(
10263            !parsed.text.contains("**Used by:**"),
10264            "Used by must NOT render in overview's schema entries; got:\n{}",
10265            parsed.text
10266        );
10267
10268        // community_bridges is in the default greedy-fill pool; on this
10269        // fixture there are no cross-cluster edges, so the bridges block is
10270        // absent entirely.
10271        assert!(
10272            parsed.bridge_headings().is_empty(),
10273            "no edges → no bridges on this fixture; got {:?}",
10274            parsed.bridge_headings()
10275        );
10276    }
10277
10278    // ----------------------------------------------------------------------
10279    // Budget-driven overview coverage.
10280    // ----------------------------------------------------------------------
10281
10282    /// Build a two-mem engine then establish a cross-mem edge via
10283    /// `memstead_relate` so community detection treats the two mems as
10284    /// separate clusters bridged by an inter-cluster edge. Returns the
10285    /// server plus the tempdir.
10286    ///
10287    /// Note: `memstead_relate` rejects cross-mem edges, so for these tests
10288    /// we seed two entities inside one mem but in structurally-distinct
10289    /// Louvain neighbourhoods by wiring many same-mem leaves to each
10290    /// root. Louvain produces two clusters, the single root↔root edge is
10291    /// inter-cluster, and `community_bridges` picks it up.
10292    fn setup_bridge_engine() -> (McpServer, TempDir) {
10293        let tmp = TempDir::new().unwrap();
10294        let mem_dir = tmp.path().join("bridge");
10295        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
10296        fs::write(
10297            mem_dir.join(".memstead/config.json"),
10298            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10299        )
10300        .unwrap();
10301
10302        // Two dense hub clusters: alpha-hub has 4 alpha-leaves linked to it;
10303        // beta-hub has 4 beta-leaves linked to it; alpha-hub USES beta-hub.
10304        let fm = "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n";
10305        for (name, rels) in [
10306            ("alpha-hub", "- **USES**: [[beta-hub]]\n"),
10307            ("beta-hub", ""),
10308        ] {
10309            let body = format!(
10310                "{fm}# {name}\n\n## Identity\n\nHub {name}.\n\n## Purpose\n\nBridge fixture.\n\n## Relationships\n\n{rels}"
10311            );
10312            fs::write(mem_dir.join(format!("{name}.md")), body).unwrap();
10313        }
10314        for side in ["alpha", "beta"] {
10315            for i in 0..4 {
10316                let body = format!(
10317                    "{fm}# {side} leaf {i}\n\n## Identity\n\nLeaf {i} of {side}.\n\n## Purpose\n\nCluster filler.\n\n## Relationships\n\n- **USES**: [[{side}-hub]]\n"
10318                );
10319                fs::write(mem_dir.join(format!("{side}-leaf-{i}.md")), body).unwrap();
10320            }
10321        }
10322
10323        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10324
10325        let server = McpServer::new(
10326            setup_unified_test_engine(tmp.path()),
10327            crate::config::DEFAULT_TOKEN_BUDGET,
10328        );
10329        (server, tmp)
10330    }
10331
10332    /// Default budget resolves to 8000 when the caller omits
10333    /// `token_budget` — surfaced in frontmatter.
10334    #[test]
10335    fn overview_budget_default_uses_8000() {
10336        let (server, _tmp) = setup_dual_test_engine();
10337        let result = server.memstead_overview(Parameters(OverviewParams {
10338            rebuild: Some(true),
10339            chunk: None,
10340            mem: None,
10341            include: None,
10342            token_budget: None,
10343        }));
10344        let parsed = ParsedOverview::from(&result);
10345        assert_eq!(parsed.budget_requested(), 8000);
10346    }
10347
10348    /// Caller-supplied `token_budget` is echoed verbatim in frontmatter.
10349    #[test]
10350    fn overview_budget_respects_user_override() {
10351        let (server, _tmp) = setup_dual_test_engine();
10352        let result = server.memstead_overview(Parameters(OverviewParams {
10353            rebuild: Some(true),
10354            chunk: None,
10355            mem: None,
10356            include: None,
10357            token_budget: Some(2000),
10358        }));
10359        let parsed = ParsedOverview::from(&result);
10360        assert_eq!(parsed.budget_requested(), 2000);
10361    }
10362
10363    /// Small graph fits under the default budget — no hints, every
10364    /// heavy block rendered.
10365    #[test]
10366    fn overview_small_graph_under_budget_ships_complete() {
10367        let (server, _tmp) = setup_dual_test_engine();
10368        let result = server.memstead_overview(Parameters(OverviewParams {
10369            rebuild: Some(true),
10370            chunk: None,
10371            mem: None,
10372            include: None,
10373            token_budget: Some(16000),
10374        }));
10375        let parsed = ParsedOverview::from(&result);
10376        assert_eq!(parsed.overview_mode(), "complete");
10377        assert!(parsed.hint_keys().is_empty(), "no hints when complete");
10378        // Schema bodies (types, sections, write_rules) left overview with
10379        // the schema-tool split — overview lists schemas as
10380        // `{ref, description}` only and points at memstead_schema for bodies.
10381        assert!(
10382            !parsed.text.contains("**Types:**"),
10383            "Types must NOT render under overview — full bodies live on memstead_schema; got:\n{}",
10384            parsed.text
10385        );
10386        assert!(
10387            parsed.text.contains("memstead_schema(name="),
10388            "Schemas block must point at the new memstead_schema reader; got:\n{}",
10389            parsed.text
10390        );
10391        // mem_distribution shipped → per-mem `By type` row renders.
10392        assert!(
10393            parsed.text.contains("**By type:**"),
10394            "mem_distribution shipped ⇒ 'By type' row; got:\n{}",
10395            parsed.text
10396        );
10397        assert!(parsed.budget_used() <= 16000);
10398    }
10399
10400    /// Tight budget forces heavy keys (community_members, mem_distribution,
10401    /// community_bridges, dangling_links) to drop into `## Hints`. Schema
10402    /// bodies are no longer in this set — they live on `memstead_schema`.
10403    #[test]
10404    fn overview_large_graph_over_budget_reduces_with_hints() {
10405        let (server, _tmp) = setup_dual_test_engine();
10406        // Budget of 30 is below community_members + mem_distribution costs
10407        // on the test fixture, but slim schema list + mem roster must
10408        // always ship as hard-required.
10409        let result = server.memstead_overview(Parameters(OverviewParams {
10410            rebuild: Some(true),
10411            chunk: None,
10412            mem: None,
10413            include: None,
10414            token_budget: Some(30),
10415        }));
10416        let parsed = ParsedOverview::from(&result);
10417
10418        // overview_mode is `reduced` when hard-required content fits and
10419        // some heavy keys were dropped; `overbudget` if even hard-required
10420        // exceeded the budget.
10421        let mode = parsed.overview_mode();
10422        assert!(mode == "reduced" || mode == "overbudget", "got mode={mode}");
10423
10424        let hints = parsed.hint_keys();
10425        assert!(
10426            !hints.is_empty(),
10427            "tight budget must produce hints; got:\n{}",
10428            parsed.text
10429        );
10430
10431        // Hard-required content still ships even in overbudget mode.
10432        assert!(
10433            !parsed.schema_refs().is_empty(),
10434            "schemas block must survive tight budget; got:\n{}",
10435            parsed.text
10436        );
10437    }
10438
10439    /// `include` forces a heavy key even when budget would have dropped it.
10440    /// Schema bodies are no longer in the heavy-key set; we exercise
10441    /// `community_members` instead — same forcing semantics.
10442    #[test]
10443    fn overview_include_overrides_budget() {
10444        let (server, _tmp) = setup_dual_test_engine();
10445        let result = server.memstead_overview(Parameters(OverviewParams {
10446            rebuild: Some(true),
10447            chunk: None,
10448            mem: None,
10449            include: Some(vec!["community_members".to_string()]),
10450            token_budget: Some(50),
10451        }));
10452        let parsed = ParsedOverview::from(&result);
10453
10454        // community_members forced → cluster member ids render even though
10455        // the budget would have dropped them. The fallback marker is
10456        // absent because the section landed.
10457        assert!(
10458            !parsed
10459                .text
10460                .contains("call with include=[\"community_members\"] to see member lists"),
10461            "community_members forced ⇒ fallback marker must be absent; got:\n{}",
10462            parsed.text
10463        );
10464    }
10465
10466    /// Overview rejects the legacy `schema_types` include key with a
10467    /// typed `INVALID_INPUT` envelope that names the new tool. Pre-release
10468    /// breaking change — agents update to `memstead_schema(name=...)`.
10469    #[test]
10470    fn overview_rejects_legacy_schema_types_include() {
10471        let (server, _tmp) = setup_dual_test_engine();
10472        let result = server.memstead_overview(Parameters(OverviewParams {
10473            rebuild: Some(true),
10474            chunk: None,
10475            mem: None,
10476            include: Some(vec!["schema_types".to_string()]),
10477            token_budget: None,
10478        }));
10479        assert_eq!(result.is_error, Some(true), "schema_types must error");
10480        let sc = result
10481            .structured_content
10482            .as_ref()
10483            .expect("error envelope present");
10484        assert_eq!(sc["code"].as_str(), Some("INVALID_INPUT"));
10485        let msg = sc["message"].as_str().unwrap_or_default();
10486        assert!(
10487            msg.contains("memstead_schema"),
10488            "error message must name the new tool; got: {msg}"
10489        );
10490    }
10491
10492    /// When budget forces keys to drop, the Markdown body must carry
10493    /// the same `hints` listing so an agent reading only the text
10494    /// channel can re-query the dropped keys.
10495    #[test]
10496    fn overview_hints_render_in_markdown_body() {
10497        let (server, _tmp) = setup_dual_test_engine();
10498        // Budget 30 is below community_members + mem_distribution costs
10499        // on the test fixture, forcing at least one hint.
10500        let result = server.memstead_overview(Parameters(OverviewParams {
10501            rebuild: Some(true),
10502            chunk: None,
10503            mem: None,
10504            include: None,
10505            token_budget: Some(30),
10506        }));
10507        let parsed = ParsedOverview::from(&result);
10508        let hints = parsed.hint_keys();
10509        assert!(
10510            !hints.is_empty(),
10511            "precondition: tight budget must produce hints"
10512        );
10513
10514        assert!(
10515            parsed.text.contains("## Hints"),
10516            "Hints header missing from markdown; got:\n{}",
10517            parsed.text
10518        );
10519        assert!(
10520            parsed.text.contains("re-query with `include:"),
10521            "Hints re-query hint missing; got:\n{}",
10522            parsed.text
10523        );
10524    }
10525
10526    /// Every hint line carries `estimated_tokens: N` — an agent reading the
10527    /// text channel can see how expensive a `include[]` re-query would be.
10528    #[test]
10529    fn overview_hints_include_estimated_tokens() {
10530        let (server, _tmp) = setup_dual_test_engine();
10531        let result = server.memstead_overview(Parameters(OverviewParams {
10532            rebuild: Some(true),
10533            chunk: None,
10534            mem: None,
10535            include: None,
10536            token_budget: Some(100),
10537        }));
10538        let parsed = ParsedOverview::from(&result);
10539        assert!(!parsed.hint_keys().is_empty());
10540
10541        let mut saw_positive = false;
10542        for line in parsed.text.lines() {
10543            let l = line.trim();
10544            if l.starts_with("- `")
10545                && let Some((_, rest)) = l.split_once("estimated_tokens: ")
10546                && let Ok(n) = rest.trim().parse::<u64>()
10547                && n > 0
10548            {
10549                saw_positive = true;
10550            }
10551        }
10552        assert!(
10553            saw_positive,
10554            "at least one hint must carry a positive cost; got:\n{}",
10555            parsed.text
10556        );
10557    }
10558
10559    /// Hard-required content is never truncated. With an impossibly small
10560    /// budget, the schema list (`{ref, description}`) and mem roster
10561    /// still ship. Schema bodies (relationship vocabulary, types) live on
10562    /// `memstead_schema(name=...)` and are no longer overview's concern.
10563    #[test]
10564    fn overview_hard_required_never_truncated() {
10565        let (server, _tmp) = setup_dual_test_engine();
10566        let result = server.memstead_overview(Parameters(OverviewParams {
10567            rebuild: Some(true),
10568            chunk: None,
10569            mem: None,
10570            include: None,
10571            token_budget: Some(10),
10572        }));
10573        let parsed = ParsedOverview::from(&result);
10574        assert!(
10575            !parsed.schema_refs().is_empty(),
10576            "schemas catalogue must survive tight budget; got:\n{}",
10577            parsed.text
10578        );
10579        assert!(
10580            parsed.text.contains("memstead_schema(name="),
10581            "Schemas block must continue to point at memstead_schema; got:\n{}",
10582            parsed.text
10583        );
10584    }
10585
10586    /// Overbudget mode surfaces when hard-required content alone exceeds the
10587    /// caller-requested budget; hints enumerate every heavy key.
10588    #[test]
10589    fn overview_overbudget_mode_surfaces_when_hard_required_exceeds() {
10590        let (server, _tmp) = setup_dual_test_engine();
10591        let result = server.memstead_overview(Parameters(OverviewParams {
10592            rebuild: Some(true),
10593            chunk: None,
10594            mem: None,
10595            include: None,
10596            token_budget: Some(10),
10597        }));
10598        let parsed = ParsedOverview::from(&result);
10599        assert_eq!(parsed.overview_mode(), "overbudget");
10600        assert!(
10601            parsed.budget_used() > parsed.budget_requested(),
10602            "overbudget ⇒ used > requested"
10603        );
10604        let hints: std::collections::BTreeSet<String> = parsed.hint_keys().into_iter().collect();
10605        // All four heavy keys appear as hints — nothing fit. Schema
10606        // bodies left the heavy set with the schema-tool split.
10607        assert!(hints.contains("mem_distribution"));
10608        assert!(hints.contains("community_members"));
10609        assert!(hints.contains("community_bridges"));
10610        assert!(hints.contains("dangling_links"));
10611        assert!(
10612            !hints.contains("schema_types"),
10613            "schema_types is no longer an overview key — schema bodies live on memstead_schema; got: {hints:?}"
10614        );
10615    }
10616
10617    /// Unknown `include` keys surface as a typed warning with the
10618    /// `UNKNOWN_INCLUDE_KEY` code in the `## Warnings` section.
10619    #[test]
10620    fn overview_unknown_include_key_warns() {
10621        let (server, _tmp) = setup_dual_test_engine();
10622        let result = server.memstead_overview(Parameters(OverviewParams {
10623            rebuild: Some(true),
10624            chunk: None,
10625            mem: None,
10626            include: Some(vec!["bogus".to_string()]),
10627            token_budget: None,
10628        }));
10629        let parsed = ParsedOverview::from(&result);
10630        assert!(
10631            parsed
10632                .warning_codes()
10633                .iter()
10634                .any(|c| c == "UNKNOWN_INCLUDE_KEY"),
10635            "UNKNOWN_INCLUDE_KEY should appear under ## Warnings; got:\n{}",
10636            parsed.text
10637        );
10638        assert!(
10639            parsed.text.contains("'bogus'"),
10640            "offending key should appear in the warning message; got:\n{}",
10641            parsed.text
10642        );
10643    }
10644
10645    /// Inter-cluster edges show up as undirected bridge headings when
10646    /// opted in via `include`. Pair key is lexicographically normalised.
10647    #[test]
10648    fn community_bridges_aggregates_undirected() {
10649        let (server, _tmp) = setup_bridge_engine();
10650        let result = server.memstead_overview(Parameters(OverviewParams {
10651            rebuild: Some(true),
10652            chunk: None,
10653            mem: None,
10654            include: Some(vec!["community_bridges".to_string()]),
10655            token_budget: None,
10656        }));
10657        let parsed = ParsedOverview::from(&result);
10658        let headings = parsed.bridge_headings();
10659        assert!(
10660            !headings.is_empty(),
10661            "bridge engine must produce at least one bridge"
10662        );
10663        // Heading shape: `<from> ↔ <to> (N edges)`. Pair lex-normalised ⇒
10664        // the from cluster id ≤ to cluster id textually.
10665        let h = &headings[0];
10666        let parts: Vec<&str> = h.splitn(2, " ↔ ").collect();
10667        assert_eq!(parts.len(), 2, "unexpected bridge heading shape: {h}");
10668        let from = parts[0];
10669        let to = parts[1].split_once(' ').map(|(a, _)| a).unwrap_or(parts[1]);
10670        assert!(
10671            from <= to,
10672            "bridge pair must be lex-normalised: {from} > {to}"
10673        );
10674        // The block must carry at least one `- **Edge types:**` row and one
10675        // sample edge bullet.
10676        assert!(parsed.text.contains("- **Edge types:**"));
10677    }
10678
10679    /// With a mem filter, `community_bridges` lists only edges whose
10680    /// source entity lives in that mem — asymmetric, matching
10681    /// `memstead_health`. Build a two-mem engine with beta having no outgoing
10682    /// edges, then filter to beta: the bridge pool must be empty even
10683    /// though the cross-cluster edge exists in the global graph.
10684    #[test]
10685    fn overview_mem_filter_scopes_bridges_source_side() {
10686        let tmp = TempDir::new().unwrap();
10687
10688        let alpha_dir = tmp.path().join("alpha");
10689        fs::create_dir_all(alpha_dir.join(".memstead")).unwrap();
10690        fs::write(
10691            alpha_dir.join(".memstead/config.json"),
10692            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10693        )
10694        .unwrap();
10695        fs::write(
10696            alpha_dir.join("alpha-root.md"),
10697            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Alpha Root\n\n## Identity\n\nAlpha.\n\n## Purpose\n\nFixture.\n",
10698        )
10699        .unwrap();
10700
10701        let beta_dir = tmp.path().join("beta");
10702        fs::create_dir_all(beta_dir.join(".memstead")).unwrap();
10703        fs::write(
10704            beta_dir.join(".memstead/config.json"),
10705            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10706        )
10707        .unwrap();
10708        fs::write(
10709            beta_dir.join("beta-root.md"),
10710            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Beta Root\n\n## Identity\n\nBeta.\n\n## Purpose\n\nFixture.\n",
10711        )
10712        .unwrap();
10713
10714        let _ = (alpha_dir, beta_dir);
10715        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10716        let server = McpServer::new(
10717            setup_unified_test_engine(tmp.path()),
10718            crate::config::DEFAULT_TOKEN_BUDGET,
10719        );
10720
10721        // Filter to beta: no outgoing edges from beta entities → no bridges.
10722        let result = server.memstead_overview(Parameters(OverviewParams {
10723            rebuild: Some(true),
10724            chunk: None,
10725            mem: Some("beta".to_string()),
10726            include: Some(vec!["community_bridges".to_string()]),
10727            token_budget: None,
10728        }));
10729        let parsed = ParsedOverview::from(&result);
10730        assert!(
10731            parsed.bridge_headings().is_empty(),
10732            "bridges filtered to beta must be empty — source-in-mem only; got:\n{}",
10733            parsed.text
10734        );
10735    }
10736
10737    /// `memstead_overview include=["dangling_links"]` lists every non-stub
10738    /// entity whose section body wiki-links resolve to a stub or
10739    /// missing target. Pre-fix this slot returned a hardcoded `[]` and
10740    /// the `## Dangling Links` block never rendered; the test fails
10741    /// against that state.
10742    #[test]
10743    fn overview_dangling_links_surfaces_stub_targets_single_mem() {
10744        let tmp = TempDir::new().unwrap();
10745        let mem_dir = tmp.path().join("specs");
10746        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
10747        fs::write(
10748            mem_dir.join(".memstead/config.json"),
10749            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10750        )
10751        .unwrap();
10752        // `a.md` references `[[gone]]` — no on-disk file, auto-stubs at
10753        // load. The stub is the dangling signal mirrored from the
10754        // health surface's `health_dangling_links_surfaces_stub_targets`.
10755        fs::write(
10756            mem_dir.join("a.md"),
10757            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# A\n\n## Identity\n\nFixture.\n\n## Purpose\n\nRefers to [[gone]] in prose.\n",
10758        )
10759        .unwrap();
10760
10761        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10762        let server = McpServer::new(
10763            setup_unified_test_engine(tmp.path()),
10764            crate::config::DEFAULT_TOKEN_BUDGET,
10765        );
10766
10767        let result = server.memstead_overview(Parameters(OverviewParams {
10768            rebuild: Some(true),
10769            chunk: None,
10770            mem: None,
10771            include: Some(vec!["dangling_links".to_string()]),
10772            token_budget: None,
10773        }));
10774        let parsed = ParsedOverview::from(&result);
10775        assert!(
10776            parsed.text.contains("## Dangling Links"),
10777            "overview must render the Dangling Links section when an opt-in caller finds one:\n{}",
10778            parsed.text
10779        );
10780        assert!(
10781            parsed.text.contains("specs--a"),
10782            "Dangling Links must name the linking entity:\n{}",
10783            parsed.text
10784        );
10785        assert!(
10786            parsed.text.contains("specs--gone"),
10787            "Dangling Links must name the dangling target:\n{}",
10788            parsed.text
10789        );
10790    }
10791
10792    /// Cross-mem dangling resolution: a link from mem A to a real
10793    /// target in mem B is NOT dangling (the unified engine resolves
10794    /// targets across all mounts); a link from mem A to a non-
10795    /// existent target in mem B IS dangling. This pins full's
10796    /// multi-mount semantics — naïvely scanning each mount in
10797    /// isolation would mis-flag the cross-mem hit.
10798    #[test]
10799    fn overview_dangling_links_cross_mem_respects_unified_store() {
10800        let tmp = TempDir::new().unwrap();
10801        let alpha_dir = tmp.path().join("alpha");
10802        fs::create_dir_all(alpha_dir.join(".memstead")).unwrap();
10803        fs::write(
10804            alpha_dir.join(".memstead/config.json"),
10805            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10806        )
10807        .unwrap();
10808        // alpha--root carries two body wiki-links: `[[orphan]]` aliases
10809        // a relation to an absent target (dangling — target missing
10810        // case), `[[beta:anchor]]` is backed by an explicit
10811        // cross-mem REFERENCES entry (alias is valid, target lives).
10812        // Acceptance under the alias model: only the missing-target
10813        // case appears in dangling_links.
10814        fs::write(
10815            alpha_dir.join("root.md"),
10816            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Root\n\n## Identity\n\nFixture root.\n\n## Purpose\n\nPoints at [[orphan]] (gone) and [[beta:anchor]] (lives).\n\n## Relationships\n\n- **REFERENCES**: [[orphan]]\n- **REFERENCES**: [[beta:anchor]]\n",
10817        )
10818        .unwrap();
10819
10820        let beta_dir = tmp.path().join("beta");
10821        fs::create_dir_all(beta_dir.join(".memstead")).unwrap();
10822        fs::write(
10823            beta_dir.join(".memstead/config.json"),
10824            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10825        )
10826        .unwrap();
10827        fs::write(
10828            beta_dir.join("anchor.md"),
10829            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Anchor\n\n## Identity\n\nReachable cross-mem target.\n\n## Purpose\n\nMust not appear in dangling links.\n",
10830        )
10831        .unwrap();
10832
10833        let _ = (alpha_dir, beta_dir);
10834        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10835        let server = McpServer::new(
10836            setup_unified_test_engine(tmp.path()),
10837            crate::config::DEFAULT_TOKEN_BUDGET,
10838        );
10839
10840        let result = server.memstead_overview(Parameters(OverviewParams {
10841            rebuild: Some(true),
10842            chunk: None,
10843            mem: None,
10844            include: Some(vec!["dangling_links".to_string()]),
10845            token_budget: None,
10846        }));
10847        let parsed = ParsedOverview::from(&result);
10848        // Slice out the Dangling Links block — `beta--anchor` may
10849        // surface in other sections (Communities lists every member),
10850        // so the cross-mem non-flagging assertion has to look only
10851        // at the block under test.
10852        let dangling_block: String = {
10853            let mut block = String::new();
10854            let mut inside = false;
10855            for line in parsed.text.lines() {
10856                if line.starts_with("## Dangling Links") {
10857                    inside = true;
10858                    continue;
10859                }
10860                if inside {
10861                    if line.starts_with("## ") {
10862                        break;
10863                    }
10864                    block.push_str(line);
10865                    block.push('\n');
10866                }
10867            }
10868            block
10869        };
10870        assert!(
10871            parsed.text.contains("## Dangling Links"),
10872            "overview must render Dangling Links when the cross-mem sweep finds one:\n{}",
10873            parsed.text
10874        );
10875        assert!(
10876            dangling_block.contains("alpha--orphan"),
10877            "dangling target inside the source mem must be listed: dangling-block=\n{dangling_block}\nfull=\n{}",
10878            parsed.text
10879        );
10880        assert!(
10881            !dangling_block.contains("beta--anchor"),
10882            "cross-mem link to a real target must NOT be flagged dangling: dangling-block=\n{dangling_block}"
10883        );
10884    }
10885
10886    /// `sample_edges` is capped at 3 entries, sorted by (rel_type, from, to).
10887    /// Build a fixture with two well-separated Louvain clusters (two dense
10888    /// triangles) bridged by five same-type edges; cap applies to the bridge.
10889    #[test]
10890    fn overview_community_bridges_caps_sample_edges_at_three() {
10891        let tmp = TempDir::new().unwrap();
10892        let mem_dir = tmp.path().join("caps");
10893        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
10894        fs::write(
10895            mem_dir.join(".memstead/config.json"),
10896            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
10897        )
10898        .unwrap();
10899
10900        let fm = "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n";
10901        // Five sources a0..a4, all USES the same target hub. Each source
10902        // also USES two other sources to create a dense cluster. The hub
10903        // sits in its own cluster thanks to four target-side leaves.
10904        for i in 0..5 {
10905            let mut rels = "- **USES**: [[hub]]\n".to_string();
10906            for j in 0..5 {
10907                if j != i {
10908                    rels.push_str(&format!("- **USES**: [[a{j}]]\n"));
10909                }
10910            }
10911            let body = format!(
10912                "{fm}# a{i}\n\n## Identity\n\nSource {i}.\n\n## Purpose\n\nBridge fixture.\n\n## Relationships\n\n{rels}"
10913            );
10914            fs::write(mem_dir.join(format!("a{i}.md")), body).unwrap();
10915        }
10916        for i in 0..4 {
10917            let mut rels = "- **USES**: [[hub]]\n".to_string();
10918            for j in 0..4 {
10919                if j != i {
10920                    rels.push_str(&format!("- **USES**: [[b{j}]]\n"));
10921                }
10922            }
10923            let body = format!(
10924                "{fm}# b{i}\n\n## Identity\n\nHub leaf {i}.\n\n## Purpose\n\nBridge fixture.\n\n## Relationships\n\n{rels}"
10925            );
10926            fs::write(mem_dir.join(format!("b{i}.md")), body).unwrap();
10927        }
10928        fs::write(
10929            mem_dir.join("hub.md"),
10930            format!("{fm}# hub\n\n## Identity\n\nHub.\n\n## Purpose\n\nBridge fixture.\n"),
10931        )
10932        .unwrap();
10933
10934        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
10935        let server = McpServer::new(
10936            setup_unified_test_engine(tmp.path()),
10937            crate::config::DEFAULT_TOKEN_BUDGET,
10938        );
10939
10940        let result = server.memstead_overview(Parameters(OverviewParams {
10941            rebuild: Some(true),
10942            chunk: None,
10943            mem: None,
10944            include: Some(vec!["community_bridges".to_string()]),
10945            token_budget: None,
10946        }));
10947        let parsed = ParsedOverview::from(&result);
10948
10949        // Bridges block: each heading is `from ↔ to (N edges)`. Samples
10950        // render as `  - \`rel\` from → to` bullets under each heading,
10951        // sorted by (rel_type, from, to). The cap (≤3 samples per pair) is
10952        // enforced by counting sample-bullet rows per heading.
10953        let mut headings = Vec::new();
10954        let mut samples: std::collections::HashMap<String, Vec<String>> = Default::default();
10955        let mut current: Option<String> = None;
10956        let mut in_bridges = false;
10957        for line in parsed.text.lines() {
10958            if line.starts_with("## Community Bridges") {
10959                in_bridges = true;
10960                continue;
10961            }
10962            if in_bridges && line.starts_with("## ") && !line.starts_with("### ") {
10963                break;
10964            }
10965            if in_bridges {
10966                if let Some(h) = line.strip_prefix("### ") {
10967                    headings.push(h.to_string());
10968                    current = Some(h.to_string());
10969                    samples.insert(h.to_string(), Vec::new());
10970                } else if line.starts_with("  - `")
10971                    && let Some(cur) = &current
10972                {
10973                    samples.get_mut(cur).unwrap().push(line.to_string());
10974                }
10975            }
10976        }
10977
10978        assert!(
10979            !headings.is_empty(),
10980            "fixture must produce at least one bridge heading"
10981        );
10982        let mut saw_capped = false;
10983        for (h, ss) in &samples {
10984            assert!(
10985                ss.len() <= 3,
10986                "sample_edges capped at 3 per pair; pair={h} got {}",
10987                ss.len()
10988            );
10989            // Sort order check: extract (rel_type, from, to) from each line.
10990            let mut parsed_keys: Vec<(String, String, String)> = Vec::new();
10991            for s in ss {
10992                // Line shape: `  - `<rel_type>` <from> → <to>`
10993                if let Some(rest) = s.strip_prefix("  - `")
10994                    && let Some((rel, rest)) = rest.split_once('`')
10995                {
10996                    let rest = rest.trim_start();
10997                    if let Some((from, to)) = rest.split_once(" → ") {
10998                        parsed_keys.push((rel.to_string(), from.to_string(), to.to_string()));
10999                    }
11000                }
11001            }
11002            for w in parsed_keys.windows(2) {
11003                assert!(
11004                    w[0] <= w[1],
11005                    "samples must be sorted by (rel_type, from, to)"
11006                );
11007            }
11008
11009            // Find edge_count on the heading — trailing `(N edges)`.
11010            let count: u64 = h
11011                .rsplit_once('(')
11012                .and_then(|(_, tail)| tail.strip_suffix(" edges)"))
11013                .and_then(|n| n.parse().ok())
11014                .unwrap_or(0);
11015            if count >= 4 {
11016                assert_eq!(ss.len(), 3, "4+ edges on one pair → capped at 3");
11017                saw_capped = true;
11018            }
11019        }
11020        assert!(
11021            saw_capped,
11022            "fixture should produce at least one bridge with 4+ edges"
11023        );
11024    }
11025
11026    /// `rebuild: true` discards the Louvain memo, so an edge added between
11027    /// two calls is reflected in the next bridge count.
11028    #[test]
11029    fn overview_rebuild_refreshes_bridges() {
11030        let (server, _tmp) = setup_bridge_engine();
11031
11032        let first = server.memstead_overview(Parameters(OverviewParams {
11033            rebuild: Some(true),
11034            chunk: None,
11035            mem: None,
11036            include: Some(vec!["community_bridges".to_string()]),
11037            token_budget: None,
11038        }));
11039        let before_total = sum_bridge_edge_counts(&ParsedOverview::from(&first));
11040
11041        // Add another cross-cluster edge via memstead_relate.
11042        let add = server.memstead_relate(Parameters(crate::tools::mutation::RelateParams {
11043            relations: vec![RelateOpInput {
11044                from: "bridge--beta-hub".to_string(),
11045                to: "bridge--alpha-hub".to_string(),
11046                r#type: "USES".to_string(),
11047                remove: None,
11048                description: None,
11049            }],
11050            note: None,
11051            role: None,
11052            dry_run: None,
11053        }));
11054        assert!(
11055            !add.is_error.unwrap_or(false),
11056            "relate must succeed: {}",
11057            extract_text(&add)
11058        );
11059
11060        let second = server.memstead_overview(Parameters(OverviewParams {
11061            rebuild: Some(true),
11062            chunk: None,
11063            mem: None,
11064            include: Some(vec!["community_bridges".to_string()]),
11065            token_budget: None,
11066        }));
11067        let after_total = sum_bridge_edge_counts(&ParsedOverview::from(&second));
11068
11069        assert!(
11070            after_total > before_total,
11071            "rebuild must surface the new edge (before={before_total}, after={after_total})"
11072        );
11073    }
11074
11075    /// Sum every `### <from> ↔ <to> (N edges)` heading's N under the
11076    /// `## Community Bridges` block.
11077    fn sum_bridge_edge_counts(parsed: &ParsedOverview) -> u64 {
11078        parsed
11079            .bridge_headings()
11080            .iter()
11081            .filter_map(|h| {
11082                h.rsplit_once('(')
11083                    .and_then(|(_, tail)| tail.strip_suffix(" edges)"))
11084                    .and_then(|n| n.parse::<u64>().ok())
11085            })
11086            .sum()
11087    }
11088
11089    /// `hints[]` is deterministic across identical calls — locks the
11090    /// iteration order against future refactors.
11091    #[test]
11092    fn overview_hints_ordered_deterministically_across_calls() {
11093        let (server, _tmp) = setup_dual_test_engine();
11094        let mut runs: Vec<Vec<(String, u64)>> = Vec::new();
11095        for _ in 0..3 {
11096            let r = server.memstead_overview(Parameters(OverviewParams {
11097                rebuild: Some(true),
11098                chunk: None,
11099                mem: None,
11100                include: None,
11101                token_budget: Some(100),
11102            }));
11103            let parsed = ParsedOverview::from(&r);
11104            let mut hints: Vec<(String, u64)> = Vec::new();
11105            for line in parsed.text.lines() {
11106                let l = line.trim();
11107                if let Some(rest) = l.strip_prefix("- `")
11108                    && let Some((key, tail)) = rest.split_once("` — estimated_tokens: ")
11109                    && let Ok(n) = tail.trim().parse::<u64>()
11110                {
11111                    hints.push((key.to_string(), n));
11112                }
11113            }
11114            runs.push(hints);
11115        }
11116        assert_eq!(runs[0], runs[1]);
11117        assert_eq!(runs[1], runs[2]);
11118        assert!(!runs[0].is_empty(), "tight budget must produce hints");
11119    }
11120
11121    /// Char-counting keeps multibyte content from inflating token
11122    /// estimates — `Ä` counts as one char whether serialised as `"Ä"`
11123    /// (2 UTF-8 bytes) or as `"A"` (1 byte).
11124    #[test]
11125    fn multibyte_content_does_not_inflate_estimate() {
11126        use memstead_base::chunking::estimate_tokens;
11127        let ascii = "Grosse Aenderung - uebersicht";
11128        let multi = "Große Änderung — übersicht ✨";
11129        let a = estimate_tokens(ascii);
11130        let b = estimate_tokens(multi);
11131        let diff = a.abs_diff(b);
11132        let max = a.max(b).max(1);
11133        assert!(
11134            diff * 10 <= max,
11135            "char-counting should keep multibyte estimates within ±10%: ascii={a}, multi={b}"
11136        );
11137    }
11138
11139    /// Unknown mem name is a tool error, not a silent empty response —
11140    /// and the error enumerates valid mems so the agent can retry
11141    /// without a second round-trip.
11142    #[test]
11143    fn overview_unknown_mem_errors() {
11144        let (server, _tmp) = setup_two_mem_engine();
11145        let result = server.memstead_overview(Parameters(OverviewParams {
11146            rebuild: None,
11147            chunk: None,
11148            mem: Some("ghost".to_string()),
11149            include: None,
11150            token_budget: None,
11151        }));
11152        assert_eq!(result.is_error, Some(true));
11153        let text = extract_text(&result);
11154        assert!(
11155            text.contains("ghost"),
11156            "error should name the bad mem: {text}"
11157        );
11158        assert!(
11159            text.contains("alpha") && text.contains("beta"),
11160            "error should list writable mems: {text}"
11161        );
11162    }
11163
11164    /// Health filter narrows every entity-scoped count, distribution, and
11165    /// detail list to the filter mem while keeping the workspace roster
11166    /// (`writable_mems`/`read_mems`) and community count global.
11167    #[test]
11168    fn health_filtered_to_one_mem() {
11169        let (server, _tmp) = setup_two_mem_engine();
11170
11171        let result = server.memstead_health(Parameters(HealthParams {
11172            include: Some(vec!["orphans".to_string(), "most_connected".to_string()]),
11173            limit: None,
11174            mem: Some("alpha".to_string()),
11175            include_config: false,
11176            token_budget: None,
11177            chunk: None,
11178            target_schema: None,
11179        }));
11180        let text = extract_text(&result);
11181        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
11182
11183        assert_eq!(json["mem"], "alpha", "mem echo marks filter mode");
11184        assert_eq!(json["summary"]["total_entities"].as_u64().unwrap(), 1);
11185        assert_eq!(json["real_nodes"].as_u64().unwrap(), 1);
11186
11187        // Type distribution narrowed to alpha's single entity.
11188        let type_dist = json["type_distribution"].as_array().unwrap();
11189        let total: u64 = type_dist.iter().map(|t| t["count"].as_u64().unwrap()).sum();
11190        assert_eq!(total, 1, "type_distribution narrows to alpha");
11191
11192        // Writable mems still lists both — roster must not be filtered.
11193        let writable: Vec<String> = json["writable_mems"]
11194            .as_array()
11195            .unwrap()
11196            .iter()
11197            .map(|v| v.as_str().unwrap().to_string())
11198            .collect();
11199        assert_eq!(
11200            writable,
11201            vec!["alpha".to_string(), "beta".to_string()],
11202            "writable_mems stays global under a filter"
11203        );
11204
11205        // mem_schemas narrows to alpha's single pin.
11206        let mem_schemas = json["mem_schemas"].as_array().unwrap();
11207        assert_eq!(mem_schemas.len(), 1);
11208        assert_eq!(mem_schemas[0]["mem"], "alpha");
11209
11210        // Most-connected list must not leak beta entities.
11211        if let Some(arr) = json.get("most_connected").and_then(|v| v.as_array()) {
11212            for hit in arr {
11213                let id = hit["id"].as_str().unwrap();
11214                assert!(id.starts_with("alpha--"), "most_connected leaks beta: {id}");
11215            }
11216        }
11217    }
11218
11219    /// Default (unfiltered) health keeps the global aggregate shape —
11220    /// no per-mem filtering unless a `mem` argument is supplied.
11221    #[test]
11222    fn health_unfiltered_aggregates_all() {
11223        let (server, _tmp) = setup_two_mem_engine();
11224
11225        let result = server.memstead_health(Parameters(HealthParams {
11226            include: None,
11227            limit: None,
11228            mem: None,
11229            include_config: false,
11230            token_budget: None,
11231            chunk: None,
11232            target_schema: None,
11233        }));
11234        let text = extract_text(&result);
11235        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
11236
11237        assert!(json["mem"].is_null(), "unfiltered mode echoes null");
11238        assert_eq!(json["summary"]["total_entities"].as_u64().unwrap(), 2);
11239        assert_eq!(json["real_nodes"].as_u64().unwrap(), 2);
11240
11241        // mem_schemas sources from MemState.name — both mems visible.
11242        let names: Vec<String> = json["mem_schemas"]
11243            .as_array()
11244            .unwrap()
11245            .iter()
11246            .map(|v| v["mem"].as_str().unwrap().to_string())
11247            .collect();
11248        assert!(names.contains(&"alpha".to_string()));
11249        assert!(names.contains(&"beta".to_string()));
11250    }
11251
11252    /// Unknown mem on `health` errors with the same contract as `overview`.
11253    #[test]
11254    fn health_unknown_mem_errors() {
11255        let (server, _tmp) = setup_two_mem_engine();
11256        let result = server.memstead_health(Parameters(HealthParams {
11257            include: None,
11258            limit: None,
11259            mem: Some("ghost".to_string()),
11260            include_config: false,
11261            token_budget: None,
11262            chunk: None,
11263            target_schema: None,
11264        }));
11265        assert_eq!(result.is_error, Some(true));
11266        let text = extract_text(&result);
11267        assert!(text.contains("ghost"));
11268        assert!(text.contains("alpha") && text.contains("beta"));
11269    }
11270
11271    #[test]
11272    fn memstead_create_rejects_unknown_type() {
11273        // `EngineError::UnknownType` carries `name`, `schema_ref`,
11274        // `declared`, `suggestion`. The error message enumerates
11275        // valid types.
11276        let (server, _tmp) = setup_dual_test_engine();
11277        let result = server.memstead_create(Parameters(CreateParams {
11278            anchors: None,
11279            title: "Bad Type Entity".to_string(),
11280            entity_type: "bogus".to_string(),
11281            mem: Some("specs".to_string()),
11282            sections: None,
11283            metadata: None,
11284            relations: None,
11285            dry_run: None,
11286
11287            note: None,
11288
11289            role: None,
11290        }));
11291        assert_eq!(result.is_error, Some(true));
11292        let text = extract_text(&result);
11293        assert!(
11294            text.contains("bogus"),
11295            "error should name the bad type: {text}"
11296        );
11297        assert!(
11298            text.contains("spec") && text.contains("memo"),
11299            "error should list valid types: {text}"
11300        );
11301    }
11302
11303    /// `dry_run` preview on `memstead_create` returns the prospective id +
11304    /// 16-hex `_hash` with no disk write / no commit. A follow-up real
11305    /// call with the same inputs produces the same hash — the content
11306    /// hash covers the `now()`-stamped `created_date`, so the test
11307    /// pins the engine mutation clock to make the equality
11308    /// deterministic (unpinned it would only hold within one
11309    /// wall-clock second — the create-path caveat the dry_run
11310    /// docstring names).
11311    #[test]
11312    fn memstead_create_dry_run_preview() {
11313        let (server, _tmp) = setup_dual_test_engine();
11314        // Pin the mutation clock: `created_date` enters `_hash`, so the
11315        // dry-run == real hash-equality assertion below is only
11316        // deterministic when both calls stamp the same instant —
11317        // unpinned, the test fails whenever a wall-clock second ticks
11318        // between them.
11319        server
11320            .unified_engine()
11321            .lock()
11322            .unwrap()
11323            .set_mutation_clock(std::sync::Arc::new(|| {
11324                std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
11325            }));
11326        let result = server.memstead_create(Parameters(CreateParams {
11327            anchors: None,
11328            title: "Große Änderung".to_string(),
11329            entity_type: "spec".to_string(),
11330            mem: Some("specs".to_string()),
11331            sections: Some(IndexMap::from_iter([
11332                ("identity".to_string(), "Preview.".to_string()),
11333                ("purpose".to_string(), "Preview.".to_string()),
11334            ])),
11335            metadata: None,
11336            relations: None,
11337            dry_run: Some(true),
11338
11339            note: None,
11340
11341            role: None,
11342        }));
11343        assert!(
11344            !result.is_error.unwrap_or(false),
11345            "dry_run must succeed: {}",
11346            extract_text(&result)
11347        );
11348        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
11349        // F1 (B+A): the slug now preserves precomposed Latin
11350        // diacritics (`Große Änderung` → `große-änderung`) instead
11351        // of transliterating to `grosse-aenderung`.
11352        assert_eq!(json["id"].as_str().unwrap(), "specs--große-änderung");
11353        assert_eq!(json["commit_sha"].as_str().unwrap(), "");
11354        // Engine's compute_hash truncates SHA-256 to 16 hex chars — MCP
11355        // serialises that directly.
11356        assert_eq!(json["_hash"].as_str().unwrap().len(), 16);
11357
11358        // Follow-up real call with the same inputs → same hash (both
11359        // calls share the wall-clock second, so the `created_date`
11360        // stamp matches; see the test doc for the cross-second caveat).
11361        let real = server.memstead_create(Parameters(CreateParams {
11362            anchors: None,
11363            title: "Große Änderung".to_string(),
11364            entity_type: "spec".to_string(),
11365            mem: Some("specs".to_string()),
11366            sections: Some(IndexMap::from_iter([
11367                ("identity".to_string(), "Preview.".to_string()),
11368                ("purpose".to_string(), "Preview.".to_string()),
11369            ])),
11370            metadata: None,
11371            relations: None,
11372            dry_run: Some(false),
11373
11374            note: None,
11375
11376            role: None,
11377        }));
11378        let real_json: serde_json::Value = serde_json::from_str(&extract_text(&real)).unwrap();
11379        assert_eq!(real_json["_hash"], json["_hash"]);
11380    }
11381
11382    /// #06: JSON object key order flowing into CreateParams.sections must be
11383    /// preserved — IndexMap's serde Deserialize is insertion-order aware, so
11384    /// an agent that emits a specific sections order sees it honoured at the
11385    /// MCP boundary (before engine-side re-parse normalises to schema order).
11386    #[test]
11387    fn create_params_preserves_json_key_order() {
11388        let payload = serde_json::json!({
11389            "title": "Ordered",
11390            "entity_type": "spec",
11391            "sections": { "identity": "A", "purpose": "B", "specifies": "C" }
11392        });
11393        let params: CreateParams = serde_json::from_value(payload).unwrap();
11394        let sections = params.sections.unwrap();
11395        let keys: Vec<&str> = sections.keys().map(String::as_str).collect();
11396        assert_eq!(
11397            keys,
11398            vec!["identity", "purpose", "specifies"],
11399            "IndexMap deserialisation must preserve JSON key order"
11400        );
11401    }
11402
11403    #[test]
11404    fn memstead_create_requires_entity_type_argument() {
11405        // Required-at-deserialization: a JSON payload missing `entity_type`
11406        // (and no `schema` alias) must fail to deserialize into CreateParams.
11407        let payload = serde_json::json!({
11408            "title": "No Type",
11409            "mem": "specs"
11410        });
11411        let err = serde_json::from_value::<CreateParams>(payload)
11412            .expect_err("deserialization should fail without entity_type");
11413        let msg = err.to_string();
11414        assert!(
11415            msg.contains("entity_type") || msg.contains("schema"),
11416            "error should mention entity_type: {msg}"
11417        );
11418    }
11419
11420    /// `memstead_health` response must expose load-time nested-prefix
11421    /// warnings on `warnings` so agents see drift without reaching into
11422    /// engine internals. Bootstraps a fixture whose plugin mem has an
11423    /// inline `[[plugin--foo]]` link and asserts the wire carries a
11424    /// `SUSPICIOUS_NESTED_PREFIX` envelope.
11425    #[test]
11426    fn health_surfaces_nested_prefix_load_warnings() {
11427        let tmp = TempDir::new().unwrap();
11428        let plugin_dir = tmp.path().join("test-mem-plugin");
11429        fs::create_dir_all(plugin_dir.join(".memstead")).unwrap();
11430        fs::write(
11431            plugin_dir.join(".memstead/config.json"),
11432            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
11433        )
11434        .unwrap();
11435        fs::write(
11436            plugin_dir.join("foo.md"),
11437            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Foo\n\n## Identity\n\nTarget.\n\n## Purpose\n\nExists.\n",
11438        )
11439        .unwrap();
11440        fs::write(
11441            plugin_dir.join("drifted.md"),
11442            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Drifted\n\n## Identity\n\nDrifted.\n\n## Purpose\n\nReferences [[plugin--foo]] via nested prefix.\n",
11443        )
11444        .unwrap();
11445
11446        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
11447        let server = McpServer::new(
11448            setup_unified_test_engine(tmp.path()),
11449            crate::config::DEFAULT_TOKEN_BUDGET,
11450        );
11451
11452        let result = server.memstead_health(Parameters(HealthParams {
11453            include: None,
11454            limit: None,
11455            mem: None,
11456            include_config: false,
11457            token_budget: None,
11458            chunk: None,
11459            target_schema: None,
11460        }));
11461        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
11462        let warnings = json["warnings"]
11463            .as_array()
11464            .expect("warnings must be an array with the drift finding");
11465        let drift = warnings
11466            .iter()
11467            .find(|w| w["code"] == "SUSPICIOUS_NESTED_PREFIX")
11468            .expect("nested-prefix warning must appear on the wire");
11469        assert_eq!(
11470            drift["details"]["from"].as_str(),
11471            Some("test-mem-plugin--drifted"),
11472            "details.from must identify the authoring entity"
11473        );
11474        assert_eq!(
11475            drift["details"]["resolved_id"].as_str(),
11476            Some("plugin--foo"),
11477            "details.resolved_id must carry the tier-0 cross-mem id the body link resolves to"
11478        );
11479        assert_eq!(
11480            drift["details"]["candidate_target"].as_str(),
11481            Some("test-mem-plugin--foo"),
11482            "pass-2 fallback must find the same-mem bare-slug target"
11483        );
11484        assert_eq!(drift["details"]["section"].as_str(), Some("purpose"));
11485    }
11486
11487    /// `memstead_health(mem=X)` filters mem-attributable warnings to
11488    /// mem X. Pre-fix the SUSPICIOUS_NESTED_PREFIX warning emitted
11489    /// for one mem's drift leaked into queries scoped to a sibling
11490    /// mem — agents couldn't act on it because the offending entity
11491    /// wasn't in their scope. The fix keeps the warning on global
11492    /// queries and on queries scoped to the warning's source mem,
11493    /// and drops it from queries scoped elsewhere.
11494    #[test]
11495    fn health_warnings_respect_mem_filter() {
11496        let tmp = TempDir::new().unwrap();
11497        // Mem A — carries the nested-prefix drift.
11498        let plugin_dir = tmp.path().join("test-mem-plugin");
11499        fs::create_dir_all(plugin_dir.join(".memstead")).unwrap();
11500        fs::write(
11501            plugin_dir.join(".memstead/config.json"),
11502            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
11503        )
11504        .unwrap();
11505        fs::write(
11506            plugin_dir.join("foo.md"),
11507            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Foo\n\n## Identity\n\nTarget.\n\n## Purpose\n\nExists.\n",
11508        )
11509        .unwrap();
11510        fs::write(
11511            plugin_dir.join("drifted.md"),
11512            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Drifted\n\n## Identity\n\nDrifted.\n\n## Purpose\n\nReferences [[plugin--foo]] via nested prefix.\n",
11513        )
11514        .unwrap();
11515
11516        // Mem B — clean.
11517        let clean_dir = tmp.path().join("specs");
11518        fs::create_dir_all(clean_dir.join(".memstead")).unwrap();
11519        fs::write(
11520            clean_dir.join(".memstead/config.json"),
11521            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
11522        )
11523        .unwrap();
11524        fs::write(
11525            clean_dir.join("clean.md"),
11526            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Clean\n\n## Identity\n\nClean.\n\n## Purpose\n\nNo drift.\n",
11527        )
11528        .unwrap();
11529
11530        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
11531        let server = McpServer::new(
11532            setup_unified_test_engine(tmp.path()),
11533            crate::config::DEFAULT_TOKEN_BUDGET,
11534        );
11535
11536        // Global (no mem filter): drift surfaces.
11537        let global = server.memstead_health(Parameters(HealthParams {
11538            include: None,
11539            limit: None,
11540            mem: None,
11541            include_config: false,
11542            token_budget: None,
11543            chunk: None,
11544            target_schema: None,
11545        }));
11546        let global_json: serde_json::Value = serde_json::from_str(&extract_text(&global)).unwrap();
11547        let global_warnings = global_json["warnings"]
11548            .as_array()
11549            .expect("global query carries the drift warning");
11550        assert!(
11551            global_warnings
11552                .iter()
11553                .any(|w| w["code"] == "SUSPICIOUS_NESTED_PREFIX"),
11554            "global query must surface the drift"
11555        );
11556
11557        // Scoped to the offending mem: drift surfaces.
11558        let scoped_to_offender = server.memstead_health(Parameters(HealthParams {
11559            include: None,
11560            limit: None,
11561            mem: Some("test-mem-plugin".to_string()),
11562            include_config: false,
11563            token_budget: None,
11564            chunk: None,
11565            target_schema: None,
11566        }));
11567        let scoped_offender_json: serde_json::Value =
11568            serde_json::from_str(&extract_text(&scoped_to_offender)).unwrap();
11569        let offender_warnings = scoped_offender_json["warnings"]
11570            .as_array()
11571            .expect("scoped-to-offender carries the drift");
11572        assert!(
11573            offender_warnings
11574                .iter()
11575                .any(|w| w["code"] == "SUSPICIOUS_NESTED_PREFIX"),
11576            "scoped-to-offender query must surface the drift"
11577        );
11578
11579        // Scoped to a clean sibling: drift filtered out — pre-fix it
11580        // leaked into this query and agents couldn't act on it.
11581        let scoped_clean = server.memstead_health(Parameters(HealthParams {
11582            include: None,
11583            limit: None,
11584            mem: Some("specs".to_string()),
11585            include_config: false,
11586            token_budget: None,
11587            chunk: None,
11588            target_schema: None,
11589        }));
11590        let scoped_clean_json: serde_json::Value =
11591            serde_json::from_str(&extract_text(&scoped_clean)).unwrap();
11592        let clean_warnings_opt = scoped_clean_json["warnings"].as_array();
11593        if let Some(clean_warnings) = clean_warnings_opt {
11594            assert!(
11595                !clean_warnings
11596                    .iter()
11597                    .any(|w| w["code"] == "SUSPICIOUS_NESTED_PREFIX"),
11598                "scoped-to-clean must NOT surface another mem's drift; got {clean_warnings:?}"
11599            );
11600        }
11601    }
11602
11603    #[test]
11604    fn test_memstead_health_default_is_compact() {
11605        let (server, _tmp) = setup_dual_test_engine();
11606        let result = server.memstead_health(Parameters(HealthParams {
11607            include: None,
11608            limit: None,
11609            mem: None,
11610            include_config: false,
11611            token_budget: None,
11612            chunk: None,
11613            target_schema: None,
11614        }));
11615        let text = extract_text(&result);
11616        assert!(
11617            text.len() < 2000,
11618            "Default health should be compact, got {} chars",
11619            text.len()
11620        );
11621        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
11622        assert!(json["summary"]["total_entities"].is_number());
11623        assert!(json["summary"]["total_orphans"].is_number());
11624        assert!(json["summary"]["total_stubs"].is_number());
11625        // Detail sections should NOT be present by default
11626        assert!(json.get("orphans").is_none());
11627        assert!(json.get("stubs").is_none());
11628        assert!(json.get("most_connected").is_none());
11629    }
11630
11631    #[test]
11632    fn test_memstead_health_with_details() {
11633        let (server, _tmp) = setup_dual_test_engine();
11634        let result = server.memstead_health(Parameters(HealthParams {
11635            include: Some(vec!["orphans".into()]),
11636            limit: None,
11637            mem: None,
11638            include_config: false,
11639            token_budget: None,
11640            chunk: None,
11641            target_schema: None,
11642        }));
11643        let text = extract_text(&result);
11644        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
11645        assert!(
11646            json["orphans"].is_array(),
11647            "orphans detail should be present when requested"
11648        );
11649        assert!(json["summary"]["total_orphans"].is_number());
11650    }
11651
11652    #[test]
11653    fn test_create_update_delete_cycle() {
11654        let (server, _tmp) = setup_dual_test_engine();
11655
11656        // Create
11657        let result = server.memstead_create(Parameters(CreateParams {
11658            anchors: None,
11659            title: "New Entity".to_string(),
11660            entity_type: "spec".to_string(),
11661            mem: Some("specs".to_string()),
11662            sections: Some(IndexMap::from_iter([
11663                ("identity".to_string(), "A new entity".to_string()),
11664                ("purpose".to_string(), "Testing CRUD".to_string()),
11665            ])),
11666            metadata: None,
11667            relations: None,
11668            dry_run: None,
11669
11670            note: None,
11671
11672            role: None,
11673        }));
11674        assert!(
11675            !result.is_error.unwrap_or(false),
11676            "Create failed: {}",
11677            extract_text(&result)
11678        );
11679        let text = extract_text(&result);
11680        let create_json: serde_json::Value = serde_json::from_str(&text).unwrap();
11681        let id = create_json["id"].as_str().unwrap().to_string();
11682        assert_eq!(
11683            id, "specs--new-entity",
11684            "ID should be derived from mem + slug(title)"
11685        );
11686        let hash = create_json["_hash"].as_str().unwrap().to_string();
11687
11688        // Read
11689        let result = server.memstead_entity(Parameters(EntityParams {
11690            id: id.clone(),
11691            include_relations: None,
11692            include_context: None,
11693            sections: None,
11694            token_budget: None,
11695            chunk: None,
11696            include_provenance: None,
11697        }));
11698        assert!(!result.is_error.unwrap_or(false));
11699        let text = extract_text(&result);
11700        assert!(text.contains("# New Entity"));
11701
11702        // Update
11703        let result = server.memstead_update(Parameters(UpdateParams {
11704            anchors: None,
11705            relations_unset: None,
11706            anchors_unset: None,
11707            id: id.clone(),
11708            expected_hash: hash,
11709            sections: Some(IndexMap::from_iter([(
11710                "identity".to_string(),
11711                "An updated entity".to_string(),
11712            )])),
11713            append_sections: None,
11714            patch_sections: None,
11715            metadata: None,
11716            metadata_unset: None,
11717            dry_run: None,
11718
11719            note: None,
11720
11721            role: None,
11722            declare_relations: None,
11723        }));
11724        assert!(
11725            !result.is_error.unwrap_or(false),
11726            "Update failed: {}",
11727            extract_text(&result)
11728        );
11729        let text = extract_text(&result);
11730        let update_json: serde_json::Value = serde_json::from_str(&text).unwrap();
11731        let post_update_hash = update_json["_hash"]
11732            .as_str()
11733            .expect("update must return content_hash")
11734            .to_string();
11735
11736        // Delete — requires the current (post-update) hash;
11737        // `expected_hash` is mandatory on `memstead_delete`.
11738        let result = server.memstead_delete(Parameters(DeleteParams {
11739            id: id.clone(),
11740            expected_hash: post_update_hash,
11741
11742            note: None,
11743
11744            role: None,
11745        }));
11746        assert!(
11747            !result.is_error.unwrap_or(false),
11748            "Delete failed: {}",
11749            extract_text(&result)
11750        );
11751
11752        // Verify deletion
11753        let result = server.memstead_entity(Parameters(EntityParams {
11754            id,
11755            include_relations: None,
11756            include_context: None,
11757            sections: None,
11758            token_budget: None,
11759            chunk: None,
11760            include_provenance: None,
11761        }));
11762        assert!(result.is_error.unwrap_or(false));
11763    }
11764
11765    #[test]
11766    fn test_memstead_relate() {
11767        let (server, _tmp) = setup_dual_test_engine();
11768        let result = server.memstead_relate(Parameters(RelateParams {
11769            relations: vec![RelateOpInput {
11770                from: "specs--entity-a".to_string(),
11771                to: "specs--entity-b".to_string(),
11772                r#type: "DEPENDS_ON".to_string(),
11773                remove: None,
11774                description: None,
11775            }],
11776            note: None,
11777            role: None,
11778            dry_run: None,
11779        }));
11780        assert!(
11781            !result.is_error.unwrap_or(false),
11782            "Relate failed: {}",
11783            extract_text(&result)
11784        );
11785        let text = extract_text(&result);
11786        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
11787        assert_eq!(json["results"][0]["rel_type"], "DEPENDS_ON");
11788    }
11789
11790    #[test]
11791    fn relate_cycle_surfaces_relationship_cycle_envelope() {
11792        // `EngineError::RelationshipCycle` surfaces a typed
11793        // RELATIONSHIP_CYCLE envelope; cycle detection runs via
11794        // `would_cycle` from `memstead_base::graph::query`.
11795        let (server, _tmp) = setup_dual_test_engine();
11796        // a DEPENDS_ON b is fine.
11797        let first = server.memstead_relate(Parameters(RelateParams {
11798            relations: vec![RelateOpInput {
11799                from: "specs--entity-a".to_string(),
11800                to: "specs--entity-b".to_string(),
11801                r#type: "DEPENDS_ON".to_string(),
11802                remove: None,
11803                description: None,
11804            }],
11805            note: None,
11806            role: None,
11807            dry_run: None,
11808        }));
11809        assert!(!first.is_error.unwrap_or(false));
11810        // b DEPENDS_ON a closes a cycle and must reject with the
11811        // structured envelope.
11812        let cycle = server.memstead_relate(Parameters(RelateParams {
11813            relations: vec![RelateOpInput {
11814                from: "specs--entity-b".to_string(),
11815                to: "specs--entity-a".to_string(),
11816                r#type: "DEPENDS_ON".to_string(),
11817                remove: None,
11818                description: None,
11819            }],
11820            note: None,
11821            role: None,
11822            dry_run: None,
11823        }));
11824        assert!(
11825            cycle.is_error.unwrap_or(false),
11826            "cycle-closing relate must error"
11827        );
11828        let sc = cycle
11829            .structured_content
11830            .as_ref()
11831            .expect("RELATIONSHIP_CYCLE must carry structured_content");
11832        assert_eq!(sc["code"], "RELATIONSHIP_CYCLE");
11833        assert_eq!(sc["details"]["rel_type"], "DEPENDS_ON");
11834        assert_eq!(sc["details"]["from"], "specs--entity-b");
11835        assert_eq!(sc["details"]["to"], "specs--entity-a");
11836        assert_eq!(sc["details"]["path_truncated"], false);
11837        let path = sc["details"]["existing_path"]
11838            .as_array()
11839            .expect("existing_path array");
11840        assert_eq!(path.len(), 2);
11841        assert_eq!(path[0], "specs--entity-a");
11842        assert_eq!(path[1], "specs--entity-b");
11843    }
11844
11845    /// `memstead_relate` on a self-loop over a rel-type the source
11846    /// type lists in `no_self_loop_relationships` refuses with
11847    /// `RELATIONSHIP_CYCLE`, independent of the rel-type's `acyclic`
11848    /// flag. The default schema's spec type declares
11849    /// `no_self_loop_relationships: [DEPENDS_ON, USES]`, so a
11850    /// `USES from spec--X to spec--X` self-loop fires the gate even
11851    /// though USES carries `acyclic: false`.
11852    #[test]
11853    fn relate_self_loop_refuses_on_listed_no_self_loop_rel_type() {
11854        let (server, _tmp) = setup_dual_test_engine();
11855        let result = server.memstead_relate(Parameters(RelateParams {
11856            relations: vec![RelateOpInput {
11857                from: "specs--entity-a".to_string(),
11858                to: "specs--entity-a".to_string(),
11859                r#type: "USES".to_string(),
11860                remove: None,
11861                description: None,
11862            }],
11863            note: None,
11864            role: None,
11865            dry_run: None,
11866        }));
11867        assert!(
11868            result.is_error.unwrap_or(false),
11869            "self-loop on a listed no-self-loop rel-type must refuse: {}",
11870            extract_text(&result)
11871        );
11872        let sc = result
11873            .structured_content
11874            .as_ref()
11875            .expect("RELATIONSHIP_CYCLE must carry structured_content");
11876        assert_eq!(sc["code"], "RELATIONSHIP_CYCLE");
11877        assert_eq!(sc["details"]["rel_type"], "USES");
11878        assert_eq!(sc["details"]["from"], "specs--entity-a");
11879        assert_eq!(sc["details"]["to"], "specs--entity-a");
11880    }
11881
11882    /// `memstead_relate` rejects cross-mem edges with the typed
11883    /// `CROSS_MEM_LINK_NOT_ALLOWED` envelope on `structured_content`
11884    /// when the workspace's `[cross_mem_links]` policy denies the
11885    /// pairing. The default-empty policy denies every cross-mem
11886    /// pair, so this fixture (two mems, no policy) trips the
11887    /// envelope.
11888    #[test]
11889    fn relate_cross_mem_surfaces_typed_envelope() {
11890        let (server, _tmp) = setup_two_mem_engine();
11891        let result = server.memstead_relate(Parameters(RelateParams {
11892            relations: vec![RelateOpInput {
11893                from: "alpha--alpha-root".to_string(),
11894                to: "beta--beta-root".to_string(),
11895                r#type: "REFERENCES".to_string(),
11896                remove: None,
11897                description: None,
11898            }],
11899            note: None,
11900            role: None,
11901            dry_run: None,
11902        }));
11903        assert!(
11904            result.is_error.unwrap_or(false),
11905            "cross-mem relate must error when policy denies"
11906        );
11907        let sc = result
11908            .structured_content
11909            .as_ref()
11910            .expect("CROSS_MEM_LINK_NOT_ALLOWED must carry structured_content");
11911        assert_eq!(sc["code"], "CROSS_MEM_LINK_NOT_ALLOWED");
11912        assert_eq!(sc["details"]["from_mem"], "alpha");
11913        assert_eq!(sc["details"]["to_mem"], "beta");
11914        assert!(
11915            sc["message"].as_str().is_some_and(|m| !m.is_empty()),
11916            "message field must be populated: {sc:?}"
11917        );
11918    }
11919
11920    /// `memstead_update` with overlapping `metadata` + `metadata_unset`
11921    /// keys returns the typed `SET_AND_UNSET_CONFLICT` envelope.
11922    /// Locks Item 02.
11923    #[test]
11924    fn update_set_and_unset_overlap_surfaces_typed_envelope() {
11925        let (server, _tmp) = setup_dual_test_engine();
11926        // Read the current hash for entity-a.
11927        let read = server.memstead_entity(Parameters(EntityParams {
11928            id: "specs--entity-a".to_string(),
11929            sections: None,
11930            include_relations: None,
11931            include_context: None,
11932            token_budget: None,
11933            chunk: None,
11934            include_provenance: None,
11935        }));
11936        let hash = extract_text(&read)
11937            .lines()
11938            .find(|l| l.starts_with("_hash:"))
11939            .map(|l| l.trim_start_matches("_hash:").trim().to_string())
11940            .expect("_hash present");
11941
11942        let mut metadata = IndexMap::new();
11943        metadata.insert("tags".to_string(), "x".to_string());
11944        let result = server.memstead_update(Parameters(UpdateParams {
11945            anchors: None,
11946            relations_unset: None,
11947            anchors_unset: None,
11948            id: "specs--entity-a".to_string(),
11949            expected_hash: hash,
11950            sections: None,
11951            append_sections: None,
11952            patch_sections: None,
11953            metadata: Some(metadata),
11954            metadata_unset: Some(vec!["tags".to_string()]),
11955            dry_run: Some(true),
11956            note: None,
11957            role: None,
11958            declare_relations: None,
11959        }));
11960        assert!(
11961            result.is_error.unwrap_or(false),
11962            "set+unset overlap must error"
11963        );
11964        let sc = result
11965            .structured_content
11966            .as_ref()
11967            .expect("SET_AND_UNSET_CONFLICT must carry structured_content");
11968        assert_eq!(sc["code"], "SET_AND_UNSET_CONFLICT");
11969        let keys = sc["details"]["keys"]
11970            .as_array()
11971            .expect("keys array present");
11972        assert!(keys.iter().any(|k| k == "tags"));
11973    }
11974
11975    /// `memstead_relate from=<stub-id>` returns the typed
11976    /// `STUB_CANNOT_RELATE` envelope, not the pre-fix cryptic
11977    /// `UnknownType { name: "" }`. Locks Item 04 of the graph-correctness contract.
11978    #[test]
11979    fn relate_from_stub_surfaces_typed_envelope() {
11980        let (server, _tmp) = setup_dual_test_engine();
11981        // Step 1: relate entity-a → ghost-target, auto-creates a
11982        // stub at specs--ghost-target with no entity_type. USES (not
11983        // REFERENCES) — explicit relate to the schema's pointer rel-
11984        // type is refused under `manual_authoring: forbidden`.
11985        let _ = server.memstead_relate(Parameters(RelateParams {
11986            relations: vec![RelateOpInput {
11987                from: "specs--entity-a".to_string(),
11988                to: "specs--ghost-target".to_string(),
11989                r#type: "USES".to_string(),
11990                remove: None,
11991                description: None,
11992            }],
11993            note: None,
11994            role: None,
11995            dry_run: None,
11996        }));
11997        // Step 2: now relate FROM the stub — must surface
11998        // STUB_CANNOT_RELATE rather than the cryptic UnknownType
11999        // envelope.
12000        let result = server.memstead_relate(Parameters(RelateParams {
12001            relations: vec![RelateOpInput {
12002                from: "specs--ghost-target".to_string(),
12003                to: "specs--entity-a".to_string(),
12004                r#type: "USES".to_string(),
12005                remove: None,
12006                description: None,
12007            }],
12008            note: None,
12009            role: None,
12010            dry_run: None,
12011        }));
12012        assert!(
12013            result.is_error.unwrap_or(false),
12014            "relate from stub must error"
12015        );
12016        let sc = result
12017            .structured_content
12018            .as_ref()
12019            .expect("STUB_CANNOT_RELATE must carry structured_content");
12020        assert_eq!(sc["code"], "STUB_CANNOT_RELATE");
12021        assert_eq!(sc["details"]["id"], "specs--ghost-target");
12022        assert!(
12023            sc["message"]
12024                .as_str()
12025                .is_some_and(|m| m.contains("stub") && m.contains("memstead_create")),
12026            "message must name the constraint and the recovery path: {sc:?}"
12027        );
12028    }
12029
12030    /// `memstead_update patch_sections` with an `old` substring that
12031    /// isn't in the body returns the typed `PATCH_OLD_NOT_FOUND`
12032    /// envelope with the truncated current-content snapshot.
12033    /// Locks Item 02.
12034    #[test]
12035    fn update_patch_old_not_found_surfaces_typed_envelope() {
12036        let (server, _tmp) = setup_dual_test_engine();
12037        let read = server.memstead_entity(Parameters(EntityParams {
12038            id: "specs--entity-a".to_string(),
12039            sections: None,
12040            include_relations: None,
12041            include_context: None,
12042            token_budget: None,
12043            chunk: None,
12044            include_provenance: None,
12045        }));
12046        let hash = extract_text(&read)
12047            .lines()
12048            .find(|l| l.starts_with("_hash:"))
12049            .map(|l| l.trim_start_matches("_hash:").trim().to_string())
12050            .expect("_hash present");
12051
12052        let mut patches: IndexMap<String, PatchInput> = IndexMap::new();
12053        patches.insert(
12054            "identity".to_string(),
12055            PatchInput {
12056                old: "this-substring-is-not-in-the-body".to_string(),
12057                new: "replacement".to_string(),
12058                all: None,
12059            },
12060        );
12061        let result = server.memstead_update(Parameters(UpdateParams {
12062            anchors: None,
12063            relations_unset: None,
12064            anchors_unset: None,
12065            id: "specs--entity-a".to_string(),
12066            expected_hash: hash,
12067            sections: None,
12068            append_sections: None,
12069            patch_sections: Some(patches),
12070            metadata: None,
12071            metadata_unset: None,
12072            dry_run: Some(true),
12073            note: None,
12074            role: None,
12075            declare_relations: None,
12076        }));
12077        assert!(result.is_error.unwrap_or(false));
12078        let sc = result
12079            .structured_content
12080            .as_ref()
12081            .expect("PATCH_OLD_NOT_FOUND must carry structured_content");
12082        assert_eq!(sc["code"], "PATCH_OLD_NOT_FOUND");
12083        assert_eq!(sc["details"]["section"], "identity");
12084        assert!(
12085            sc["details"]["current_content"].is_string(),
12086            "current_content snapshot must ship"
12087        );
12088        assert!(
12089            sc["details"]["truncated"].is_boolean(),
12090            "truncated flag must ship"
12091        );
12092    }
12093
12094    /// `memstead_relate` with a target id that does not match the
12095    /// wiki-link grammar must not seed a polluted stub — it returns
12096    /// `INVALID_ENTITY_ID` with `details.id` and `details.reason`,
12097    /// and no stub appears in subsequent searches. Locks Item 04
12098    /// sub-case 1 of the graph-correctness contract.
12099    #[test]
12100    fn relate_rejects_malformed_target_id_with_typed_envelope() {
12101        let (server, _tmp) = setup_dual_test_engine();
12102        let result = server.memstead_relate(Parameters(RelateParams {
12103            relations: vec![RelateOpInput {
12104                from: "specs--entity-a".to_string(),
12105                to: "specs--bad@chars$here".to_string(),
12106                r#type: "REFERENCES".to_string(),
12107                remove: None,
12108                description: None,
12109            }],
12110            note: None,
12111            role: None,
12112            dry_run: None,
12113        }));
12114        assert!(
12115            result.is_error.unwrap_or(false),
12116            "malformed target id must error: {:?}",
12117            extract_text(&result)
12118        );
12119        let sc = result
12120            .structured_content
12121            .as_ref()
12122            .expect("INVALID_ENTITY_ID must carry structured_content");
12123        assert_eq!(sc["code"], "INVALID_ENTITY_ID");
12124        assert_eq!(sc["details"]["id"], "specs--bad@chars$here");
12125        assert!(
12126            sc["details"]["reason"]
12127                .as_str()
12128                .is_some_and(|r| r.contains("wiki-link grammar")),
12129            "reason must name the grammar rule: {sc:?}"
12130        );
12131
12132        // Sanity: no stub got created at the malformed id.
12133        let read = server.memstead_entity(Parameters(EntityParams {
12134            id: "specs--bad@chars$here".to_string(),
12135            sections: None,
12136            include_relations: None,
12137            include_context: None,
12138            token_budget: None,
12139            chunk: None,
12140            include_provenance: None,
12141        }));
12142        assert!(
12143            read.is_error.unwrap_or(false),
12144            "no stub should exist at the malformed id"
12145        );
12146    }
12147
12148    /// `memstead_relate` add path with a shape-violating pair on a
12149    /// mem pinned to `software@0.1.0` returns the typed
12150    /// `INVALID_REL_SHAPE` envelope with the documented
12151    /// `details.*` payload. Locks Item 03 of the graph-correctness contract.
12152    #[test]
12153    fn relate_shape_violation_surfaces_typed_envelope() {
12154        let tmp = TempDir::new().unwrap();
12155        let mem_dir = tmp.path().join("software-mem");
12156        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
12157        fs::write(
12158            mem_dir.join(".memstead/config.json"),
12159            r#"{"version":"0.1.0","schema":"software@0.1.0","mediums":{},"projections":{}}"#,
12160        )
12161        .unwrap();
12162        // Two specs — the source must be `spec` so OWNS (source_types=[actor])
12163        // is shape-violating; the target type does not gate the source-side
12164        // check.
12165        fs::write(
12166            mem_dir.join("source-spec.md"),
12167            "---\ntype: spec\ncreated_date: 2026-05-13\nlast_modified: 2026-05-13\nlevel: M0\nstability: evolving\n---\n# Source Spec\n\n## Identity\nSource entity body.\n\n## Purpose\nForcing a shape-violating OWNS.\n",
12168        )
12169        .unwrap();
12170        fs::write(
12171            mem_dir.join("target-spec.md"),
12172            "---\ntype: spec\ncreated_date: 2026-05-13\nlast_modified: 2026-05-13\nlevel: M0\nstability: evolving\n---\n# Target Spec\n\n## Identity\nTarget entity body.\n\n## Purpose\nReceiver of the OWNS attempt.\n",
12173        )
12174        .unwrap();
12175        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
12176        let engine = setup_unified_test_engine(tmp.path());
12177        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
12178
12179        // Sanity: the spec source exists.
12180        let _ = server.memstead_entity(Parameters(EntityParams {
12181            id: "software-mem--source-spec".to_string(),
12182            sections: None,
12183            include_relations: None,
12184            include_context: None,
12185            token_budget: None,
12186            chunk: None,
12187            include_provenance: None,
12188        }));
12189
12190        let result = server.memstead_relate(Parameters(RelateParams {
12191            relations: vec![RelateOpInput {
12192                from: "software-mem--source-spec".to_string(),
12193                to: "software-mem--target-spec".to_string(),
12194                r#type: "OWNS".to_string(),
12195                remove: None,
12196                description: None,
12197            }],
12198            note: None,
12199            role: None,
12200            dry_run: None,
12201        }));
12202        assert!(
12203            result.is_error.unwrap_or(false),
12204            "shape violation must error: {:?}",
12205            extract_text(&result)
12206        );
12207        let sc = result
12208            .structured_content
12209            .as_ref()
12210            .expect("INVALID_REL_SHAPE must carry structured_content");
12211        assert_eq!(sc["code"], "INVALID_REL_SHAPE");
12212        assert_eq!(sc["details"]["rel_type"], "OWNS");
12213        assert_eq!(sc["details"]["from_type"], "spec");
12214        assert_eq!(sc["details"]["to_type"], "spec");
12215        let allowed_source = sc["details"]["allowed_source_types"]
12216            .as_array()
12217            .expect("allowed_source_types array");
12218        assert!(
12219            allowed_source.iter().any(|v| v == "actor"),
12220            "allowed_source_types must contain 'actor': {sc:?}"
12221        );
12222        // OWNS has no target_types restriction — the structured payload
12223        // omits the field when unconstrained (absence = "any"); the text
12224        // channel renders "allowed targets: any" inline.
12225        assert!(
12226            sc["details"].get("allowed_target_types").is_none(),
12227            "allowed_target_types must be omitted when unconstrained: {sc:?}"
12228        );
12229        let text = extract_text(&result);
12230        assert!(
12231            text.contains("allowed targets: any"),
12232            "text message must render `allowed targets: any` when target_types is unconstrained: {text}"
12233        );
12234    }
12235
12236    /// `memstead_relate remove=true` skips shape validation so an edge
12237    /// authored before the constraint landed remains cleanable.
12238    /// Locks the migration path for Item 03.
12239    #[test]
12240    fn relate_remove_skips_shape_validation() {
12241        let tmp = TempDir::new().unwrap();
12242        let mem_dir = tmp.path().join("software-mem");
12243        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
12244        fs::write(
12245            mem_dir.join(".memstead/config.json"),
12246            r#"{"version":"0.1.0","schema":"software@0.1.0","mediums":{},"projections":{}}"#,
12247        )
12248        .unwrap();
12249        // Seed an entity that already has a shape-violating OWNS edge
12250        // baked into its markdown — simulates a pre-constraint state.
12251        fs::write(
12252            mem_dir.join("legacy-spec.md"),
12253            "---\ntype: spec\ncreated_date: 2026-05-13\nlast_modified: 2026-05-13\nlevel: M0\nstability: evolving\n---\n# Legacy Spec\n\n## Identity\nCarries a pre-constraint OWNS edge.\n\n## Purpose\nVerifies the cleanup path.\n\n## Relationships\n- **OWNS**: [[legacy-target]]\n",
12254        )
12255        .unwrap();
12256        fs::write(
12257            mem_dir.join("legacy-target.md"),
12258            "---\ntype: spec\ncreated_date: 2026-05-13\nlast_modified: 2026-05-13\nlevel: M0\nstability: evolving\n---\n# Legacy Target\n\n## Identity\nReceives the shape-violating OWNS.\n\n## Purpose\nMust remain removable.\n",
12259        )
12260        .unwrap();
12261        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
12262        let engine = setup_unified_test_engine(tmp.path());
12263        let server = McpServer::new(engine, crate::config::DEFAULT_TOKEN_BUDGET);
12264
12265        // Remove the existing shape-violating edge — must succeed.
12266        let result = server.memstead_relate(Parameters(RelateParams {
12267            relations: vec![RelateOpInput {
12268                from: "software-mem--legacy-spec".to_string(),
12269                to: "software-mem--legacy-target".to_string(),
12270                r#type: "OWNS".to_string(),
12271                remove: Some(true),
12272                description: None,
12273            }],
12274            note: None,
12275            role: None,
12276            dry_run: None,
12277        }));
12278        assert!(
12279            !result.is_error.unwrap_or(false),
12280            "remove path must not error on shape-violating edge: {:?}",
12281            extract_text(&result)
12282        );
12283    }
12284
12285    #[test]
12286    fn test_empty_id_returns_validation_error() {
12287        let (server, _tmp) = setup_dual_test_engine();
12288        let result = server.memstead_entity(Parameters(EntityParams {
12289            id: "".to_string(),
12290            include_relations: None,
12291            include_context: None,
12292            sections: None,
12293            token_budget: None,
12294            chunk: None,
12295            include_provenance: None,
12296        }));
12297        assert!(result.is_error.unwrap_or(false));
12298        assert!(extract_text(&result).contains("must not be empty"));
12299    }
12300
12301    #[test]
12302    fn test_long_id_returns_validation_error() {
12303        let (server, _tmp) = setup_dual_test_engine();
12304        let long_id = "a".repeat(201);
12305        let result = server.memstead_entity(Parameters(EntityParams {
12306            id: long_id,
12307            include_relations: None,
12308            include_context: None,
12309            sections: None,
12310            token_budget: None,
12311            chunk: None,
12312            include_provenance: None,
12313        }));
12314        assert!(result.is_error.unwrap_or(false));
12315        assert!(extract_text(&result).contains("too long"));
12316    }
12317
12318    /// `memstead_health.limit` is clamped at 100 to keep an over-eager
12319    /// caller from materialising the whole graph as `most_connected`
12320    /// records. The clamp surfaces as the uniform warning envelope
12321    /// `{ code: "LIMIT_CLAMPED", message, details: { requested, actual } }`
12322    /// so callers can branch on `code` without reparsing the message.
12323    #[test]
12324    fn health_limit_clamps_with_warning() {
12325        let (server, _tmp) = setup_dual_test_engine();
12326        let result = server.memstead_health(Parameters(HealthParams {
12327            include: Some(vec!["most_connected".to_string()]),
12328            limit: Some(1000),
12329            mem: None,
12330            include_config: false,
12331            token_budget: None,
12332            chunk: None,
12333            target_schema: None,
12334        }));
12335        assert!(
12336            !result.is_error.unwrap_or(false),
12337            "health call failed: {}",
12338            extract_text(&result)
12339        );
12340        let text = extract_text(&result);
12341        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
12342
12343        // Detail list never exceeds the 100-entry cap (the fixture is small,
12344        // so this is more about the contract than the count itself).
12345        let connected = json["most_connected"]
12346            .as_array()
12347            .expect("most_connected list present when included");
12348        assert!(
12349            connected.len() <= 100,
12350            "most_connected exceeds clamp: {} entries",
12351            connected.len()
12352        );
12353
12354        // Warning surfaces the clamp as a typed `WarningHint::LimitClamped`.
12355        let warnings = json["warnings"]
12356            .as_array()
12357            .expect("clamped limit must surface a warnings entry");
12358        let clamp = warnings
12359            .iter()
12360            .find(|w| w["code"].as_str() == Some("LIMIT_CLAMPED"))
12361            .expect("expected a `code: \"LIMIT_CLAMPED\"` warning envelope");
12362        assert_eq!(clamp["details"]["requested"].as_u64(), Some(1000));
12363        assert_eq!(clamp["details"]["actual"].as_u64(), Some(100));
12364    }
12365
12366    /// No clamp triggered ⇒ no `LIMIT_CLAMPED` entry.
12367    /// The `OUTER_REPO_NOT_IGNORING_MEM_REPO` warning may fire when the
12368    /// test workspace is embedded under a `/var/folders/.../.git`
12369    /// leftover — that's an environmental warning unrelated to the
12370    /// clamp behaviour the test pins. Filter to `LIMIT_CLAMPED`
12371    /// specifically rather than asserting an empty `warnings` field.
12372    #[test]
12373    fn health_limit_under_cap_emits_no_warning() {
12374        let (server, _tmp) = setup_dual_test_engine();
12375        let result = server.memstead_health(Parameters(HealthParams {
12376            include: Some(vec!["most_connected".to_string()]),
12377            limit: Some(10),
12378            mem: None,
12379            include_config: false,
12380            token_budget: None,
12381            chunk: None,
12382            target_schema: None,
12383        }));
12384        let text = extract_text(&result);
12385        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
12386        let clamp = json
12387            .get("warnings")
12388            .and_then(|w| w.as_array())
12389            .map(|arr| {
12390                arr.iter()
12391                    .any(|w| w["code"].as_str() == Some("LIMIT_CLAMPED"))
12392            })
12393            .unwrap_or(false);
12394        assert!(!clamp, "no clamp ⇒ no LIMIT_CLAMPED warning; got {}", json);
12395    }
12396
12397    /// `memstead_update` response must carry the structured
12398    /// `modified_sections` / `modified_metadata` objects. A mixed-mode
12399    /// call populates every sub-bucket exactly once; empty sub-vecs are
12400    /// serde-omitted so callers don't have to special-case `[]`. The
12401    /// wire shape is a stable object-of-arrays, not a flat
12402    /// `modified_fields` string vec with mode-prefix encoding.
12403    #[test]
12404    fn update_response_wire_shape() {
12405        let (server, _tmp) = setup_dual_test_engine();
12406
12407        // Bootstrap a fresh entity with a `tags` metadata and
12408        // `constraints` section body so we have something to unset and
12409        // something to patch.
12410        let create = server.memstead_create(Parameters(CreateParams {
12411            anchors: None,
12412            title: "Wire Shape".to_string(),
12413            entity_type: "spec".to_string(),
12414            mem: Some("specs".to_string()),
12415            sections: Some(IndexMap::from_iter([
12416                ("identity".to_string(), "i".to_string()),
12417                ("purpose".to_string(), "p".to_string()),
12418                ("constraints".to_string(), "drop me".to_string()),
12419            ])),
12420            metadata: Some(IndexMap::from_iter([(
12421                "tags".to_string(),
12422                "x, y".to_string(),
12423            )])),
12424            relations: None,
12425            dry_run: Some(false),
12426
12427            note: None,
12428
12429            role: None,
12430        }));
12431        let create_json: serde_json::Value = serde_json::from_str(&extract_text(&create)).unwrap();
12432        let id = create_json["id"].as_str().unwrap().to_string();
12433        let hash = create_json["_hash"].as_str().unwrap().to_string();
12434
12435        // Mixed-mode update: replace, append, patch, metadata-set,
12436        // metadata-unset all in one call.
12437        let result = server.memstead_update(Parameters(UpdateParams {
12438            anchors: None,
12439            relations_unset: None,
12440            anchors_unset: None,
12441            id,
12442            expected_hash: hash,
12443            sections: Some(IndexMap::from_iter([(
12444                "identity".to_string(),
12445                "replaced.".to_string(),
12446            )])),
12447            append_sections: Some(IndexMap::from_iter([(
12448                "purpose".to_string(),
12449                "tail.".to_string(),
12450            )])),
12451            patch_sections: Some(IndexMap::from_iter([(
12452                "constraints".to_string(),
12453                crate::tools::mutation::PatchInput {
12454                    old: "drop me".to_string(),
12455                    new: "keep me".to_string(),
12456                    all: Some(false),
12457                },
12458            )])),
12459            metadata: Some(IndexMap::from_iter([(
12460                "level".to_string(),
12461                "M1".to_string(),
12462            )])),
12463            metadata_unset: Some(vec!["tags".to_string()]),
12464            dry_run: Some(false),
12465
12466            note: None,
12467
12468            role: None,
12469            declare_relations: None,
12470        }));
12471        assert!(
12472            !result.is_error.unwrap_or(false),
12473            "mixed update must succeed: {}",
12474            extract_text(&result)
12475        );
12476        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
12477
12478        // Parent keys are always present as objects — stable shape for
12479        // callers.
12480        assert!(
12481            json["modified_sections"].is_object(),
12482            "modified_sections must be an object, got {}",
12483            json["modified_sections"]
12484        );
12485        assert!(
12486            json["modified_metadata"].is_object(),
12487            "modified_metadata must be an object, got {}",
12488            json["modified_metadata"]
12489        );
12490
12491        // Sub-vecs carry exactly the keys we set — no mode-prefix
12492        // encoding, no cross-bucket noise.
12493        assert_eq!(
12494            json["modified_sections"]["replaced"],
12495            serde_json::json!(["identity"])
12496        );
12497        assert_eq!(
12498            json["modified_sections"]["appended"],
12499            serde_json::json!(["purpose"])
12500        );
12501        assert_eq!(
12502            json["modified_sections"]["patched"],
12503            serde_json::json!(["constraints"])
12504        );
12505        assert_eq!(
12506            json["modified_metadata"]["set"],
12507            serde_json::json!(["level"])
12508        );
12509        assert_eq!(
12510            json["modified_metadata"]["unset"],
12511            serde_json::json!(["tags"])
12512        );
12513
12514        // The old flat field must be gone — guards against a future
12515        // backward-compat shim leaking it back.
12516        assert!(
12517            json.get("modified_fields").is_none(),
12518            "modified_fields must not be on the wire anymore"
12519        );
12520    }
12521
12522    /// `memstead_update` dry-run returns BOTH the unchanged on-disk hash
12523    /// (as `_hash`) AND the post-write hash the proposed change
12524    /// would produce (as `prospective_hash`). The agent uses
12525    /// `_hash` as the `expected_hash` on the follow-up real call
12526    /// (the disk file is unchanged, so optimistic locking accepts it)
12527    /// and predicts the post-write `_hash` via
12528    /// `prospective_hash`.
12529    ///
12530    /// End-to-end round-trip: create → dry-run → real update with the
12531    /// dry-run's `_hash`. Asserts (a) `prospective_hash` differs from
12532    /// `_hash` (otherwise the dry-run wasn't really previewing a
12533    /// change), (b) the real call succeeds with the dry-run's `_hash`
12534    /// as the lock, and (c) the real call's returned `_hash` matches
12535    /// the dry-run's `prospective_hash`.
12536    #[test]
12537    fn update_dry_run_returns_prospective_and_current_hash() {
12538        let (server, _tmp) = setup_dual_test_engine();
12539        // Pin the mutation clock: the auto-stamped `last_modified`
12540        // enters the hash, so the prospective == real post-write
12541        // equality assertion below is only deterministic under a
12542        // frozen clock (unpinned, it fails across a second tick).
12543        server
12544            .unified_engine()
12545            .lock()
12546            .unwrap()
12547            .set_mutation_clock(std::sync::Arc::new(|| {
12548                std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
12549            }));
12550        let id = "specs--entity-a".to_string();
12551
12552        // Read current hash via memstead_entity.
12553        let entity_result = server.memstead_entity(Parameters(EntityParams {
12554            id: id.clone(),
12555            include_relations: None,
12556            include_context: None,
12557            sections: None,
12558            token_budget: None,
12559            chunk: None,
12560            include_provenance: None,
12561        }));
12562        let entity_text = extract_text(&entity_result);
12563        let initial_hash = entity_text
12564            .lines()
12565            .find(|l| l.starts_with("_hash:"))
12566            .and_then(|l| l.split(':').nth(1))
12567            .map(|s| s.trim().to_string())
12568            .expect("entity response must include _hash frontmatter");
12569
12570        // Dry-run update.
12571        let dry_run = server.memstead_update(Parameters(UpdateParams {
12572            anchors: None,
12573            relations_unset: None,
12574            anchors_unset: None,
12575            id: id.clone(),
12576            expected_hash: initial_hash.clone(),
12577            sections: Some(IndexMap::from_iter([(
12578                "identity".to_string(),
12579                "Dry-run preview content.".to_string(),
12580            )])),
12581            append_sections: None,
12582            patch_sections: None,
12583            metadata: None,
12584            metadata_unset: None,
12585            dry_run: Some(true),
12586
12587            note: None,
12588
12589            role: None,
12590            declare_relations: None,
12591        }));
12592        assert!(
12593            !dry_run.is_error.unwrap_or(false),
12594            "dry-run failed: {}",
12595            extract_text(&dry_run)
12596        );
12597        let dry_text = extract_text(&dry_run);
12598        let dry_json: serde_json::Value = serde_json::from_str(&dry_text).unwrap();
12599
12600        let dry_current_hash = dry_json["_hash"].as_str().unwrap().to_string();
12601        let prospective_hash = dry_json["prospective_hash"]
12602            .as_str()
12603            .expect("dry-run must return prospective_hash")
12604            .to_string();
12605        // Disk file untouched ⇒ content_hash echoes the entity's current hash.
12606        assert_eq!(
12607            dry_current_hash, initial_hash,
12608            "dry-run content_hash must mirror current on-disk hash"
12609        );
12610        // Proposed change must shift the hash; otherwise the test isn't
12611        // really previewing anything.
12612        assert_ne!(
12613            dry_current_hash, prospective_hash,
12614            "prospective_hash must differ from current_hash for a non-trivial change"
12615        );
12616
12617        // Real update with the dry-run's content_hash (= the unchanged disk
12618        // hash) as the lock. This is the documented "preview → commit"
12619        // pattern.
12620        let real = server.memstead_update(Parameters(UpdateParams {
12621            anchors: None,
12622            relations_unset: None,
12623            anchors_unset: None,
12624            id: id.clone(),
12625            expected_hash: dry_current_hash,
12626            sections: Some(IndexMap::from_iter([(
12627                "identity".to_string(),
12628                "Dry-run preview content.".to_string(),
12629            )])),
12630            append_sections: None,
12631            patch_sections: None,
12632            metadata: None,
12633            metadata_unset: None,
12634            dry_run: Some(false),
12635
12636            note: None,
12637
12638            role: None,
12639            declare_relations: None,
12640        }));
12641        assert!(
12642            !real.is_error.unwrap_or(false),
12643            "real update failed: {}",
12644            extract_text(&real)
12645        );
12646        let real_text = extract_text(&real);
12647        let real_json: serde_json::Value = serde_json::from_str(&real_text).unwrap();
12648
12649        // Real call must NOT carry a prospective_hash (Option::is_none is
12650        // skipped in serialization).
12651        assert!(
12652            real_json.get("prospective_hash").is_none(),
12653            "real (non-dry-run) update must omit prospective_hash; got {}",
12654            real_json
12655        );
12656
12657        // The real call's post-write content_hash must match the prospective
12658        // hash the dry-run predicted.
12659        let real_hash = real_json["_hash"].as_str().unwrap().to_string();
12660        assert_eq!(
12661            real_hash, prospective_hash,
12662            "real post-write hash must equal dry-run's prospective_hash"
12663        );
12664    }
12665
12666    /// `dry_run` is a recovery path: a stale `expected_hash` must not
12667    /// reject a dry-run call; the response carries the current on-disk
12668    /// hash as `_hash`, which the agent uses as `expected_hash`
12669    /// on the real follow-up.
12670    #[test]
12671    fn update_dry_run_recovers_stale_hash() {
12672        let (server, _tmp) = setup_dual_test_engine();
12673        let id = "specs--entity-a".to_string();
12674
12675        let stale = "0".repeat(64);
12676        let dry_run = server.memstead_update(Parameters(UpdateParams {
12677            anchors: None,
12678            relations_unset: None,
12679            anchors_unset: None,
12680            id: id.clone(),
12681            expected_hash: stale.clone(),
12682            sections: Some(IndexMap::from_iter([(
12683                "identity".to_string(),
12684                "Recovery preview.".to_string(),
12685            )])),
12686            append_sections: None,
12687            patch_sections: None,
12688            metadata: None,
12689            metadata_unset: None,
12690            dry_run: Some(true),
12691
12692            note: None,
12693
12694            role: None,
12695            declare_relations: None,
12696        }));
12697        assert!(
12698            !dry_run.is_error.unwrap_or(false),
12699            "dry_run must ignore stale expected_hash: {}",
12700            extract_text(&dry_run)
12701        );
12702        let dry_json: serde_json::Value = serde_json::from_str(&extract_text(&dry_run)).unwrap();
12703        let recovered = dry_json["_hash"].as_str().unwrap();
12704        assert_ne!(
12705            recovered, stale,
12706            "content_hash must be the real on-disk hash, not the stale input"
12707        );
12708        // MCP serialises the engine's truncated SHA-256 (16 hex chars).
12709        assert_eq!(
12710            recovered.len(),
12711            16,
12712            "content_hash must be truncated SHA-256 hex"
12713        );
12714
12715        // Follow-up real call with the recovered hash must succeed.
12716        let real = server.memstead_update(Parameters(UpdateParams {
12717            anchors: None,
12718            relations_unset: None,
12719            anchors_unset: None,
12720            id,
12721            expected_hash: recovered.to_string(),
12722            sections: Some(IndexMap::from_iter([(
12723                "identity".to_string(),
12724                "Recovery preview.".to_string(),
12725            )])),
12726            append_sections: None,
12727            patch_sections: None,
12728            metadata: None,
12729            metadata_unset: None,
12730            dry_run: Some(false),
12731
12732            note: None,
12733
12734            role: None,
12735            declare_relations: None,
12736        }));
12737        assert!(
12738            !real.is_error.unwrap_or(false),
12739            "real follow-up failed: {}",
12740            extract_text(&real)
12741        );
12742    }
12743
12744    /// `memstead_delete` requires `expected_hash`. A stale hash must
12745    /// produce an engine `HashMismatch` and leave the entity intact
12746    /// (mirror of the contract `memstead_update` / `memstead_rename` enforce).
12747    #[test]
12748    fn delete_rejects_stale_hash() {
12749        let (server, _tmp) = setup_dual_test_engine();
12750        let id = "specs--entity-a".to_string();
12751
12752        let result = server.memstead_delete(Parameters(DeleteParams {
12753            id: id.clone(),
12754            expected_hash: "0000000000000000000000000000000000000000000000000000000000000000"
12755                .to_string(),
12756
12757            note: None,
12758
12759            role: None,
12760        }));
12761        assert!(
12762            result.is_error.unwrap_or(false),
12763            "delete with stale hash must fail"
12764        );
12765
12766        // HashMismatch must carry a structured payload so agents can recover
12767        // without a follow-up memstead_entity read.
12768        let sc = result
12769            .structured_content
12770            .as_ref()
12771            .expect("HashMismatch must carry structured_content");
12772        assert_eq!(sc["code"], "HASH_MISMATCH");
12773        let current = sc["details"]["current"]
12774            .as_str()
12775            .expect("details.current must be a string");
12776        // `compute_hash` returns truncated SHA-256 (first 16 hex chars) —
12777        // same shape content_hash carries throughout the surface.
12778        assert_eq!(
12779            current.len(),
12780            16,
12781            "current must be truncated SHA-256 hex (16 chars), got {current:?}"
12782        );
12783        assert!(
12784            current.chars().all(|c| c.is_ascii_hexdigit()),
12785            "current must be hex, got {current:?}"
12786        );
12787
12788        // Entity must survive the failed delete — prove it with a live read.
12789        let entity_result = server.memstead_entity(Parameters(EntityParams {
12790            id,
12791            include_relations: None,
12792            include_context: None,
12793            sections: None,
12794            token_budget: None,
12795            chunk: None,
12796            include_provenance: None,
12797        }));
12798        assert!(
12799            !entity_result.is_error.unwrap_or(false),
12800            "entity must still be readable after failed delete"
12801        );
12802    }
12803
12804    /// `memstead_rename` response must carry a non-empty `_hash` on both
12805    /// a real rename and the slug-noop short-circuit, so agents can chain
12806    /// the next hash-protected op (memstead_update / memstead_delete / memstead_rename)
12807    /// without a fresh memstead_entity read. Mirrors what memstead_relate already
12808    /// returns — locks the cross-tool contract in wire-shape form.
12809    #[test]
12810    fn rename_response_wire_shape() {
12811        let (server, _tmp) = setup_dual_test_engine();
12812
12813        // Bootstrap a fresh entity via memstead_create to get a known-valid hash
12814        // without parsing memstead_entity's markdown frontmatter.
12815        let create = server.memstead_create(Parameters(CreateParams {
12816            anchors: None,
12817            title: "Rename Wire".to_string(),
12818            entity_type: "spec".to_string(),
12819            mem: Some("specs".to_string()),
12820            sections: Some(IndexMap::from_iter([
12821                ("identity".to_string(), "x".to_string()),
12822                ("purpose".to_string(), "y".to_string()),
12823            ])),
12824            metadata: None,
12825            relations: None,
12826            dry_run: Some(false),
12827
12828            note: None,
12829
12830            role: None,
12831        }));
12832        let create_json: serde_json::Value = serde_json::from_str(&extract_text(&create)).unwrap();
12833        let id = create_json["id"].as_str().unwrap().to_string();
12834        let create_hash = create_json["_hash"].as_str().unwrap().to_string();
12835
12836        // Real rename → non-empty content_hash on the response.
12837        let real = server.memstead_rename(Parameters(RenameParams {
12838            id: id.clone(),
12839            new_title: "Rename Wire Changed".to_string(),
12840            expected_hash: create_hash.clone(),
12841
12842            note: None,
12843
12844            role: None,
12845        }));
12846        assert!(
12847            !real.is_error.unwrap_or(false),
12848            "real rename must succeed: {}",
12849            extract_text(&real)
12850        );
12851        let real_json: serde_json::Value = serde_json::from_str(&extract_text(&real)).unwrap();
12852        let real_hash = real_json["_hash"]
12853            .as_str()
12854            .expect("content_hash must be a string on real rename");
12855        assert_eq!(
12856            real_hash.len(),
12857            16,
12858            "content_hash must be truncated SHA-256 hex"
12859        );
12860
12861        // Slug-noop → echoes the current on-disk hash of the renamed entity.
12862        let noop = server.memstead_rename(Parameters(RenameParams {
12863            id: real_json["new_id"].as_str().unwrap().to_string(),
12864            new_title: "RENAME WIRE CHANGED".to_string(), // case-only — same slug
12865            expected_hash: real_hash.to_string(),
12866
12867            note: None,
12868
12869            role: None,
12870        }));
12871        assert!(
12872            !noop.is_error.unwrap_or(false),
12873            "slug-noop rename must succeed: {}",
12874            extract_text(&noop)
12875        );
12876        let noop_json: serde_json::Value = serde_json::from_str(&extract_text(&noop)).unwrap();
12877        assert_eq!(
12878            noop_json["old_id"], noop_json["new_id"],
12879            "slug-noop must keep the id unchanged"
12880        );
12881        assert_eq!(
12882            noop_json["_hash"].as_str().unwrap(),
12883            real_hash,
12884            "slug-noop echoes the unchanged on-disk hash"
12885        );
12886    }
12887
12888    /// Re-adding the same edge returns success but surfaces a typed
12889    /// `WarningHint::DuplicateRelationship` on the wire (envelope
12890    /// `code: "DUPLICATE_RELATIONSHIP"`).
12891    #[test]
12892    fn relate_duplicate_emits_typed_warning() {
12893        let (server, _tmp) = setup_dual_test_engine();
12894
12895        // First call — establishes the edge cleanly.
12896        let first = server.memstead_relate(Parameters(RelateParams {
12897            relations: vec![RelateOpInput {
12898                from: "specs--entity-a".to_string(),
12899                to: "specs--entity-b".to_string(),
12900                r#type: "DEPENDS_ON".to_string(),
12901                remove: None,
12902                description: None,
12903            }],
12904            note: None,
12905            role: None,
12906            dry_run: None,
12907        }));
12908        assert!(
12909            !first.is_error.unwrap_or(false),
12910            "first relate failed: {}",
12911            extract_text(&first)
12912        );
12913        let first_json: serde_json::Value = serde_json::from_str(&extract_text(&first)).unwrap();
12914        assert!(
12915            first_json.get("warnings").is_none()
12916                || first_json["warnings"]
12917                    .as_array()
12918                    .map(|a| {
12919                        !a.iter()
12920                            .any(|w| w["code"].as_str() == Some("DUPLICATE_RELATIONSHIP"))
12921                    })
12922                    .unwrap_or(true),
12923            "first call must not carry duplicate warning; got {first_json}"
12924        );
12925
12926        // Second call — same edge, must succeed AND emit the typed warning.
12927        let second = server.memstead_relate(Parameters(RelateParams {
12928            relations: vec![RelateOpInput {
12929                from: "specs--entity-a".to_string(),
12930                to: "specs--entity-b".to_string(),
12931                r#type: "DEPENDS_ON".to_string(),
12932                remove: None,
12933                description: None,
12934            }],
12935            note: None,
12936            role: None,
12937            dry_run: None,
12938        }));
12939        assert!(
12940            !second.is_error.unwrap_or(false),
12941            "duplicate relate must succeed: {}",
12942            extract_text(&second)
12943        );
12944        let second_json: serde_json::Value = serde_json::from_str(&extract_text(&second)).unwrap();
12945        let warnings = second_json["warnings"]
12946            .as_array()
12947            .expect("duplicate relate must carry warnings");
12948        let dup = warnings
12949            .iter()
12950            .find(|w| w["code"].as_str() == Some("DUPLICATE_RELATIONSHIP"))
12951            .expect("DuplicateRelationship warning expected on repeat add");
12952        assert_eq!(dup["details"]["rel_type"].as_str(), Some("DEPENDS_ON"));
12953        assert_eq!(dup["details"]["from"].as_str(), Some("specs--entity-a"));
12954        assert_eq!(dup["details"]["to"].as_str(), Some("specs--entity-b"));
12955    }
12956
12957    /// Removing an edge that was never there returns success but
12958    /// surfaces a typed `WarningHint::NoSuchRelationship` on the wire.
12959    #[test]
12960    fn relate_remove_nonexistent_emits_typed_warning() {
12961        let (server, _tmp) = setup_dual_test_engine();
12962
12963        // No DEPENDS_ON edge exists from a→b yet; removing it is a no-op.
12964        let result = server.memstead_relate(Parameters(RelateParams {
12965            relations: vec![RelateOpInput {
12966                from: "specs--entity-a".to_string(),
12967                to: "specs--entity-b".to_string(),
12968                r#type: "DEPENDS_ON".to_string(),
12969                remove: Some(true),
12970                description: None,
12971            }],
12972            note: None,
12973            role: None,
12974            dry_run: None,
12975        }));
12976        assert!(
12977            !result.is_error.unwrap_or(false),
12978            "remove-nonexistent must succeed: {}",
12979            extract_text(&result)
12980        );
12981        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
12982        let warnings = json["warnings"]
12983            .as_array()
12984            .expect("remove-nonexistent must carry warnings");
12985        let miss = warnings
12986            .iter()
12987            .find(|w| w["code"].as_str() == Some("NO_SUCH_RELATIONSHIP"))
12988            .expect("NoSuchRelationship warning expected on remove no-op");
12989        assert_eq!(miss["details"]["rel_type"].as_str(), Some("DEPENDS_ON"));
12990        assert_eq!(miss["details"]["from"].as_str(), Some("specs--entity-a"));
12991        assert_eq!(miss["details"]["to"].as_str(), Some("specs--entity-b"));
12992    }
12993
12994    /// An unknown `include` key emits a typed
12995    /// `WarningHint::UnknownIncludeKey` with the allowed list echoed
12996    /// back so the agent can self-correct.
12997    #[test]
12998    fn health_unknown_include_emits_typed_warning() {
12999        let (server, _tmp) = setup_dual_test_engine();
13000        let result = server.memstead_health(Parameters(HealthParams {
13001            include: Some(vec!["orphans".into(), "bogus".into()]),
13002            limit: None,
13003            mem: None,
13004            include_config: false,
13005            token_budget: None,
13006            chunk: None,
13007            target_schema: None,
13008        }));
13009        assert!(
13010            !result.is_error.unwrap_or(false),
13011            "health call failed: {}",
13012            extract_text(&result)
13013        );
13014        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
13015        // Known key still materialises its detail list.
13016        assert!(
13017            json["orphans"].is_array(),
13018            "orphans detail must still appear alongside bogus key"
13019        );
13020        let warnings = json["warnings"]
13021            .as_array()
13022            .expect("unknown include key must surface a warnings entry");
13023        let unknown = warnings
13024            .iter()
13025            .find(|w| w["code"].as_str() == Some("UNKNOWN_INCLUDE_KEY"))
13026            .expect("UnknownIncludeKey warning expected");
13027        assert_eq!(unknown["details"]["key"].as_str(), Some("bogus"));
13028        let allowed = unknown["details"]["allowed"]
13029            .as_array()
13030            .expect("allowed list must echo back to the caller");
13031        // Canonical set — keeps the wire contract stable for machine consumers.
13032        let allowed_set: std::collections::HashSet<String> = allowed
13033            .iter()
13034            .map(|v| v.as_str().unwrap().to_string())
13035            .collect();
13036        for expected in [
13037            "orphans",
13038            "stubs",
13039            "most_connected",
13040            "missing_fields",
13041            "stale",
13042            "dangling_links",
13043            "tags",
13044        ] {
13045            assert!(
13046                allowed_set.contains(expected),
13047                "allowed list must include `{expected}`: got {allowed:?}"
13048            );
13049        }
13050    }
13051
13052    /// An inline `[[id]]` in an entity's section body whose target has
13053    /// no on-disk markdown file (post-delete, rename-without-rewrite,
13054    /// or typo) surfaces as a `dangling_links` detail entry when the
13055    /// caller opts in via `include=["dangling_links"]`.
13056    #[test]
13057    fn health_dangling_links_surfaces_stub_targets() {
13058        let tmp = TempDir::new().unwrap();
13059        let mem_dir = tmp.path().join("specs");
13060        fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
13061        fs::write(
13062            mem_dir.join(".memstead/config.json"),
13063            r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
13064        )
13065        .unwrap();
13066        // Only `a.md` on disk — its body references `[[gone]]`, which
13067        // has no file and therefore auto-stubs at load time. The stub
13068        // is the dangling signal.
13069        fs::write(
13070            mem_dir.join("a.md"),
13071            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# A\n\n## Identity\n\nFixture.\n\n## Purpose\n\nRefers to [[gone]] in prose.\n",
13072        )
13073        .unwrap();
13074
13075        memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
13076        let server = McpServer::new(
13077            setup_unified_test_engine(tmp.path()),
13078            crate::config::DEFAULT_TOKEN_BUDGET,
13079        );
13080
13081        let result = server.memstead_health(Parameters(HealthParams {
13082            include: Some(vec!["dangling_links".to_string()]),
13083            limit: None,
13084            mem: None,
13085            include_config: false,
13086            token_budget: None,
13087            chunk: None,
13088            target_schema: None,
13089        }));
13090        assert!(
13091            !result.is_error.unwrap_or(false),
13092            "health call failed: {}",
13093            extract_text(&result)
13094        );
13095        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
13096        let arr = json["dangling_links"]
13097            .as_array()
13098            .expect("dangling_links array present when key opted in");
13099        assert_eq!(arr.len(), 1, "exactly one dangling link expected: {arr:?}");
13100        let entry = &arr[0];
13101        assert_eq!(entry["from"].as_str(), Some("specs--a"));
13102        assert_eq!(entry["target_id"].as_str(), Some("specs--gone"));
13103        assert_eq!(entry["target_path"].as_str(), Some("gone"));
13104        assert_eq!(entry["section"].as_str(), Some("purpose"));
13105    }
13106
13107    /// `include=["tags"]` populates `tag_distribution`,
13108    /// `tag_distribution_folded`, and `untagged_entities`; absence of
13109    /// the key leaves every field unset (matches the `dangling_links`
13110    /// handler-driven contract).
13111    #[test]
13112    fn health_tags_include_switch_emits_field() {
13113        let (server, _tmp) = setup_dual_test_engine();
13114
13115        // With include=["tags"]: every field present.
13116        let result = server.memstead_health(Parameters(HealthParams {
13117            include: Some(vec!["tags".to_string()]),
13118            limit: None,
13119            mem: None,
13120            include_config: false,
13121            token_budget: None,
13122            chunk: None,
13123            target_schema: None,
13124        }));
13125        assert!(!result.is_error.unwrap_or(false));
13126        let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
13127        assert!(
13128            json.get("tag_distribution").is_some(),
13129            "tag_distribution present when key opted in"
13130        );
13131        assert!(
13132            json["tag_distribution"].is_array(),
13133            "tag_distribution must be an array"
13134        );
13135        assert!(
13136            json.get("untagged_entities").is_some(),
13137            "untagged_entities present when key opted in"
13138        );
13139        assert!(
13140            json["untagged_entities"].is_object(),
13141            "untagged_entities must be an object"
13142        );
13143        assert!(
13144            json.get("tag_distribution_folded").is_some(),
13145            "tag_distribution_folded present when key opted in"
13146        );
13147
13148        // Without include: none of the three appear.
13149        let result_no_include = server.memstead_health(Parameters(HealthParams {
13150            include: None,
13151            limit: None,
13152            mem: None,
13153            include_config: false,
13154            token_budget: None,
13155            chunk: None,
13156            target_schema: None,
13157        }));
13158        let json_no: serde_json::Value =
13159            serde_json::from_str(&extract_text(&result_no_include)).unwrap();
13160        assert!(json_no.get("tag_distribution").is_none());
13161        assert!(json_no.get("tag_distribution_folded").is_none());
13162        assert!(json_no.get("untagged_entities").is_none());
13163    }
13164
13165    // ------------------------------------------------------------------
13166    // memstead_mem_create — envelope + happy-path wire tests.
13167    //
13168    // The engine-level contract is covered in
13169    // `memstead-git-branch/tests/mem_management.rs`; these tests pin the
13170    // MCP-layer envelope shape (structured_content `{code, message,
13171    // details}`) and the success-path JSON response so the contract the
13172    // agent sees doesn't drift from the handler's wire surface.
13173    // ------------------------------------------------------------------
13174
13175    use crate::lifecycle::MemCreateParams as TlsMemCreateParams;
13176    use memstead_base::WorkspaceSettings;
13177
13178    /// Build an `McpServer` around an empty engine with permissive
13179    /// `[[mem_management.create]]` rules rooted at the given
13180    /// TempDir. Two patterns: a flat `*` (single-segment leaves) and a
13181    /// `**` (any depth, including hierarchical path-and-leaf
13182    /// candidates) — the minimum surface `memstead_mem_create` tests
13183    /// need to exercise both flat and hierarchical layouts under
13184    /// gitignore-style matching.
13185    fn setup_lifecycle_server(tmp: &TempDir) -> McpServer {
13186        // The engine produces real 40-char hex `seed_commit_sha`
13187        // values when mounted on a mem-repo (backend factory +
13188        // storage heuristic). Use the git-branch test-engine helper
13189        // so memstead_mem_create asserts against real commit shas.
13190        let full_settings = memstead_git_branch::test_support::auto_seed_with_settings(
13191            tmp.path(),
13192            WorkspaceSettings {
13193                mem_create_rules: vec![
13194                    memstead_base::CreateRuleSetting {
13195                        pattern: "*".to_string(),
13196                        schemas: vec!["default@1.0.0".to_string()],
13197                        default_cross_links: None,
13198                    },
13199                    memstead_base::CreateRuleSetting {
13200                        pattern: "**".to_string(),
13201                        schemas: vec!["default@1.0.0".to_string()],
13202                        default_cross_links: None,
13203                    },
13204                ],
13205                mem_delete_rules: vec![],
13206                ..Default::default()
13207            },
13208        );
13209        let unified_settings = full_settings.clone();
13210        let mut unified = setup_unified_test_engine_git_branch(tmp.path());
13211        unified.set_settings(unified_settings);
13212        let canonical_root =
13213            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
13214        unified.set_workspace_root(canonical_root);
13215        McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET)
13216    }
13217
13218    #[test]
13219    fn memstead_mem_create_happy_path_returns_seed_sha() {
13220        let tmp = TempDir::new().unwrap();
13221        let server = setup_lifecycle_server(&tmp);
13222        let target = tmp.path().join("fresh");
13223        let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13224            title: None,
13225            description: None,
13226            subject: None,
13227            schema_verbosity: None,
13228            write_guidance: Default::default(),
13229            name: "fresh".to_string(),
13230            location: target.to_string_lossy().into_owned(),
13231            schema: "default@1.0.0".to_string(),
13232
13233            vcs: None,
13234            note: Some("mcp handler happy path".to_string()),
13235            recovery: None,
13236            include_schema: false,
13237        }));
13238        assert!(
13239            result.is_error.is_none() || result.is_error == Some(false),
13240            "happy path must not be an error: {:?}",
13241            result
13242        );
13243        let json: serde_json::Value =
13244            serde_json::from_str(&extract_text(&result)).expect("response must be JSON");
13245        assert_eq!(json["name"], "fresh");
13246        assert!(
13247            json["seed_commit_sha"]
13248                .as_str()
13249                .is_some_and(|s| s.len() == 40),
13250            "seed_commit_sha must be a 40-char hex string: {}",
13251            json["seed_commit_sha"]
13252        );
13253        assert!(
13254            json.get("belongs_to").is_none(),
13255            "belongs_to is dropped from the response (workspace-cross-link-policy)"
13256        );
13257    }
13258
13259    /// Goal 2 (hierarchical content branches): a `memstead_mem_create`
13260    /// call that supplies an organizational `path` lands the new
13261    /// content branch at `refs/heads/<path>/<leaf>` and the matching
13262    /// per-mem config at `__MEMSTEAD:mems/<path>/<leaf>/config.json`. The
13263    /// leaf-only API surface still works — `read_config(ws, leaf)`
13264    /// transparently resolves to the full hierarchical path.
13265    #[test]
13266    fn memstead_mem_create_hierarchical_path_lands_branch_and_config() {
13267        // Hierarchical paths are first-class. `name = "planning/hier"` is the
13268        // canonical hierarchical-mem input — no separate `path`
13269        // wire field. The branch ref + `__MEMSTEAD` config blob land at
13270        // `refs/heads/planning/hier` / `mems/planning/hier/config.json`.
13271        let tmp = TempDir::new().unwrap();
13272        let server = setup_lifecycle_server(&tmp);
13273        let target = tmp.path().join("hier");
13274        let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13275            title: None,
13276            description: None,
13277            subject: None,
13278            schema_verbosity: None,
13279            write_guidance: Default::default(),
13280            name: "planning/hier".to_string(),
13281            location: target.to_string_lossy().into_owned(),
13282            schema: "default@1.0.0".to_string(),
13283            vcs: None,
13284            note: Some("hierarchical layout".to_string()),
13285            recovery: None,
13286            include_schema: false,
13287        }));
13288        assert!(
13289            result.is_error.is_none() || result.is_error == Some(false),
13290            "happy path with hierarchical name must succeed: {:?}",
13291            result
13292        );
13293
13294        // Branch landed at `refs/heads/planning/hier`.
13295        let gitdir = tmp.path().join("mem-repo").join(".git");
13296        let repo = gix::open(&gitdir).expect("mem-repo must open");
13297        assert!(
13298            matches!(
13299                repo.try_find_reference("refs/heads/planning/hier"),
13300                Ok(Some(_))
13301            ),
13302            "hierarchical content branch refs/heads/planning/hier must exist"
13303        );
13304        assert!(
13305            matches!(repo.try_find_reference("refs/heads/hier"), Ok(None)),
13306            "flat fallback refs/heads/hier must NOT exist for hierarchical create"
13307        );
13308
13309        // Config readable by leaf (resolver maps leaf → full path).
13310        let cfg = memstead_git_branch::mem_repo_config::read_config(tmp.path(), "hier")
13311            .expect("read_config must resolve leaf to hierarchical path");
13312        // Goal 3 made `name` optional in the on-disk config. The
13313        // engine omits it on writes; the legacy fixture path may
13314        // populate it. Tolerate both.
13315        assert!(cfg.name.is_none() || cfg.name.as_deref() == Some("hier"));
13316    }
13317
13318    /// A mem-create whose leaf already exists in the mem-repo at
13319    /// a different organizational path is rejected with
13320    /// `MEM_NAME_COLLISION` before any disk side effect lands. The
13321    /// envelope payload carries the colliding full paths so the agent
13322    /// can disambiguate without a second round trip.
13323    #[test]
13324    fn memstead_mem_create_tree_walk_collision_rejected_with_paths() {
13325        let tmp = TempDir::new().unwrap();
13326        let server = setup_lifecycle_server(&tmp);
13327
13328        // Seed an existing hierarchical branch under `demo/engine`
13329        // via a successful create. After this, leaf `engine` is
13330        // sealed at `refs/heads/demo/engine`.
13331        let first_target = tmp.path().join("engine");
13332        let first = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13333            title: None,
13334            description: None,
13335            subject: None,
13336            schema_verbosity: None,
13337            write_guidance: Default::default(),
13338            name: "engine".to_string(),
13339            location: first_target.to_string_lossy().into_owned(),
13340            schema: "default@1.0.0".to_string(),
13341
13342            vcs: None,
13343            note: None,
13344            recovery: None,
13345            include_schema: false,
13346        }));
13347        assert!(
13348            first.is_error.is_none() || first.is_error == Some(false),
13349            "first hierarchical create must succeed: {:?}",
13350            first
13351        );
13352
13353        // Second create with the same leaf at a different path. The
13354        // memory-router probe ALSO catches this (engine was registered),
13355        // but the tree-walk probe enriches the envelope with
13356        // `colliding_paths` and `suggestion` regardless.
13357        let second_target = tmp.path().join("engine-second");
13358        let second = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13359            title: None,
13360            description: None,
13361            subject: None,
13362            schema_verbosity: None,
13363            write_guidance: Default::default(),
13364            name: "engine".to_string(),
13365            location: second_target.to_string_lossy().into_owned(),
13366            schema: "default@1.0.0".to_string(),
13367
13368            vcs: None,
13369            note: None,
13370            recovery: None,
13371            include_schema: false,
13372        }));
13373        assert_eq!(
13374            second.is_error,
13375            Some(true),
13376            "second create with colliding leaf must surface an error envelope"
13377        );
13378
13379        let envelope = second
13380            .structured_content
13381            .clone()
13382            .expect("collision must carry envelope");
13383        assert_eq!(envelope["code"], "MEM_NAME_COLLISION");
13384        let details = &envelope["details"];
13385        assert_eq!(details["name"], "engine");
13386        // Collisions surface through the snapshot probe
13387        // (`mem_router.origin_for_mem`) with the `{name, source}`
13388        // payload. Agents branch on `code` only. The collision is
13389        // detected and rejected before any disk write at the second
13390        // target.
13391        assert!(
13392            !second_target.exists(),
13393            "collision must reject before disk side effects"
13394        );
13395    }
13396
13397    /// The mem-name
13398    /// grammar refuses malformed hierarchical paths (leading slash,
13399    /// double slash, etc.) before any disk side effect lands.
13400    /// `INVALID_INPUT` envelope is returned.
13401    #[test]
13402    fn memstead_mem_create_rejects_invalid_name_grammar() {
13403        let tmp = TempDir::new().unwrap();
13404        let server = setup_lifecycle_server(&tmp);
13405        let target = tmp.path().join("bad");
13406        let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13407            title: None,
13408            description: None,
13409            subject: None,
13410            schema_verbosity: None,
13411            write_guidance: Default::default(),
13412            name: "/leading-slash".to_string(),
13413            location: target.to_string_lossy().into_owned(),
13414            schema: "default@1.0.0".to_string(),
13415            vcs: None,
13416            note: Some("invalid grammar".to_string()),
13417            recovery: None,
13418            include_schema: false,
13419        }));
13420        assert!(
13421            result.is_error == Some(true),
13422            "invalid name grammar must surface as an error envelope"
13423        );
13424        let text = extract_text(&result);
13425        // Structural-failure
13426        // refusals surface as the typed `INVALID_MEM_NAME` code
13427        // with a `details.reason` discriminator. `/leading-slash`
13428        // would fail the regex grammar, classified as `invalid_char`.
13429        assert!(
13430            text.contains("INVALID_MEM_NAME"),
13431            "envelope must surface INVALID_MEM_NAME refusal, got: {}",
13432            text
13433        );
13434    }
13435
13436    #[test]
13437    fn memstead_mem_create_path_not_allowed_emits_structured_envelope() {
13438        // Empty allowlist surfaces MEM_PATH_NOT_ALLOWED through
13439        // `memstead_engine::mem_management::create_mem`'s pre-check.
13440        let tmp = TempDir::new().unwrap();
13441        // Empty allowlist.
13442        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
13443            tmp.path(),
13444            WorkspaceSettings {
13445                mem_create_rules: vec![],
13446                mem_delete_rules: vec![],
13447                ..Default::default()
13448            },
13449        );
13450        let unified_settings = settings.clone();
13451        let _ = settings;
13452        let mut unified = setup_unified_test_engine(tmp.path());
13453        unified.set_settings(unified_settings);
13454        let canonical_root =
13455            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
13456        unified.set_workspace_root(canonical_root);
13457        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
13458
13459        let target = tmp.path().join("blocked");
13460        let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13461            title: None,
13462            description: None,
13463            subject: None,
13464            schema_verbosity: None,
13465            write_guidance: Default::default(),
13466            name: "blocked".to_string(),
13467            location: target.to_string_lossy().into_owned(),
13468            schema: "default@1.0.0".to_string(),
13469
13470            vcs: None,
13471            note: None,
13472            recovery: None,
13473            include_schema: false,
13474        }));
13475        assert_eq!(result.is_error, Some(true));
13476        let envelope = result
13477            .structured_content
13478            .expect("error must carry structured envelope");
13479        assert_eq!(envelope["code"], "MEM_PATH_NOT_ALLOWED");
13480        assert!(envelope.get("message").is_some());
13481        let details = envelope
13482            .get("details")
13483            .expect("envelope must carry details");
13484        assert!(details.get("attempted").is_some());
13485        assert_eq!(details["patterns"], serde_json::json!([]));
13486        assert_eq!(details["reason"], "no_allowlist_configured");
13487        // The structured remedy reaches the MCP wire — the agent can
13488        // recover from `details` without parsing prose.
13489        assert_eq!(details["remedy"]["mcp"], "memstead_workspace_allow_create");
13490    }
13491
13492    #[test]
13493    fn memstead_mem_create_name_collision_envelope_carries_source() {
13494        let tmp = TempDir::new().unwrap();
13495        let server = setup_lifecycle_server(&tmp);
13496
13497        // First create — succeeds. Basename must equal the name under
13498        // the basename-invariant, so the location is `tmp/same/`.
13499        let first = tmp.path().join("same");
13500        let ok = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13501            title: None,
13502            description: None,
13503            subject: None,
13504            schema_verbosity: None,
13505            write_guidance: Default::default(),
13506            name: "same".to_string(),
13507            location: first.to_string_lossy().into_owned(),
13508            schema: "default@1.0.0".to_string(),
13509
13510            vcs: None,
13511            note: None,
13512            recovery: None,
13513            include_schema: false,
13514        }));
13515        assert!(ok.is_error.is_none() || ok.is_error == Some(false));
13516
13517        // Second create — same name, different canonical location
13518        // (nested under `b/`). Basename still matches `name`.
13519        std::fs::create_dir_all(tmp.path().join("b")).unwrap();
13520        let second = tmp.path().join("b").join("same");
13521        let err = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13522            title: None,
13523            description: None,
13524            subject: None,
13525            schema_verbosity: None,
13526            write_guidance: Default::default(),
13527            name: "same".to_string(),
13528            location: second.to_string_lossy().into_owned(),
13529            schema: "default@1.0.0".to_string(),
13530
13531            vcs: None,
13532            note: None,
13533            recovery: None,
13534            include_schema: false,
13535        }));
13536        assert_eq!(err.is_error, Some(true));
13537        let envelope = err
13538            .structured_content
13539            .expect("collision must carry envelope");
13540        assert_eq!(envelope["code"], "MEM_NAME_COLLISION");
13541        let source = envelope["details"]["source"]
13542            .as_str()
13543            .expect("details.source must be a string");
13544        // The snapshot probe renders as `runtime-created at <ts>
13545        // by memstead_mem_create`.
13546        assert!(
13547            source.contains("runtime-created") && source.contains("memstead_mem_create"),
13548            "snapshot-probe collision source must identify the runtime registration: {source}"
13549        );
13550    }
13551
13552    /// Cold-boot persistence regression.
13553    ///
13554    /// Reproduces a showstopper where a successful
13555    /// `memstead_mem_create` writes the per-mem branch + `__MEMSTEAD`
13556    /// config but NOT the workspace mount manifest, so the very next
13557    /// CLI / MCP process boots without the new mem.
13558    ///
13559    /// Asserts the engine-side persistence fix:
13560    /// 1. The mount manifest (`.memstead/state/mounts.json`) is rewritten
13561    ///    on create — a fresh `FileWorkspaceStore::load` sees the
13562    ///    new mount.
13563    /// 2. A second create with the same name surfaces
13564    ///    `MEM_NAME_COLLISION` (in-process; the in-memory router
13565    ///    already carries the mount). Combined with point 1, the
13566    ///    cold-boot collision is symmetric.
13567    /// 3. After `memstead_mem_delete`, the manifest no longer carries
13568    ///    the mount.
13569    #[test]
13570    fn memstead_mem_create_persists_mount_for_cold_boot() {
13571        let tmp = TempDir::new().unwrap();
13572        let server = setup_lifecycle_server_with_delete(&tmp);
13573        let canonical_root =
13574            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
13575
13576        // No mount file yet — auto_seed doesn't create one when no
13577        // pre-existing disk mems are present.
13578        let mounts_path = canonical_root
13579            .join(".memstead")
13580            .join("state")
13581            .join("mounts.json");
13582
13583        let target = canonical_root.join("persisted");
13584        let ok = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13585            title: None,
13586            description: None,
13587            subject: None,
13588            schema_verbosity: None,
13589            write_guidance: Default::default(),
13590            name: "persisted".to_string(),
13591            location: target.to_string_lossy().into_owned(),
13592            schema: "default@1.0.0".to_string(),
13593            vcs: None,
13594            note: None,
13595            recovery: None,
13596            include_schema: false,
13597        }));
13598        assert!(
13599            ok.is_error.is_none() || ok.is_error == Some(false),
13600            "create must succeed: {:?}",
13601            ok
13602        );
13603
13604        // Manifest now exists and a freshly-loaded workspace sees the
13605        // new mount.
13606        assert!(
13607            mounts_path.is_file(),
13608            "create must write {} so cold-boot sees the new mem",
13609            mounts_path.display()
13610        );
13611        let reloaded =
13612            <memstead_base::FileWorkspaceStore as memstead_base::WorkspaceStoreAdapter>::load(
13613                &memstead_base::FileWorkspaceStore::new(),
13614                &canonical_root,
13615            )
13616            .expect("reload must succeed");
13617        assert!(
13618            reloaded.mounts.iter().any(|m| m.mem == "persisted"),
13619            "cold-boot workspace must include the freshly-created mem: {:?}",
13620            reloaded.mounts.iter().map(|m| &m.mem).collect::<Vec<_>>()
13621        );
13622
13623        // Second create with the same name trips MEM_NAME_COLLISION —
13624        // F3 follows from the persistence fix.
13625        let dup = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13626            title: None,
13627            description: None,
13628            subject: None,
13629            schema_verbosity: None,
13630            write_guidance: Default::default(),
13631            name: "persisted".to_string(),
13632            location: target.to_string_lossy().into_owned(),
13633            schema: "default@1.0.0".to_string(),
13634            vcs: None,
13635            note: None,
13636            recovery: None,
13637            include_schema: false,
13638        }));
13639        assert_eq!(
13640            dup.is_error,
13641            Some(true),
13642            "second create of an already-persisted mem must collide"
13643        );
13644        let envelope = dup
13645            .structured_content
13646            .expect("collision must carry envelope");
13647        assert_eq!(envelope["code"], "MEM_NAME_COLLISION");
13648
13649        // Delete persists too — the manifest no longer carries the
13650        // mount after a successful unregister.
13651        let del = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
13652            name: "persisted".to_string(),
13653            note: None,
13654        }));
13655        assert!(
13656            del.is_error.is_none() || del.is_error == Some(false),
13657            "delete must succeed: {:?}",
13658            del
13659        );
13660        let after_delete =
13661            <memstead_base::FileWorkspaceStore as memstead_base::WorkspaceStoreAdapter>::load(
13662                &memstead_base::FileWorkspaceStore::new(),
13663                &canonical_root,
13664            )
13665            .expect("post-delete reload must succeed");
13666        assert!(
13667            after_delete.mounts.iter().all(|m| m.mem != "persisted"),
13668            "cold-boot workspace must NOT include the deleted mem"
13669        );
13670    }
13671
13672    #[test]
13673    fn memstead_mem_create_invalid_schema_ref_emits_invalid_input() {
13674        let tmp = TempDir::new().unwrap();
13675        let server = setup_lifecycle_server(&tmp);
13676        let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13677            title: None,
13678            description: None,
13679            subject: None,
13680            schema_verbosity: None,
13681            write_guidance: Default::default(),
13682            name: "bad-schema".to_string(),
13683            location: tmp.path().join("bad-schema").to_string_lossy().into_owned(),
13684            schema: "this-is-not-a-ref".to_string(),
13685
13686            vcs: None,
13687            note: None,
13688            recovery: None,
13689            include_schema: false,
13690        }));
13691        assert_eq!(result.is_error, Some(true));
13692        let envelope = result
13693            .structured_content
13694            .expect("invalid ref must carry envelope");
13695        assert_eq!(envelope["code"], "INVALID_INPUT");
13696    }
13697
13698    // ------------------------------------------------------------------
13699    // memstead_mem_delete — envelope + happy-path wire tests.
13700    // ------------------------------------------------------------------
13701
13702    use crate::lifecycle::MemDeleteParams as TlsMemDeleteParams;
13703
13704    /// Build an `McpServer` around an empty engine with matching create +
13705    /// delete allowlists rooted at the given TempDir, so `memstead_mem_delete`
13706    /// can actually unregister what a prior `memstead_mem_create` set up.
13707    fn setup_lifecycle_server_with_delete(tmp: &TempDir) -> McpServer {
13708        let full_settings = memstead_git_branch::test_support::auto_seed_with_settings(
13709            tmp.path(),
13710            WorkspaceSettings {
13711                mem_create_rules: vec![memstead_base::CreateRuleSetting {
13712                    pattern: "*".to_string(),
13713                    schemas: vec!["default@1.0.0".to_string()],
13714                    default_cross_links: None,
13715                }],
13716                mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
13717                    pattern: "*".to_string(),
13718                }],
13719                ..Default::default()
13720            },
13721        );
13722        let unified_settings = full_settings.clone();
13723        let mut unified = setup_unified_test_engine_git_branch(tmp.path());
13724        unified.set_settings(unified_settings);
13725        let canonical_root =
13726            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
13727        unified.set_workspace_root(canonical_root);
13728        McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET)
13729    }
13730
13731    #[test]
13732    fn memstead_mem_delete_happy_path_returns_destructive_response() {
13733        let tmp = TempDir::new().unwrap();
13734        let server = setup_lifecycle_server_with_delete(&tmp);
13735
13736        // Seed a mem first so delete has something to remove.
13737        let target = tmp.path().join("wipe");
13738        let create_result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13739            title: None,
13740            description: None,
13741            subject: None,
13742            schema_verbosity: None,
13743            write_guidance: Default::default(),
13744            name: "wipe".to_string(),
13745            location: target.to_string_lossy().into_owned(),
13746            schema: "default@1.0.0".to_string(),
13747
13748            vcs: None,
13749            note: None,
13750            recovery: None,
13751            include_schema: false,
13752        }));
13753        assert!(create_result.is_error.is_none() || create_result.is_error == Some(false));
13754
13755        // MCP `memstead_mem_delete`
13756        // is always destructive. The wrapper hardcodes `delete_files:
13757        // true`; the engine prunes the per-mem branch + `__MEMSTEAD` config
13758        // blob in one ref-edit transaction and the response carries
13759        // `files_deleted: true`.
13760        let result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
13761            name: "wipe".to_string(),
13762            note: Some("mcp handler happy path".to_string()),
13763        }));
13764        assert!(
13765            result.is_error.is_none() || result.is_error == Some(false),
13766            "happy path must not be an error: {:?}",
13767            result
13768        );
13769        let json: serde_json::Value =
13770            serde_json::from_str(&extract_text(&result)).expect("response must be JSON");
13771        assert_eq!(json["name"], "wipe");
13772        assert_eq!(json["deleted_from_router"], serde_json::Value::Bool(true));
13773        assert_eq!(json["files_deleted"], serde_json::Value::Bool(true));
13774        let _ = target;
13775    }
13776
13777    #[test]
13778    fn memstead_mem_delete_path_not_allowed_emits_structured_envelope() {
13779        let tmp = TempDir::new().unwrap();
13780        // Create allowlist is permissive, delete allowlist is empty.
13781        let settings = memstead_git_branch::test_support::auto_seed_with_settings(
13782            tmp.path(),
13783            WorkspaceSettings {
13784                mem_create_rules: vec![memstead_base::CreateRuleSetting {
13785                    pattern: "*".to_string(),
13786                    schemas: vec!["default@1.0.0".to_string()],
13787                    default_cross_links: None,
13788                }],
13789                mem_delete_rules: vec![],
13790                ..Default::default()
13791            },
13792        );
13793        let unified_settings = settings.clone();
13794        let _ = settings;
13795        let mut unified = setup_unified_test_engine(tmp.path());
13796        unified.set_settings(unified_settings);
13797        let canonical_root =
13798            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
13799        unified.set_workspace_root(canonical_root);
13800        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
13801
13802        // Set up a mem we can try to delete.
13803        let target = tmp.path().join("pinned");
13804        let _ = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13805            title: None,
13806            description: None,
13807            subject: None,
13808            schema_verbosity: None,
13809            write_guidance: Default::default(),
13810            name: "pinned".to_string(),
13811            location: target.to_string_lossy().into_owned(),
13812            schema: "default@1.0.0".to_string(),
13813
13814            vcs: None,
13815            note: None,
13816            recovery: None,
13817            include_schema: false,
13818        }));
13819
13820        let result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
13821            name: "pinned".to_string(),
13822            note: None,
13823        }));
13824        assert_eq!(result.is_error, Some(true));
13825        let envelope = result
13826            .structured_content
13827            .expect("error must carry structured envelope");
13828        assert_eq!(envelope["code"], "MEM_PATH_NOT_ALLOWED");
13829        let details = envelope
13830            .get("details")
13831            .expect("envelope must carry details");
13832        assert_eq!(details["reason"], "no_allowlist_configured");
13833    }
13834
13835    #[test]
13836    fn memstead_mem_delete_unknown_mem_emits_unknown_mem_envelope() {
13837        let tmp = TempDir::new().unwrap();
13838        let server = setup_lifecycle_server_with_delete(&tmp);
13839
13840        let result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
13841            name: "ghost".to_string(),
13842            note: None,
13843        }));
13844        assert_eq!(result.is_error, Some(true));
13845        let envelope = result
13846            .structured_content
13847            .expect("unknown must carry envelope");
13848        assert_eq!(envelope["code"], "UNKNOWN_MEM");
13849    }
13850
13851    /// `MEM_REFERENCED_BY_POLICY` fires when the workspace-level
13852    /// `[cross_mem_links]` policy grants any other writable mem
13853    /// permission to write into the delete target. Build an engine
13854    /// where `plan-x = ["primary"]` is declared in the effective
13855    /// cross-link map, then attempt to delete `primary` and assert
13856    /// the envelope surfaces the granting mem.
13857    #[test]
13858    fn memstead_mem_delete_policy_grant_envelope_carries_referring_mems() {
13859        use std::collections::BTreeMap;
13860        let tmp = TempDir::new().unwrap();
13861
13862        // Seed both mems via the create-allowlisted server, then
13863        // re-init the engine with explicit cross-link policy (no MCP
13864        // path mutates `[cross_mem_links]`; the operator edits
13865        // `.memstead/workspace.toml` directly).
13866        let server = setup_lifecycle_server_with_delete(&tmp);
13867        let _ = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13868            title: None,
13869            description: None,
13870            subject: None,
13871            schema_verbosity: None,
13872            write_guidance: Default::default(),
13873            name: "primary".to_string(),
13874            location: tmp.path().join("primary").to_string_lossy().into_owned(),
13875            schema: "default@1.0.0".to_string(),
13876            vcs: None,
13877            note: None,
13878            recovery: None,
13879            include_schema: false,
13880        }));
13881        let _ = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13882            title: None,
13883            description: None,
13884            subject: None,
13885            schema_verbosity: None,
13886            write_guidance: Default::default(),
13887            name: "plan-x".to_string(),
13888            location: tmp.path().join("plan-x").to_string_lossy().into_owned(),
13889            schema: "default@1.0.0".to_string(),
13890            vcs: None,
13891            note: None,
13892            recovery: None,
13893            include_schema: false,
13894        }));
13895        // Drop the seeded server; rebuild the engine with explicit
13896        // `cross_mem_links` so plan-x → primary is the effective
13897        // policy. Engine re-init reads the existing mem-repo state.
13898        drop(server);
13899
13900        let mut links: BTreeMap<String, memstead_schema::workspace_config::CrossLinkValue> =
13901            BTreeMap::new();
13902        links.insert(
13903            "plan-x".to_string(),
13904            memstead_schema::workspace_config::CrossLinkValue::List(vec!["primary".to_string()]),
13905        );
13906        let settings = WorkspaceSettings {
13907            mem_create_rules: vec![memstead_base::CreateRuleSetting {
13908                pattern: "*".to_string(),
13909                schemas: vec!["default@1.0.0".to_string()],
13910                default_cross_links: None,
13911            }],
13912            mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
13913                pattern: "*".to_string(),
13914            }],
13915            cross_mem_links: links,
13916            ..Default::default()
13917        };
13918        // Build an engine that mounts the runtime-created
13919        // git-branch mems with the cross_mem_links policy. Walk
13920        // `mem-repo/.git/refs/heads/` to enumerate mounts —
13921        // `setup_unified_test_engine_git_branch` looks for
13922        // disk-shape mem dirs which don't exist for pure
13923        // runtime-created git-branch mems.
13924        let gitdir = tmp.path().join("mem-repo").join(".git");
13925        let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir.clone());
13926        let mut mounts: Vec<(
13927            memstead_base::Mount,
13928            Box<dyn memstead_base::backend::MemBackend>,
13929        )> = Vec::new();
13930        for mem_name in ["primary", "plan-x"] {
13931            let mount = memstead_base::Mount {
13932                migration_target: None,
13933                mem: mem_name.to_string(),
13934                schema: Some("default@1.0.0".parse().unwrap()),
13935                storage: memstead_base::MountStorage::GitBranch {
13936                    gitdir: canonical_gitdir.clone(),
13937                    branch: format!("refs/heads/{mem_name}"),
13938                },
13939                capability: memstead_base::MountCapability::Write,
13940                lifecycle: memstead_base::MountLifecycle::Eager,
13941                cross_linkable: true,
13942            };
13943            let backend = memstead_git_branch::storage::instantiate_full_backend(&mount).unwrap();
13944            mounts.push((mount, backend));
13945        }
13946        let mut unified = memstead_base::Engine::from_mounts(mounts).unwrap();
13947        unified.set_backend_factory(memstead_git_branch::storage::instantiate_full_backend);
13948        unified.set_settings(settings.clone());
13949        let canonical_root =
13950            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
13951        unified.set_workspace_root(canonical_root);
13952        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
13953
13954        // The MCP surface is
13955        // always destructive (no `delete_files` parameter on the wire).
13956        // The policy safeguard fires whenever a `[cross_mem_links]`
13957        // grant points at the target — router-only unregister with
13958        // storage-preserved is reachable only via the CLI's
13959        // `memstead mem unregister` verb.
13960        let result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
13961            name: "primary".to_string(),
13962            note: None,
13963        }));
13964        assert_eq!(result.is_error, Some(true));
13965        let envelope = result
13966            .structured_content
13967            .expect("reference-block must carry envelope");
13968        assert_eq!(envelope["code"], "MEM_REFERENCED_BY_POLICY");
13969        let details = envelope
13970            .get("details")
13971            .expect("envelope must carry details");
13972        assert_eq!(details["name"], "primary");
13973        assert_eq!(
13974            details["referring_mems"],
13975            serde_json::json!(["plan-x"]),
13976            "details.referring_mems must name the blocking referrer"
13977        );
13978    }
13979
13980    /// `memstead_mem_delete delete_files=true` against a mem-db-backed
13981    /// (git-branch) mount runs the symmetric cleanup: the per-mem
13982    /// branch + `__MEMSTEAD:mems/<name>/config.json` are pruned, the
13983    /// response carries `files_deleted: true`, and no
13984    /// `MEM_FILES_NOT_DELETED` warning is emitted. Counterpart to
13985    /// the folder-mount test below — together they pin the agent-
13986    /// creatable-equals-agent-deletable contract for both backends.
13987    #[test]
13988    fn memstead_mem_delete_with_delete_files_true_on_mem_db_mount_prunes_branch_and_config() {
13989        let tmp = TempDir::new().unwrap();
13990        let server = setup_lifecycle_server_with_delete(&tmp);
13991        let gitdir = tmp.path().join("mem-repo").join(".git");
13992
13993        let _ = server.memstead_mem_create(Parameters(TlsMemCreateParams {
13994            title: None,
13995            description: None,
13996            subject: None,
13997            schema_verbosity: None,
13998            write_guidance: Default::default(),
13999            name: "ephemeral".to_string(),
14000            location: tmp.path().join("ephemeral").to_string_lossy().into_owned(),
14001            schema: "default@1.0.0".to_string(),
14002            vcs: None,
14003            note: None,
14004            recovery: None,
14005            include_schema: false,
14006        }));
14007        // Pre-condition: create wrote the branch + the __MEMSTEAD entry.
14008        let repo = gix::open(&gitdir).expect("mem-repo gitdir open");
14009        assert!(
14010            repo.try_find_reference("refs/heads/ephemeral")
14011                .unwrap()
14012                .is_some(),
14013            "create must seed the per-mem branch"
14014        );
14015        let memstead_tree = repo
14016            .try_find_reference("refs/heads/__MEMSTEAD")
14017            .unwrap()
14018            .expect("create must seed __MEMSTEAD")
14019            .into_fully_peeled_id()
14020            .unwrap()
14021            .object()
14022            .unwrap()
14023            .into_commit()
14024            .tree()
14025            .unwrap();
14026        assert!(
14027            memstead_tree
14028                .lookup_entry_by_path("mems/ephemeral/config.json")
14029                .unwrap()
14030                .is_some(),
14031            "create must seed __MEMSTEAD:mems/ephemeral/config.json"
14032        );
14033
14034        let result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
14035            name: "ephemeral".to_string(),
14036            note: None,
14037        }));
14038        assert!(
14039            result.is_error.is_none() || result.is_error == Some(false),
14040            "delete must succeed: {result:?}"
14041        );
14042        let sc = result
14043            .structured_content
14044            .as_ref()
14045            .expect("response must carry structured_content");
14046        assert_eq!(
14047            sc["files_deleted"],
14048            serde_json::Value::Bool(true),
14049            "mem-db delete_files=true now prunes the branch + __MEMSTEAD config — files_deleted must be true"
14050        );
14051        if let Some(warnings) = sc.get("warnings").and_then(|v| v.as_array()) {
14052            assert!(
14053                warnings
14054                    .iter()
14055                    .all(|w| w["code"] != "MEM_FILES_NOT_DELETED"),
14056                "no MEM_FILES_NOT_DELETED warning when cleanup succeeded: {warnings:?}"
14057            );
14058        }
14059        // Post-condition: branch gone, __MEMSTEAD entry gone.
14060        let repo = gix::open(&gitdir).expect("mem-repo gitdir reopen");
14061        assert!(
14062            repo.try_find_reference("refs/heads/ephemeral")
14063                .unwrap()
14064                .is_none(),
14065            "delete must drop refs/heads/ephemeral"
14066        );
14067        let memstead_tree = repo
14068            .try_find_reference("refs/heads/__MEMSTEAD")
14069            .unwrap()
14070            .expect("__MEMSTEAD survives the per-mem prune")
14071            .into_fully_peeled_id()
14072            .unwrap()
14073            .object()
14074            .unwrap()
14075            .into_commit()
14076            .tree()
14077            .unwrap();
14078        assert!(
14079            memstead_tree
14080                .lookup_entry_by_path("mems/ephemeral/config.json")
14081                .unwrap()
14082                .is_none(),
14083            "delete must prune __MEMSTEAD:mems/ephemeral/config.json"
14084        );
14085    }
14086
14087    /// Folder-backend happy path: `delete_files=true` on a mem whose
14088    /// directory exists removes the directory and reports
14089    /// `files_deleted: true` with no `MEM_FILES_NOT_DELETED` warning.
14090    /// Companion to the mem-db-backed test above — together they
14091    /// pin the two halves of the documented contract.
14092    #[test]
14093    fn memstead_mem_delete_with_delete_files_true_on_folder_mount_removes_dir() {
14094        let tmp = TempDir::new().unwrap();
14095        // Build a lean-flavour engine: folder backend only, no
14096        // mem-repo seeded. The create orchestrator's heuristic then
14097        // picks `MountStorage::Folder { path }` so `dir_for_mem`
14098        // returns Some on the registered mem.
14099        let mut unified = memstead_base::Engine::from_mounts(Vec::new()).unwrap();
14100        unified.set_settings(WorkspaceSettings {
14101            mem_create_rules: vec![memstead_base::CreateRuleSetting {
14102                pattern: "*".to_string(),
14103                schemas: vec!["default@1.0.0".to_string()],
14104                default_cross_links: None,
14105            }],
14106            mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
14107                pattern: "*".to_string(),
14108            }],
14109            ..Default::default()
14110        });
14111        let canonical_root =
14112            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
14113        unified.set_workspace_root(canonical_root);
14114        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
14115
14116        let mem_dir = tmp.path().join("scratch");
14117        let _ = server.memstead_mem_create(Parameters(TlsMemCreateParams {
14118            title: None,
14119            description: None,
14120            subject: None,
14121            schema_verbosity: None,
14122            write_guidance: Default::default(),
14123            name: "scratch".to_string(),
14124            location: mem_dir.to_string_lossy().into_owned(),
14125            schema: "default@1.0.0".to_string(),
14126            vcs: None,
14127            note: None,
14128            recovery: None,
14129            include_schema: false,
14130        }));
14131        assert!(mem_dir.is_dir(), "create must produce the mem dir on disk");
14132
14133        let result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
14134            name: "scratch".to_string(),
14135            note: None,
14136        }));
14137        assert!(
14138            result.is_error.is_none() || result.is_error == Some(false),
14139            "delete must succeed: {result:?}"
14140        );
14141        let sc = result
14142            .structured_content
14143            .as_ref()
14144            .expect("response must carry structured_content");
14145        assert_eq!(
14146            sc["files_deleted"],
14147            serde_json::Value::Bool(true),
14148            "rmdir on a removable directory must report files_deleted: true"
14149        );
14150        // The directory must be physically gone.
14151        assert!(!mem_dir.exists(), "the on-disk directory must be removed");
14152        // No MEM_FILES_NOT_DELETED warning — the cleanup ran cleanly.
14153        if let Some(warnings) = sc.get("warnings").and_then(|v| v.as_array()) {
14154            assert!(
14155                warnings
14156                    .iter()
14157                    .all(|w| w["code"] != "MEM_FILES_NOT_DELETED"),
14158                "no MEM_FILES_NOT_DELETED warning when rmdir succeeded: {warnings:?}"
14159            );
14160        }
14161    }
14162
14163    /// Hierarchical mem-db round-trip:
14164    /// `memstead_mem_create name=plan-q4 path=planning schema=…` followed by
14165    /// `memstead_mem_delete name=plan-q4 delete_files=true` leaves zero
14166    /// trace of the hierarchical branch + `__MEMSTEAD` config. The branch
14167    /// ref under `refs/heads/planning/plan-q4` and the tree path
14168    /// `mems/planning/plan-q4/config.json` must both be gone after
14169    /// the delete. Pairs with the flat case (`exec-*` / `ephemeral`)
14170    /// above — together they pin the path-composition contract on
14171    /// both halves of the lifecycle.
14172    #[test]
14173    fn memstead_mem_delete_hierarchical_path_prunes_branch_and_config() {
14174        let tmp = TempDir::new().unwrap();
14175        let full_settings = memstead_git_branch::test_support::auto_seed_with_settings(
14176            tmp.path(),
14177            WorkspaceSettings {
14178                mem_create_rules: vec![memstead_base::CreateRuleSetting {
14179                    pattern: "planning/plan-*".to_string(),
14180                    schemas: vec!["default@1.0.0".to_string()],
14181                    default_cross_links: None,
14182                }],
14183                mem_delete_rules: vec![memstead_base::DeleteRuleSetting {
14184                    pattern: "planning/plan-*".to_string(),
14185                }],
14186                ..Default::default()
14187            },
14188        );
14189        let mut unified = setup_unified_test_engine_git_branch(tmp.path());
14190        unified.set_settings(full_settings);
14191        let canonical_root =
14192            std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
14193        unified.set_workspace_root(canonical_root);
14194        let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
14195        let gitdir = tmp.path().join("mem-repo").join(".git");
14196
14197        // Create the hierarchical mem. The full
14198        // `planning/plan-q4` name is the canonical input — no
14199        // separate `path` field.
14200        let create_result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
14201            title: None,
14202            description: None,
14203            subject: None,
14204            schema_verbosity: None,
14205            write_guidance: Default::default(),
14206            name: "planning/plan-q4".to_string(),
14207            location: tmp
14208                .path()
14209                .join("planning")
14210                .join("plan-q4")
14211                .to_string_lossy()
14212                .into_owned(),
14213            schema: "default@1.0.0".to_string(),
14214            vcs: None,
14215            note: None,
14216            recovery: None,
14217            include_schema: false,
14218        }));
14219        assert!(
14220            create_result.is_error.is_none() || create_result.is_error == Some(false),
14221            "hierarchical create must succeed: {create_result:?}"
14222        );
14223
14224        // Pre-condition: hierarchical branch + config exist.
14225        let repo = gix::open(&gitdir).expect("mem-repo open");
14226        assert!(
14227            repo.try_find_reference("refs/heads/planning/plan-q4")
14228                .unwrap()
14229                .is_some(),
14230            "create must seed refs/heads/planning/plan-q4"
14231        );
14232        let memstead_tree = repo
14233            .try_find_reference("refs/heads/__MEMSTEAD")
14234            .unwrap()
14235            .expect("__MEMSTEAD exists after create")
14236            .into_fully_peeled_id()
14237            .unwrap()
14238            .object()
14239            .unwrap()
14240            .into_commit()
14241            .tree()
14242            .unwrap();
14243        assert!(
14244            memstead_tree
14245                .lookup_entry_by_path("mems/planning/plan-q4/config.json")
14246                .unwrap()
14247                .is_some(),
14248            "create must seed __MEMSTEAD:mems/planning/plan-q4/config.json"
14249        );
14250
14251        // Symmetric cleanup — MCP delete is always destructive, and
14252        // the mem name IS the full hierarchical path.
14253        let delete_result = server.memstead_mem_delete(Parameters(TlsMemDeleteParams {
14254            name: "planning/plan-q4".to_string(),
14255            note: None,
14256        }));
14257        assert!(
14258            delete_result.is_error.is_none() || delete_result.is_error == Some(false),
14259            "hierarchical delete must succeed: {delete_result:?}"
14260        );
14261        let sc = delete_result
14262            .structured_content
14263            .as_ref()
14264            .expect("response carries structured_content");
14265        assert_eq!(
14266            sc["files_deleted"],
14267            serde_json::Value::Bool(true),
14268            "hierarchical mem-db delete_files=true must report files_deleted: true"
14269        );
14270
14271        // Post-condition: both branch + config gone.
14272        let repo = gix::open(&gitdir).expect("mem-repo reopen");
14273        assert!(
14274            repo.try_find_reference("refs/heads/planning/plan-q4")
14275                .unwrap()
14276                .is_none(),
14277            "delete must drop refs/heads/planning/plan-q4"
14278        );
14279        let memstead_tree = repo
14280            .try_find_reference("refs/heads/__MEMSTEAD")
14281            .unwrap()
14282            .expect("__MEMSTEAD survives")
14283            .into_fully_peeled_id()
14284            .unwrap()
14285            .object()
14286            .unwrap()
14287            .into_commit()
14288            .tree()
14289            .unwrap();
14290        assert!(
14291            memstead_tree
14292                .lookup_entry_by_path("mems/planning/plan-q4/config.json")
14293                .unwrap()
14294                .is_none(),
14295            "delete must prune __MEMSTEAD:mems/planning/plan-q4/config.json"
14296        );
14297        // The empty `planning/` ancestor directory is gix-pruned on the
14298        // tree write — assert it's gone too.
14299        assert!(
14300            memstead_tree
14301                .lookup_entry_by_path("mems/planning")
14302                .unwrap()
14303                .is_none(),
14304            "delete must collapse the empty `mems/planning/` ancestor"
14305        );
14306    }
14307
14308    // ======================================================================
14309    // MCP tool filter — [mcp].disabled_tools end-to-end
14310    // ======================================================================
14311
14312    mod disabled_tools {
14313        use super::super::*;
14314        use super::{setup_dual_test_engine, setup_unified_test_engine};
14315        use std::collections::HashSet;
14316
14317        /// Build an `McpServer` with the same engine as `setup_test_engine`
14318        /// plus an explicit disabled-tool set. Keeps the mem fixture
14319        /// identical so baseline assertions carry over.
14320        fn setup_filtered_server(disabled: &[&str]) -> (McpServer, tempfile::TempDir) {
14321            // Reuse the existing engine factory, then rewrap with the
14322            // filter. `setup_test_engine` returns an `McpServer` — we
14323            // throw it away and construct a fresh one sharing the same
14324            // tmp fixture so the filter lands on the same graph state.
14325            let tmp = tempfile::TempDir::new().unwrap();
14326            let mem_dir = tmp.path().join("specs");
14327            std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
14328            std::fs::write(
14329                mem_dir.join(".memstead/config.json"),
14330                r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
14331            )
14332            .unwrap();
14333            let _ = mem_dir;
14334            memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
14335            let set: HashSet<String> = disabled.iter().map(|s| s.to_string()).collect();
14336            let server = McpServer::new_with_filter(
14337                setup_unified_test_engine(tmp.path()),
14338                crate::config::DEFAULT_TOKEN_BUDGET,
14339                set,
14340                Some(PathBuf::from("/tmp/test.memstead.toml")),
14341            );
14342            (server, tmp)
14343        }
14344
14345        #[test]
14346        fn empty_filter_lists_every_compiled_tool() {
14347            // Invariant: absent or empty filter is byte-identical to the
14348            // pre-filter surface.
14349            let (server, _tmp) = setup_dual_test_engine();
14350            let filtered: Vec<String> = server
14351                .filtered_tool_list()
14352                .iter()
14353                .map(|t| t.name.to_string())
14354                .collect();
14355            let router_all: Vec<String> = McpServer::tool_router()
14356                .list_all()
14357                .iter()
14358                .map(|t| t.name.to_string())
14359                .collect();
14360            assert_eq!(filtered, router_all);
14361        }
14362
14363        #[test]
14364        fn filter_omits_disabled_names_from_list() {
14365            let (server, _tmp) =
14366                setup_filtered_server(&["memstead_mem_create", "memstead_mem_delete"]);
14367            let names: Vec<String> = server
14368                .filtered_tool_list()
14369                .iter()
14370                .map(|t| t.name.to_string())
14371                .collect();
14372            assert!(!names.iter().any(|n| n == "memstead_mem_create"));
14373            assert!(!names.iter().any(|n| n == "memstead_mem_delete"));
14374            // Every other tool still present — verify by cardinality +
14375            // presence of a well-known read tool.
14376            assert!(names.iter().any(|n| n == "memstead_entity"));
14377            assert!(names.iter().any(|n| n == "memstead_overview"));
14378            let router_all: usize = McpServer::tool_router().list_all().len();
14379            assert_eq!(names.len(), router_all - 2);
14380        }
14381
14382        #[test]
14383        fn get_tool_returns_none_for_disabled_name() {
14384            use rmcp::ServerHandler;
14385            let (server, _tmp) = setup_filtered_server(&["memstead_mem_create"]);
14386            assert!(server.get_tool("memstead_mem_create").is_none());
14387            // Non-disabled name still resolves.
14388            assert!(server.get_tool("memstead_entity").is_some());
14389        }
14390
14391        #[test]
14392        fn tool_disabled_envelope_carries_code_and_details() {
14393            let (server, _tmp) = setup_filtered_server(&["memstead_mem_create"]);
14394            let result = server.tool_disabled_response("memstead_mem_create");
14395            assert_eq!(result.is_error, Some(true));
14396            let env = result
14397                .structured_content
14398                .expect("tool_disabled must carry structured_content");
14399            assert_eq!(env["code"], "TOOL_DISABLED");
14400            let details = env.get("details").expect("details present");
14401            assert_eq!(details["tool"], "memstead_mem_create");
14402            assert_eq!(
14403                details["config_source"], "/tmp/test.memstead.toml",
14404                "config_source must echo the resolved path"
14405            );
14406        }
14407
14408        #[test]
14409        fn tool_disabled_envelope_omits_config_source_when_none() {
14410            // Construct without a config_source (tests / no file-backed
14411            // config). The envelope drops the field entirely so agents
14412            // don't decode a stub path.
14413            let tmp = tempfile::TempDir::new().unwrap();
14414            let mem_dir = tmp.path().join("specs");
14415            std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
14416            std::fs::write(
14417                mem_dir.join(".memstead/config.json"),
14418                r#"{"version":"0.1.0","schema":"default@1.0.0","mediums":{},"projections":{}}"#,
14419            )
14420            .unwrap();
14421            let _ = mem_dir;
14422            memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
14423            let mut set = HashSet::new();
14424            set.insert("memstead_entity".to_string());
14425            let server = McpServer::new_with_filter(
14426                memstead_base::Engine::from_mounts(vec![]).unwrap(),
14427                crate::config::DEFAULT_TOKEN_BUDGET,
14428                set,
14429                None,
14430            );
14431            let result = server.tool_disabled_response("memstead_entity");
14432            let env = result.structured_content.unwrap();
14433            let details = env.get("details").unwrap();
14434            assert_eq!(details["tool"], "memstead_entity");
14435            assert!(
14436                details.get("config_source").is_none(),
14437                "config_source must be absent when no file-backed config sourced the filter"
14438            );
14439        }
14440
14441        #[test]
14442        fn chat_agent_scenario_hides_mem_lifecycle_pair() {
14443            // Mirrors the macOS chat-agent's intended config: the app's
14444            // `WorkspaceService` owns mem lifecycle, so the in-process
14445            // chat agent never sees `memstead_mem_create` /
14446            // `memstead_mem_delete` on its MCP surface.
14447            use rmcp::ServerHandler;
14448            let (server, _tmp) =
14449                setup_filtered_server(&["memstead_mem_create", "memstead_mem_delete"]);
14450            // List omits both.
14451            let names: Vec<String> = server
14452                .filtered_tool_list()
14453                .iter()
14454                .map(|t| t.name.to_string())
14455                .collect();
14456            assert!(!names.iter().any(|n| n == "memstead_mem_create"));
14457            assert!(!names.iter().any(|n| n == "memstead_mem_delete"));
14458            // get_tool rejects.
14459            assert!(server.get_tool("memstead_mem_create").is_none());
14460            assert!(server.get_tool("memstead_mem_delete").is_none());
14461            // Direct response envelope for a filtered call.
14462            let resp = server.tool_disabled_response("memstead_mem_create");
14463            assert_eq!(resp.is_error, Some(true));
14464            // Non-filtered tools still reachable.
14465            assert!(server.get_tool("memstead_entity").is_some());
14466            assert!(server.get_tool("memstead_search").is_some());
14467            assert!(server.is_tool_disabled("memstead_mem_create"));
14468            assert!(!server.is_tool_disabled("memstead_entity"));
14469        }
14470
14471        #[test]
14472        fn is_tool_disabled_matches_exactly_no_glob() {
14473            // Plan invariant: exact string equality, no glob / prefix
14474            // semantics. A partial match must not trigger the filter.
14475            let (server, _tmp) = setup_filtered_server(&["memstead_mem_create"]);
14476            assert!(server.is_tool_disabled("memstead_mem_create"));
14477            assert!(!server.is_tool_disabled("memstead_mem_create_extra"));
14478            assert!(!server.is_tool_disabled("memstead_mem"));
14479            assert!(!server.is_tool_disabled("memstead_"));
14480        }
14481    }
14482
14483    // --------------------------------------------------------------
14484    // `note` field on mutation tools + `[mutations].require_notes`
14485    // WarningHint pipeline — commit-body layout and require-notes
14486    // policy are the load-bearing assertions.
14487    // --------------------------------------------------------------
14488
14489    mod mutation_note {
14490        use super::*;
14491        use crate::tools::mutation::{CreateParams, UpdateParams};
14492
14493        /// Open the per-mem gitdir and return the `HEAD` commit's
14494        /// raw message. Shells out to `git log -1 --format=%B` so we
14495        /// don't pull `gix` into the `memstead-mcp` dev-dependency surface
14496        /// just for one test — the production crate already depends
14497        /// on `gix` transitively via `memstead-git-branch`, but MCP-level tests
14498        /// shouldn't take that direct dep. Uses the engine's own
14499        /// `gitdir_for` resolver so the test tracks whatever layout
14500        /// the default resolver produced (isolated `.git/` under the
14501        /// mem root today).
14502        fn head_commit_message(server: &McpServer, mem: &str) -> String {
14503            let gitdir = {
14504                let unified = server.unified_engine().clone();
14505                let engine = unified.lock().unwrap();
14506                engine
14507                    .gitdir_for(mem)
14508                    .expect("gitdir resolves for fixture mem")
14509            };
14510            // Mem-repo-backed mems commit to `refs/heads/<mem>`;
14511            // the shared `mem-repo/.git/`'s HEAD points at `main`
14512            // (workspace configs branch), so a plain `git log -1` would
14513            // read the seed commit instead of the entity write. Try the
14514            // per-mem branch first; fall back to HEAD for legacy
14515            // disk-backed mems whose only branch is HEAD.
14516            let per_mem_ref = format!("refs/heads/{mem}");
14517            let try_log = |rev: &str| {
14518                std::process::Command::new("git")
14519                    .arg("--git-dir")
14520                    .arg(&gitdir)
14521                    .arg("log")
14522                    .arg("-1")
14523                    .arg("--format=%B")
14524                    .arg(rev)
14525                    .output()
14526                    .expect("git log invocation succeeds")
14527            };
14528            let output = {
14529                let first = try_log(&per_mem_ref);
14530                if first.status.success() {
14531                    first
14532                } else {
14533                    try_log("HEAD")
14534                }
14535            };
14536            assert!(
14537                output.status.success(),
14538                "git log failed: {}",
14539                String::from_utf8_lossy(&output.stderr)
14540            );
14541            String::from_utf8(output.stdout).expect("commit message is utf-8")
14542        }
14543
14544        /// Build a server whose workspace config matches the one
14545        /// `[mutations].require_notes = require` produces after
14546        /// `config::resolve`. Fixture engine is shared with the
14547        /// module-level helper; only the constructor changes.
14548        fn server_with_require_notes(require: bool) -> (McpServer, TempDir) {
14549            let (_default_server, tmp) = setup_dual_test_engine();
14550            // Re-read the fixture mem under a fresh Engine because
14551            // `setup_test_engine` already consumed it; the simpler
14552            // path is to rebuild the engine from the same on-disk
14553            // state, which is canonical because `Engine::init` is
14554            // deterministic over the fixture directory.
14555            let _mem_dir = tmp.path().join("specs");
14556            memstead_git_branch::test_support::auto_seeded_settings(tmp.path());
14557            let mutations = crate::config::MutationsSection {
14558                require_notes: Some(require),
14559            };
14560            // Build a git-branch engine so `memstead_create` /
14561            // `memstead_update` write to mem-repo and
14562            // `head_commit_message` reads from the same gitdir.
14563            let mut unified = setup_unified_test_engine_git_branch(tmp.path());
14564            let canonical_root =
14565                std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
14566            unified.set_workspace_root(canonical_root);
14567            let server = McpServer::new_with_config(
14568                unified,
14569                crate::config::DEFAULT_TOKEN_BUDGET,
14570                HashSet::new(),
14571                None,
14572                mutations,
14573                HashMap::new(),
14574            );
14575            (server, tmp)
14576        }
14577
14578        /// Happy path: `memstead_create` with a `note` field threads the
14579        /// sentence into the commit body between the subject and the
14580        /// provenance trailers. The response also carries no
14581        /// `NOTE_MISSING` warning because the caller supplied one.
14582        #[test]
14583        fn memstead_create_with_note_lands_in_commit_body() {
14584            let tmp = setup_test_workspace();
14585            let mut unified = setup_unified_test_engine_git_branch(tmp.path());
14586            let canonical_root =
14587                std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
14588            unified.set_workspace_root(canonical_root);
14589            let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
14590            let mut sections = IndexMap::new();
14591            sections.insert("identity".into(), "c".into());
14592            sections.insert("purpose".into(), "d".into());
14593            let result = server.memstead_create(Parameters(CreateParams {
14594                anchors: None,
14595                title: "Foo Invariant".into(),
14596                entity_type: "spec".into(),
14597                mem: None,
14598                sections: Some(sections),
14599                metadata: None,
14600                relations: None,
14601                dry_run: Some(false),
14602                note: Some("documenting the foo invariant".into()),
14603                role: None,
14604            }));
14605            assert!(
14606                result.is_error.is_none() || result.is_error == Some(false),
14607                "memstead_create must succeed: {}",
14608                extract_text(&result)
14609            );
14610            // Confirm the commit body carries the note between the
14611            // subject and the trailer block.
14612            let msg = head_commit_message(&server, "specs");
14613            assert!(
14614                msg.contains("\n\ndocumenting the foo invariant\n\n"),
14615                "commit message must contain the note between subject \
14616                 and trailers; got: {msg:?}"
14617            );
14618            // And the trailer block still carries the `Actor:` line
14619            // after the note paragraph.
14620            assert!(
14621                msg.contains("Actor: agent"),
14622                "provenance trailer block must survive the note insert; \
14623                 got: {msg:?}"
14624            );
14625            // No warning added when the note is supplied (default
14626            // posture: require_notes = false / None).
14627            let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
14628            let empty_vec: Vec<serde_json::Value> = vec![];
14629            let warnings = json["warnings"].as_array().unwrap_or(&empty_vec);
14630            assert!(
14631                !warnings
14632                    .iter()
14633                    .any(|w| w["code"].as_str() == Some("NOTE_MISSING")),
14634                "NOTE_MISSING must not appear when note is supplied: {warnings:?}"
14635            );
14636        }
14637
14638        /// `[mutations].require_notes = true` + missing note: the
14639        /// mutation still commits, and the response carries a
14640        /// `NOTE_MISSING` WarningHint envelope so autonomous skills
14641        /// can audit their coverage. Verifies both the wire shape
14642        /// (warnings array contains the envelope) and the commit-body
14643        /// shape (no extra body paragraph — a missing note collapses
14644        /// to the no-note layout).
14645        #[test]
14646        fn require_notes_without_note_adds_warning_but_commit_still_lands() {
14647            let (server, _tmp) = server_with_require_notes(true);
14648            let result = server.memstead_update(Parameters(UpdateParams {
14649                anchors: None,
14650                relations_unset: None,
14651                anchors_unset: None,
14652                id: "specs--entity-a".into(),
14653                expected_hash: {
14654                    // Read the current hash so we don't have to hard-code it.
14655                    let entity = server.memstead_entity(Parameters(EntityParams {
14656                        id: "specs--entity-a".into(),
14657                        include_relations: None,
14658                        include_context: None,
14659                        sections: None,
14660                        token_budget: None,
14661                        chunk: None,
14662                        include_provenance: None,
14663                    }));
14664                    let text = extract_text(&entity);
14665                    // Markdown rendering includes the frontmatter
14666                    // line `_hash: <hex>`; extract it.
14667                    text.lines()
14668                        .find(|l| l.starts_with("_hash: "))
14669                        .unwrap()
14670                        .trim_start_matches("_hash: ")
14671                        .to_string()
14672                },
14673                sections: None,
14674                append_sections: Some({
14675                    let mut m = IndexMap::new();
14676                    m.insert("purpose".into(), "extra".into());
14677                    m
14678                }),
14679                patch_sections: None,
14680                metadata: None,
14681                metadata_unset: None,
14682                dry_run: Some(false),
14683                note: None,
14684                role: None,
14685                declare_relations: None,
14686            }));
14687            assert!(
14688                result.is_error.is_none() || result.is_error == Some(false),
14689                "memstead_update must succeed under require_notes: {}",
14690                extract_text(&result)
14691            );
14692            // Warning on the wire.
14693            let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
14694            let warnings = json["warnings"].as_array().expect("warnings array");
14695            // The engine is the single enforcement point; the warning's
14696            // `tool` is the engine-level verb (`update_entity`), matching
14697            // the commit `Tool:` provenance trailer — not the MCP tool
14698            // name. The CLI surface inherits the same value.
14699            let has_note_missing = warnings.iter().any(|w| {
14700                w["code"].as_str() == Some("NOTE_MISSING")
14701                    && w["details"]["tool"].as_str() == Some("update_entity")
14702            });
14703            assert!(
14704                has_note_missing,
14705                "require_notes + missing note must append NOTE_MISSING \
14706                 warning; got: {warnings:?}"
14707            );
14708            // Commit still lands — body carries no extra paragraph
14709            // since the note was absent.
14710            let msg = head_commit_message(&server, "specs");
14711            assert!(
14712                msg.starts_with("memstead: update specs--entity-a"),
14713                "commit subject preserved: {msg:?}"
14714            );
14715            assert!(
14716                msg.contains("Actor: agent"),
14717                "provenance trailer survives the warning path: {msg:?}"
14718            );
14719        }
14720
14721        /// `require_notes = true` + blank / whitespace-only note: the
14722        /// note collapses to "absent" semantics, so the pipeline
14723        /// still emits `NOTE_MISSING`. Guards against an agent who
14724        /// satisfies the type signature without actually documenting
14725        /// the change.
14726        #[test]
14727        fn require_notes_with_blank_note_still_warns() {
14728            let (server, _tmp) = server_with_require_notes(true);
14729            let mut sections = IndexMap::new();
14730            sections.insert("identity".into(), "c".into());
14731            sections.insert("purpose".into(), "d".into());
14732            let result = server.memstead_create(Parameters(CreateParams {
14733                anchors: None,
14734                title: "Blank Note Path".into(),
14735                entity_type: "spec".into(),
14736                mem: None,
14737                sections: Some(sections),
14738                metadata: None,
14739                relations: None,
14740                dry_run: Some(false),
14741                note: Some("   \t ".into()),
14742                role: None,
14743            }));
14744            let json: serde_json::Value = serde_json::from_str(&extract_text(&result)).unwrap();
14745            let warnings = json["warnings"].as_array().expect("warnings array");
14746            assert!(
14747                warnings
14748                    .iter()
14749                    .any(|w| w["code"].as_str() == Some("NOTE_MISSING")),
14750                "blank note must still trigger NOTE_MISSING: {warnings:?}"
14751            );
14752        }
14753
14754        /// An over-cap note (> `NOTE_MAX_LEN` chars) is a hard
14755        /// `INVALID_INPUT` rejection before the engine is touched.
14756        /// No commit is produced; the agent sees the typed envelope.
14757        #[test]
14758        fn oversized_note_returns_invalid_input() {
14759            let (server, _tmp) = setup_dual_test_engine();
14760            let mut sections = IndexMap::new();
14761            sections.insert("identity".into(), "c".into());
14762            sections.insert("purpose".into(), "d".into());
14763            let oversized: String = "x".repeat(memstead_engine::mem_management::NOTE_MAX_LEN + 1);
14764            let result = server.memstead_create(Parameters(CreateParams {
14765                anchors: None,
14766                title: "Oversized Note".into(),
14767                entity_type: "spec".into(),
14768                mem: None,
14769                sections: Some(sections),
14770                metadata: None,
14771                relations: None,
14772                dry_run: Some(false),
14773                note: Some(oversized),
14774                role: None,
14775            }));
14776            assert_eq!(result.is_error, Some(true));
14777            let payload = result
14778                .structured_content
14779                .as_ref()
14780                .expect("structured envelope present");
14781            assert_eq!(
14782                payload["code"].as_str(),
14783                Some("INVALID_INPUT"),
14784                "expected INVALID_INPUT envelope, got: {payload:?}"
14785            );
14786        }
14787    }
14788
14789    /// Every single-mem response carries
14790    /// `_mem_schema: <name>@<version>` so agents read
14791    /// the canonical schema pin in the same response they're already
14792    /// looking at. Multi-mem responses and pre-resolve errors carry no
14793    /// anchor (no single mem to point at).
14794    mod schema_anchor {
14795        use super::*;
14796
14797        /// Pull the structured-content JSON object from a tool response.
14798        fn payload(result: &CallToolResult) -> serde_json::Value {
14799            result
14800                .structured_content
14801                .as_ref()
14802                .cloned()
14803                .expect("structured_content present")
14804        }
14805
14806        /// JSON-shaped responses anchor the canonical schema-ref at the
14807        /// top level so agents reading a response do not need a follow-up
14808        /// `memstead_overview` to know which schema is in play.
14809        #[test]
14810        fn memstead_create_response_carries_anchor() {
14811            let (server, _tmp) = setup_dual_test_engine();
14812            let result = server.memstead_create(Parameters(CreateParams {
14813                anchors: None,
14814                title: "Anchor Probe".to_string(),
14815                entity_type: "spec".to_string(),
14816                mem: Some("specs".to_string()),
14817                sections: Some(IndexMap::from_iter([
14818                    ("identity".to_string(), "Probe.".to_string()),
14819                    ("purpose".to_string(), "Probe.".to_string()),
14820                ])),
14821                metadata: None,
14822                relations: None,
14823                dry_run: Some(true),
14824                note: None,
14825                role: None,
14826            }));
14827            assert!(!result.is_error.unwrap_or(false));
14828            assert_eq!(
14829                payload(&result)["_mem_schema"].as_str(),
14830                Some("default@1.0.0"),
14831            );
14832        }
14833
14834        /// `memstead_update` derives the mem from the entity ID — the anchor
14835        /// must follow the same mem even though the params have no
14836        /// explicit `mem` field.
14837        #[test]
14838        fn memstead_update_response_carries_anchor() {
14839            let (server, _tmp) = setup_dual_test_engine();
14840            // Read first to grab the hash.
14841            let read = server.memstead_entity(Parameters(EntityParams {
14842                id: "specs--entity-a".to_string(),
14843                sections: None,
14844                include_relations: None,
14845                include_context: None,
14846                token_budget: None,
14847                chunk: None,
14848                include_provenance: None,
14849            }));
14850            let read_text = extract_text(&read);
14851            let hash_line = read_text
14852                .lines()
14853                .find(|l| l.starts_with("_hash:"))
14854                .expect("_hash line present");
14855            let expected_hash = hash_line.trim_start_matches("_hash:").trim();
14856
14857            // Empty payloads refuse with `EMPTY_UPDATE`. Pass same-content
14858            // section to keep this dry_run preview on the success
14859            // path that emits the anchor.
14860            let mut sections = indexmap::IndexMap::new();
14861            sections.insert("identity".to_string(), "First test entity.".to_string());
14862            let result = server.memstead_update(Parameters(UpdateParams {
14863                anchors: None,
14864                relations_unset: None,
14865                anchors_unset: None,
14866                id: "specs--entity-a".to_string(),
14867                expected_hash: expected_hash.to_string(),
14868                sections: Some(sections),
14869                append_sections: None,
14870                patch_sections: None,
14871                metadata: None,
14872                metadata_unset: None,
14873                dry_run: Some(true),
14874                note: None,
14875                role: None,
14876                declare_relations: None,
14877            }));
14878            assert!(
14879                !result.is_error.unwrap_or(false),
14880                "{}",
14881                extract_text(&result)
14882            );
14883            assert_eq!(
14884                payload(&result)["_mem_schema"].as_str(),
14885                Some("default@1.0.0"),
14886            );
14887        }
14888
14889        /// `memstead_update`'s new `declare_relations` parameter
14890        /// surfaces on the wire: the JSON body carries a
14891        /// `relations_declared: [...]` array per the additive
14892        /// response-shape contract. Verified end-to-end against the
14893        /// MCP server. The atomicity with the strict
14894        /// wiki-link/relation validator is covered at the engine
14895        /// layer (memstead-base) — this test pins the MCP plumbing.
14896        #[test]
14897        fn memstead_update_relations_declared_surfaces_on_response() {
14898            let (server, _tmp) = setup_dual_test_engine();
14899            // Read entity-a to grab the hash.
14900            let read = server.memstead_entity(Parameters(EntityParams {
14901                id: "specs--entity-a".to_string(),
14902                sections: None,
14903                include_relations: None,
14904                include_context: None,
14905                token_budget: None,
14906                chunk: None,
14907                include_provenance: None,
14908            }));
14909            let read_text = extract_text(&read);
14910            let hash_line = read_text
14911                .lines()
14912                .find(|l| l.starts_with("_hash:"))
14913                .expect("_hash line present");
14914            let expected_hash = hash_line.trim_start_matches("_hash:").trim();
14915
14916            let result = server.memstead_update(Parameters(UpdateParams {
14917                anchors: None,
14918                relations_unset: None,
14919                anchors_unset: None,
14920                id: "specs--entity-a".to_string(),
14921                expected_hash: expected_hash.to_string(),
14922                sections: None,
14923                append_sections: None,
14924                patch_sections: None,
14925                metadata: None,
14926                metadata_unset: None,
14927                dry_run: None,
14928                note: None,
14929                role: None,
14930                declare_relations: Some(vec![crate::tools::mutation::RelationInput {
14931                    to: "specs--entity-b".to_string(),
14932                    r#type: "USES".to_string(),
14933                    description: None,
14934                }]),
14935            }));
14936            assert!(
14937                !result.is_error.unwrap_or(false),
14938                "{}",
14939                extract_text(&result)
14940            );
14941            let body = payload(&result);
14942            let declared = body["relations_declared"]
14943                .as_array()
14944                .expect("relations_declared must be an array on the response");
14945            assert_eq!(declared.len(), 1, "expected one declared relation: {body}");
14946            assert_eq!(declared[0]["rel_type"].as_str(), Some("USES"));
14947            assert_eq!(declared[0]["target"].as_str(), Some("specs--entity-b"),);
14948            assert_eq!(
14949                declared[0]["target_was_stubbed"].as_bool(),
14950                Some(false),
14951                "target already existed; target_was_stubbed must be false"
14952            );
14953        }
14954
14955        /// `memstead_relate` resolves mem from the source entity. Even on a
14956        /// duplicate-relationship warning path the anchor must ship.
14957        #[test]
14958        fn memstead_relate_response_carries_anchor() {
14959            let (server, _tmp) = setup_dual_test_engine();
14960            let result = server.memstead_relate(Parameters(RelateParams {
14961                relations: vec![RelateOpInput {
14962                    from: "specs--entity-a".to_string(),
14963                    to: "specs--entity-b".to_string(),
14964                    r#type: "USES".to_string(),
14965                    remove: None,
14966                    description: None,
14967                }],
14968                note: None,
14969                role: None,
14970                dry_run: None,
14971            }));
14972            assert!(
14973                !result.is_error.unwrap_or(false),
14974                "{}",
14975                extract_text(&result)
14976            );
14977            assert_eq!(
14978                payload(&result)["_mem_schema"].as_str(),
14979                Some("default@1.0.0"),
14980            );
14981        }
14982
14983        /// Rehearsal marker form (agent-trust plan 07), single-op path:
14984        /// `dry_run: true` reports the would-be edge and would-be stub
14985        /// with an EMPTY `commit_sha`, creates nothing, and the
14986        /// follow-up real call succeeds with a non-empty one.
14987        #[test]
14988        fn memstead_relate_dry_run_single_carries_marker_and_creates_nothing() {
14989            let (server, _tmp) = setup_dual_test_engine();
14990            let call = |dry: Option<bool>| {
14991                server.memstead_relate(Parameters(RelateParams {
14992                    relations: vec![RelateOpInput {
14993                        from: "specs--entity-a".to_string(),
14994                        to: "specs--rehearsed-ghost".to_string(),
14995                        r#type: "USES".to_string(),
14996                        remove: None,
14997                        description: None,
14998                    }],
14999                    note: None,
15000                    role: None,
15001                    dry_run: dry,
15002                }))
15003            };
15004            let rehearsed = call(Some(true));
15005            assert!(
15006                !rehearsed.is_error.unwrap_or(false),
15007                "{}",
15008                extract_text(&rehearsed)
15009            );
15010            let body = payload(&rehearsed);
15011            assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
15012            assert_eq!(body["results"][0]["action"], "added");
15013            let warnings = serde_json::to_string(&body["warnings"]).unwrap();
15014            assert!(
15015                warnings.contains("AUTO_STUB_CREATED"),
15016                "would-be stub must be reported: {warnings}"
15017            );
15018            // The stub was never created.
15019            let read = server.memstead_entity(Parameters(EntityParams {
15020                id: "specs--rehearsed-ghost".to_string(),
15021                sections: None,
15022                include_relations: None,
15023                include_context: None,
15024                token_budget: None,
15025                chunk: None,
15026                include_provenance: None,
15027            }));
15028            assert!(
15029                read.is_error.unwrap_or(false),
15030                "rehearsed stub must not exist: {}",
15031                extract_text(&read)
15032            );
15033            // The real call on the unchanged mem lands.
15034            let real = call(None);
15035            assert!(!real.is_error.unwrap_or(false), "{}", extract_text(&real));
15036            let real_body = payload(&real);
15037            assert_ne!(real_body["commit_sha"], "", "the real relate commits");
15038            // No cross-call hash-equality assertion: the auto-stamped
15039            // `last_modified` (second-resolution wall clock) enters
15040            // `_hash`, so rehearsed vs real legitimately diverge when
15041            // a second ticks between the calls. The clock-pinned
15042            // engine test asserts equality deterministically.
15043        }
15044
15045        /// Rehearsal marker form, batch path: a multi-op `dry_run`
15046        /// list reports every would-be action with an empty
15047        /// `commit_sha`, reports (never creates) would-be stubs, and
15048        /// the follow-up real list succeeds.
15049        #[test]
15050        fn memstead_relate_dry_run_batch_carries_marker_and_creates_nothing() {
15051            let (server, _tmp) = setup_dual_test_engine();
15052            let ops = || {
15053                vec![
15054                    // Reverse direction of the fixture's existing a→b
15055                    // edge so this op is a genuine would-be add.
15056                    RelateOpInput {
15057                        from: "specs--entity-b".to_string(),
15058                        to: "specs--entity-a".to_string(),
15059                        r#type: "USES".to_string(),
15060                        remove: None,
15061                        description: None,
15062                    },
15063                    RelateOpInput {
15064                        from: "specs--entity-b".to_string(),
15065                        to: "specs--batch-ghost".to_string(),
15066                        r#type: "USES".to_string(),
15067                        remove: None,
15068                        description: None,
15069                    },
15070                ]
15071            };
15072            let rehearsed = server.memstead_relate(Parameters(RelateParams {
15073                relations: ops(),
15074                note: None,
15075                role: None,
15076                dry_run: Some(true),
15077            }));
15078            assert!(
15079                !rehearsed.is_error.unwrap_or(false),
15080                "{}",
15081                extract_text(&rehearsed)
15082            );
15083            let body = payload(&rehearsed);
15084            assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
15085            assert_eq!(body["results"][0]["action"], "added");
15086            assert_eq!(body["results"][1]["action"], "added");
15087            let warnings = serde_json::to_string(&body["warnings"]).unwrap();
15088            assert!(
15089                warnings.contains("AUTO_STUB_CREATED") && warnings.contains("specs--batch-ghost"),
15090                "would-be stub must be reported: {warnings}"
15091            );
15092            let read = server.memstead_entity(Parameters(EntityParams {
15093                id: "specs--batch-ghost".to_string(),
15094                sections: None,
15095                include_relations: None,
15096                include_context: None,
15097                token_budget: None,
15098                chunk: None,
15099                include_provenance: None,
15100            }));
15101            assert!(
15102                read.is_error.unwrap_or(false),
15103                "rehearsed stub must not exist"
15104            );
15105            // The real list on the unchanged mems lands.
15106            let real = server.memstead_relate(Parameters(RelateParams {
15107                relations: ops(),
15108                note: None,
15109                role: None,
15110                dry_run: None,
15111            }));
15112            assert!(!real.is_error.unwrap_or(false), "{}", extract_text(&real));
15113            assert_ne!(payload(&real)["commit_sha"], "", "the real batch commits");
15114        }
15115
15116        /// Markdown frontmatter on `memstead_entity` carries the anchor as the
15117        /// first line inside the `---` block. Position matters less than
15118        /// presence — agents parsing YAML frontmatter find it either way.
15119        #[test]
15120        fn memstead_entity_frontmatter_carries_anchor_for_real_entity() {
15121            let (server, _tmp) = setup_dual_test_engine();
15122            let result = server.memstead_entity(Parameters(EntityParams {
15123                id: "specs--entity-a".to_string(),
15124                sections: None,
15125                include_relations: None,
15126                include_context: None,
15127                token_budget: None,
15128                chunk: None,
15129                include_provenance: None,
15130            }));
15131            let text = extract_text(&result);
15132            assert!(
15133                text.contains("_mem_schema: default@1.0.0"),
15134                "anchor missing from entity frontmatter:\n{text}"
15135            );
15136        }
15137
15138        /// Stub reads carry the anchor too — the entity is a placeholder
15139        /// but the mem is real and known. Pinned so a future regression
15140        /// stripping anchors from "incomplete" entities is caught.
15141        #[test]
15142        fn memstead_entity_frontmatter_carries_anchor_for_stub() {
15143            let (server, _tmp) = setup_dual_test_engine();
15144            // Create a stub by relating to a non-existent target.
15145            let _ = server.memstead_relate(Parameters(RelateParams {
15146                relations: vec![RelateOpInput {
15147                    from: "specs--entity-a".to_string(),
15148                    to: "specs--stub-target".to_string(),
15149                    r#type: "USES".to_string(),
15150                    remove: None,
15151                    description: None,
15152                }],
15153                note: None,
15154                role: None,
15155                dry_run: None,
15156            }));
15157
15158            let result = server.memstead_entity(Parameters(EntityParams {
15159                id: "specs--stub-target".to_string(),
15160                sections: None,
15161                include_relations: None,
15162                include_context: None,
15163                token_budget: None,
15164                chunk: None,
15165                include_provenance: None,
15166            }));
15167            let text = extract_text(&result);
15168            assert!(
15169                text.contains("_mem_schema: default@1.0.0"),
15170                "stub frontmatter must still carry the anchor:\n{text}"
15171            );
15172        }
15173
15174        /// `memstead_overview` scoped to a single mem carries the anchor in
15175        /// its frontmatter.
15176        #[test]
15177        fn memstead_overview_single_mem_carries_anchor() {
15178            let (server, _tmp) = setup_dual_test_engine();
15179            let result = server.memstead_overview(Parameters(OverviewParams {
15180                mem: Some("specs".to_string()),
15181                rebuild: Some(true),
15182                chunk: None,
15183                token_budget: None,
15184                include: None,
15185            }));
15186            let text = extract_text(&result);
15187            assert!(
15188                text.contains("_mem_schema: default@1.0.0"),
15189                "single-mem overview must carry the anchor:\n{text}"
15190            );
15191        }
15192
15193        /// Multi-mem `memstead_overview` (no `mem` filter) skips the
15194        /// anchor — there's no single mem to point at.
15195        #[test]
15196        fn memstead_overview_multi_mem_omits_anchor() {
15197            let (server, _tmp) = setup_dual_test_engine();
15198            let result = server.memstead_overview(Parameters(OverviewParams {
15199                mem: None,
15200                rebuild: Some(true),
15201                chunk: None,
15202                token_budget: None,
15203                include: None,
15204            }));
15205            let text = extract_text(&result);
15206            assert!(
15207                !text.contains("_mem_schema:"),
15208                "multi-mem overview must NOT carry an anchor:\n{text}"
15209            );
15210        }
15211
15212        /// `memstead_health` inherits the same single-vs-multi rule: scoped
15213        /// to one mem → anchor; global → no anchor.
15214        #[test]
15215        fn memstead_health_single_mem_carries_anchor() {
15216            let (server, _tmp) = setup_dual_test_engine();
15217            let result = server.memstead_health(Parameters(HealthParams {
15218                include: None,
15219                limit: None,
15220                mem: Some("specs".to_string()),
15221                include_config: false,
15222                token_budget: None,
15223                chunk: None,
15224                target_schema: None,
15225            }));
15226            assert_eq!(
15227                payload(&result)["_mem_schema"].as_str(),
15228                Some("default@1.0.0"),
15229            );
15230        }
15231
15232        #[test]
15233        fn memstead_health_multi_mem_omits_anchor() {
15234            let (server, _tmp) = setup_dual_test_engine();
15235            let result = server.memstead_health(Parameters(HealthParams {
15236                include: None,
15237                limit: None,
15238                mem: None,
15239                include_config: false,
15240                token_budget: None,
15241                chunk: None,
15242                target_schema: None,
15243            }));
15244            assert!(
15245                payload(&result).get("_mem_schema").is_none(),
15246                "global health must not carry an anchor: {:?}",
15247                payload(&result)
15248            );
15249        }
15250
15251        /// Pre-resolve errors carry no anchor — the engine never resolved
15252        /// a mem to point at. Pinning this so a future "always inject
15253        /// even on errors" change does not silently anchor to the wrong
15254        /// mem.
15255        #[test]
15256        fn pre_resolve_unknown_mem_carries_no_anchor() {
15257            let (server, _tmp) = setup_dual_test_engine();
15258            let result = server.memstead_entity(Parameters(EntityParams {
15259                id: "ghost-mem--missing-entity".to_string(),
15260                sections: None,
15261                include_relations: None,
15262                include_context: None,
15263                token_budget: None,
15264                chunk: None,
15265                include_provenance: None,
15266            }));
15267            // The lookup fails (NotFound). The response shape varies
15268            // (sometimes a markdown body, sometimes an error envelope on
15269            // structured_content) but never carries an anchor — both
15270            // surfaces are checked.
15271            assert_eq!(result.is_error, Some(true));
15272            let text = extract_text(&result);
15273            assert!(
15274                !text.contains("_mem_schema:"),
15275                "pre-resolve error markdown must not anchor: {text}"
15276            );
15277            if let Some(sc) = result.structured_content.as_ref() {
15278                assert!(
15279                    sc.get("_mem_schema").is_none(),
15280                    "pre-resolve error envelope must not anchor: {sc:?}"
15281                );
15282            }
15283        }
15284    }
15285
15286    /// Agent-schema-priming contract — `memstead_mem_create` returns the
15287    /// full per-type catalogue under `schema`, byte-identical to what
15288    /// `memstead_schema(name=<resolved-schema>)` would ship for the same mem.
15289    /// Primes the agent that just created the mem with everything they
15290    /// need to write into it.
15291    mod schema_payload {
15292        use super::*;
15293
15294        /// Set up a permissive lifecycle server (sibling to
15295        /// `setup_lifecycle_server` further down — duplicated here so this
15296        /// module can run independently if the upstream helper moves).
15297        fn setup() -> (McpServer, TempDir) {
15298            let tmp = TempDir::new().unwrap();
15299            let settings = memstead_git_branch::test_support::auto_seed_with_settings(
15300                tmp.path(),
15301                WorkspaceSettings {
15302                    mem_create_rules: vec![memstead_base::CreateRuleSetting {
15303                        pattern: "*".to_string(),
15304                        schemas: vec!["default@1.0.0".to_string()],
15305                        default_cross_links: None,
15306                    }],
15307                    mem_delete_rules: vec![],
15308                    ..Default::default()
15309                },
15310            );
15311            let unified_settings = settings.clone();
15312            let _ = settings;
15313            // Install the create-rule policy on the unified engine
15314            // so memstead_mem_create_unified's gating sees it.
15315            // Canonicalise tmp.path() because TempDir returns a
15316            // symlink (e.g. /var/.../) on macOS while canonical
15317            // paths resolve to /private/var/...; the
15318            // `canonical.strip_prefix(workspace_root)` check needs
15319            // the two to match.
15320            let mut unified = setup_unified_test_engine(tmp.path());
15321            unified.set_settings(unified_settings);
15322            let canonical_root =
15323                std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
15324            unified.set_workspace_root(canonical_root);
15325            let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
15326            (server, tmp)
15327        }
15328
15329        /// Create a mem and return the response's structured payload.
15330        /// `include_schema:
15331        /// true` is required to surface the schema body — these tests
15332        /// exist *to verify* the inlined body, so the opt-in fires.
15333        fn create_and_get_payload(
15334            server: &McpServer,
15335            tmp: &TempDir,
15336            name: &str,
15337        ) -> serde_json::Value {
15338            let target = tmp.path().join(name);
15339            let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
15340                title: None,
15341                description: None,
15342                subject: None,
15343                // These payload tests pin the FULL catalogue content;
15344                // the omitted-param default is lite (tested separately).
15345                schema_verbosity: Some("full".to_string()),
15346                write_guidance: Default::default(),
15347                name: name.to_string(),
15348                location: target.to_string_lossy().into_owned(),
15349                schema: "default@1.0.0".to_string(),
15350
15351                vcs: None,
15352                note: Some("schema payload".to_string()),
15353                recovery: None,
15354                include_schema: true,
15355            }));
15356            assert!(
15357                !result.is_error.unwrap_or(false),
15358                "create must succeed: {result:?}"
15359            );
15360            result
15361                .structured_content
15362                .expect("structured_content present")
15363        }
15364
15365        /// Plan-05 criterion 2 (create half): curation params applied
15366        /// at creation are visible on the loaded config and the
15367        /// configure tool's stable response.
15368        #[test]
15369        fn mem_create_with_curation_carries_all_three() {
15370            let (server, tmp) = setup();
15371            let target = tmp.path().join("curated");
15372            let result = server.memstead_mem_create(Parameters(TlsMemCreateParams {
15373                title: Some("Curated Library".to_string()),
15374                description: Some("One-line card text.".to_string()),
15375                subject: Some(crate::lifecycle::MemSubjectInput {
15376                    scope: "Everything about curation.".to_string(),
15377                    method: Some("Hand-written fixtures.".to_string()),
15378                    exclusions: Some(vec!["History.".to_string()]),
15379                }),
15380                schema_verbosity: None,
15381                write_guidance: Default::default(),
15382                name: "curated".to_string(),
15383                location: target.to_string_lossy().into_owned(),
15384                schema: "default@1.0.0".to_string(),
15385                vcs: None,
15386                note: Some("curation at create".to_string()),
15387                recovery: None,
15388                include_schema: false,
15389            }));
15390            assert!(
15391                !result.is_error.unwrap_or(false),
15392                "create must succeed: {result:?}"
15393            );
15394
15395            // The stable read-back: a no-field configure call returns
15396            // the post-create state.
15397            let state =
15398                server.memstead_mem_configure(Parameters(crate::lifecycle::MemConfigureParams {
15399                    name: "curated".to_string(),
15400                    title: None,
15401                    description: None,
15402                    subject: None,
15403                    clear_subject: false,
15404                    note: None,
15405                }));
15406            assert!(!state.is_error.unwrap_or(false), "{state:?}");
15407            let body = state.structured_content.expect("structured body");
15408            assert_eq!(body["title"], "Curated Library");
15409            assert_eq!(body["description"], "One-line card text.");
15410            assert_eq!(body["subject"]["scope"], "Everything about curation.");
15411            assert_eq!(body["subject"]["method"], "Hand-written fixtures.");
15412            assert_eq!(body["subject"]["exclusions"][0], "History.");
15413        }
15414
15415        /// Plan-05 criterion 2 (configure half): set, overwrite, and
15416        /// clear each field on an existing mem; unknown mem refuses.
15417        #[test]
15418        fn mem_configure_sets_overwrites_and_clears() {
15419            let (server, tmp) = setup();
15420            let target = tmp.path().join("plain");
15421            let create = server.memstead_mem_create(Parameters(TlsMemCreateParams {
15422                title: None,
15423                description: None,
15424                subject: None,
15425                schema_verbosity: None,
15426                write_guidance: Default::default(),
15427                name: "plain".to_string(),
15428                location: target.to_string_lossy().into_owned(),
15429                schema: "default@1.0.0".to_string(),
15430                vcs: None,
15431                note: Some("plain create".to_string()),
15432                recovery: None,
15433                include_schema: false,
15434            }));
15435            assert!(!create.is_error.unwrap_or(false), "{create:?}");
15436
15437            let configure = |title: Option<&str>,
15438                             description: Option<&str>,
15439                             subject: Option<crate::lifecycle::MemSubjectInput>,
15440                             clear_subject: bool| {
15441                server.memstead_mem_configure(Parameters(crate::lifecycle::MemConfigureParams {
15442                    name: "plain".to_string(),
15443                    title: title.map(String::from),
15444                    description: description.map(String::from),
15445                    subject,
15446                    clear_subject,
15447                    note: Some("configure test".to_string()),
15448                }))
15449            };
15450
15451            // Set.
15452            let set = configure(
15453                Some("First Title"),
15454                Some("First description."),
15455                Some(crate::lifecycle::MemSubjectInput {
15456                    scope: "Scope one.".to_string(),
15457                    method: None,
15458                    exclusions: None,
15459                }),
15460                false,
15461            );
15462            assert!(!set.is_error.unwrap_or(false), "{set:?}");
15463            let body = set.structured_content.unwrap();
15464            assert_eq!(body["title"], "First Title");
15465            assert_eq!(body["subject"]["scope"], "Scope one.");
15466
15467            // Overwrite one field, leave the rest untouched.
15468            let over = configure(Some("Second Title"), None, None, false);
15469            let body = over.structured_content.unwrap();
15470            assert_eq!(body["title"], "Second Title");
15471            assert_eq!(body["description"], "First description.");
15472            assert_eq!(body["subject"]["scope"], "Scope one.");
15473
15474            // Clear: empty string for the strings, clear_subject for
15475            // the block.
15476            let cleared = configure(Some(""), Some(""), None, true);
15477            let body = cleared.structured_content.unwrap();
15478            assert!(
15479                body["title"].is_null(),
15480                "cleared title must be null: {body}"
15481            );
15482            assert!(body["description"].is_null());
15483            assert!(body["subject"].is_null());
15484
15485            // Unknown mem refuses with the typed code.
15486            let unknown =
15487                server.memstead_mem_configure(Parameters(crate::lifecycle::MemConfigureParams {
15488                    name: "no-such-mem".to_string(),
15489                    title: Some("X".to_string()),
15490                    description: None,
15491                    subject: None,
15492                    clear_subject: false,
15493                    note: None,
15494                }));
15495            assert!(unknown.is_error.unwrap_or(false));
15496            let err = unknown.structured_content.unwrap();
15497            assert_eq!(err["code"], "UNKNOWN_MEM", "{err}");
15498        }
15499
15500        /// Plan-05 criterion 3 (the operator's permission complement):
15501        /// curation params change nothing about the create allowlist
15502        /// gate — a rejected name refuses with the same typed code and
15503        /// detail shape; and configure against a read-only mount
15504        /// refuses READ_ONLY_MOUNT.
15505        #[test]
15506        fn mem_curation_changes_no_permission_gate() {
15507            // Restrictive create allowlist: only `allowed-*` passes.
15508            let tmp = TempDir::new().unwrap();
15509            let settings = memstead_git_branch::test_support::auto_seed_with_settings(
15510                tmp.path(),
15511                WorkspaceSettings {
15512                    mem_create_rules: vec![memstead_base::CreateRuleSetting {
15513                        pattern: "allowed-*".to_string(),
15514                        schemas: vec!["default@1.0.0".to_string()],
15515                        default_cross_links: None,
15516                    }],
15517                    mem_delete_rules: vec![],
15518                    ..Default::default()
15519                },
15520            );
15521            let mut unified = setup_unified_test_engine(tmp.path());
15522            unified.set_settings(settings);
15523            let canonical_root =
15524                std::fs::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
15525            unified.set_workspace_root(canonical_root);
15526            let server = McpServer::new(unified, crate::config::DEFAULT_TOKEN_BUDGET);
15527
15528            let target = tmp.path().join("denied-x");
15529            let refused = server.memstead_mem_create(Parameters(TlsMemCreateParams {
15530                title: Some("Sneaky Title".to_string()),
15531                description: Some("Sneaky description.".to_string()),
15532                subject: None,
15533                schema_verbosity: None,
15534                write_guidance: Default::default(),
15535                name: "denied-x".to_string(),
15536                location: target.to_string_lossy().into_owned(),
15537                schema: "default@1.0.0".to_string(),
15538                vcs: None,
15539                note: None,
15540                recovery: None,
15541                include_schema: false,
15542            }));
15543            assert!(refused.is_error.unwrap_or(false), "{refused:?}");
15544            let err = refused.structured_content.unwrap();
15545            assert_eq!(err["code"], "MEM_PATH_NOT_ALLOWED", "{err}");
15546            assert!(
15547                err["details"]["candidate"].is_string()
15548                    && err["details"]["patterns"].is_array()
15549                    && err["details"]["reason"].is_string(),
15550                "detail shape must match the param-less refusal: {err}"
15551            );
15552
15553            // Read-only mount: build a real sealed archive, register
15554            // it read-only on the live engine (the exact mount shape
15555            // `memstead install` produces), and configure against it.
15556            {
15557                let src = tmp.path().join("frozen-src").join("frozen");
15558                std::fs::create_dir_all(src.join(".memstead")).unwrap();
15559                std::fs::write(
15560                    src.join(".memstead/config.json"),
15561                    r#"{"version":"1.0.0","schema":"default@1.0.0"}"#,
15562                )
15563                .unwrap();
15564                std::fs::write(
15565                    src.join("solo.md"),
15566                    "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-01-15\nlevel: M0\n---\n# Solo\n\n## Identity\n\nA.\n\n## Purpose\n\nB.\n",
15567                ).unwrap();
15568                let archive = tmp.path().join("frozen.mem");
15569                let config = memstead_schema::load_and_validate(&src).unwrap();
15570                memstead_git_branch::ops::export::export_mem(&src, &config, &archive, None, None)
15571                    .unwrap();
15572
15573                let unified = server.unified_engine();
15574                let mut engine = unified.lock().unwrap_or_else(|e| e.into_inner());
15575                let mount = memstead_base::Mount {
15576                    mem: "frozen".to_string(),
15577                    schema: Some("default@1.0.0".parse().unwrap()),
15578                    storage: memstead_base::MountStorage::Archive {
15579                        path: archive.clone(),
15580                    },
15581                    capability: memstead_base::MountCapability::ReadOnly,
15582                    lifecycle: memstead_base::MountLifecycle::Eager,
15583                    cross_linkable: false,
15584                    migration_target: None,
15585                };
15586                let backend: Box<dyn memstead_base::MemBackend> =
15587                    Box::new(memstead_base::storage::ArchiveBackend::new(archive));
15588                engine
15589                    .register_read_mount(
15590                        mount,
15591                        backend,
15592                        memstead_base::MemOrigin::RuntimeCreated {
15593                            at: std::time::SystemTime::now(),
15594                            by_tool: "test",
15595                        },
15596                    )
15597                    .unwrap();
15598            }
15599            let ro =
15600                server.memstead_mem_configure(Parameters(crate::lifecycle::MemConfigureParams {
15601                    name: "frozen".to_string(),
15602                    title: Some("New Title".to_string()),
15603                    description: None,
15604                    subject: None,
15605                    clear_subject: false,
15606                    note: None,
15607                }));
15608            assert!(ro.is_error.unwrap_or(false), "{ro:?}");
15609            let err = ro.structured_content.unwrap();
15610            assert_eq!(err["code"], "READ_ONLY_MOUNT", "{err}");
15611        }
15612
15613        /// The schema payload exists, names the pinned schema, and ships
15614        /// the full type catalogue (not the lite `types_summary`).
15615        #[test]
15616        fn mem_create_response_carries_full_schema_catalogue() {
15617            let (server, tmp) = setup();
15618            let payload = create_and_get_payload(&server, &tmp, "fresh-payload");
15619            let schema = payload
15620                .get("schema")
15621                .expect("schema block present in mem_create response");
15622            assert_eq!(schema["ref"].as_str(), Some("default@1.0.0"));
15623            assert!(
15624                schema.get("types").is_some(),
15625                "full per-type catalogue must ship (types[]), not types_summary"
15626            );
15627            assert!(
15628                schema.get("types_summary").is_none(),
15629                "lite summary must not ship when types[] is requested"
15630            );
15631            assert!(
15632                schema["types"]
15633                    .as_array()
15634                    .map(|a| !a.is_empty())
15635                    .unwrap_or(false),
15636                "types[] must be non-empty"
15637            );
15638        }
15639
15640        /// Schema-level
15641        /// `default_writing_guidance` is surfaced at the top level of the
15642        /// schema payload so plugin-side resolvers (`writing-guidance.mjs`)
15643        /// can concatenate the schema-generic prose with per-mem
15644        /// additions in one place. Authored as block scalars in YAML;
15645        /// payload value is the raw `String` (chomp behaviour follows the
15646        /// loader's serde rules).
15647        #[test]
15648        fn schema_payload_surfaces_default_writing_guidance() {
15649            // Build a minimal schema fixture in-memory carrying both
15650            // `avoid` and `goal`. Direct call to `build_schema_payload`
15651            // — the field flows through every consumer (memstead_overview,
15652            // memstead_mem_create) via the same helper.
15653            let manifest_yaml = r#"name: tests-dwg
15654version: 0.1.0
15655description: dwg test schema
15656when_to_use: tests
15657types:
15658  - sample
15659relationships:
15660  mode: strict
15661  definitions:
15662    - name: PART_OF
15663      description: hier
15664      default_weight: 3.0
15665    - name: REFERENCES
15666      description: ref
15667      default_weight: 0.5
15668    - name: _default
15669      description: Fallback
15670      default_weight: 1.0
15671community:
15672  resolution: 1.0
15673  seed: 42
15674default_writing_guidance:
15675  avoid: |
15676    First avoid line.
15677
15678    - bullet
15679  goal: |
15680    Goal prose.
15681"#;
15682            let type_yaml = r#"name: sample
15683description: t
15684when_to_use: tests
15685sections:
15686  - key: body
15687    heading: Body
15688    required: true
15689    search_weight: 10.0
15690    catch_all: true
15691    write_rules: []
15692metadata_fields: []
15693title_weight: 100.0
15694text_fields:
15695  - body
15696hierarchy_relationship: PART_OF
15697no_self_loop_relationships: []
15698updatable_fields:
15699  - title
15700  - body
15701health_required_fields:
15702  - body
15703staleness_threshold_days: 90
15704write_rules: []
15705"#;
15706            let schema = Arc::new(
15707                memstead_schema::load_schema_from_memory(
15708                    manifest_yaml,
15709                    &[("sample".to_string(), type_yaml.to_string())],
15710                )
15711                .expect("dwg fixture must parse"),
15712            );
15713            let payload = render::build_schema_payload(
15714                &schema,
15715                vec!["v".to_string()],
15716                render::SchemaVerbosity::Full,
15717                render::OriginClass::FirstParty,
15718            );
15719            let dwg = payload
15720                .get("default_writing_guidance")
15721                .expect("default_writing_guidance must surface at top level");
15722            assert!(
15723                dwg["avoid"]
15724                    .as_str()
15725                    .map(|s| s.contains("First avoid line") && s.contains("- bullet"))
15726                    .unwrap_or(false),
15727                "avoid block scalar must reach the wire as a string; got {dwg}"
15728            );
15729            assert!(
15730                dwg["goal"]
15731                    .as_str()
15732                    .map(|s| s.contains("Goal prose"))
15733                    .unwrap_or(false),
15734                "goal block scalar must reach the wire; got {dwg}"
15735            );
15736        }
15737
15738        /// Every
15739        /// field carries its `filterable` posture so an agent reads
15740        /// `filters` / `range_filters` eligibility straight from the
15741        /// schema body — `"equality"`, `"range"`, or `null`. Also pins
15742        /// that the engine-stamped `created_date` is range-
15743        /// filterable.
15744        #[test]
15745        fn schema_payload_surfaces_filterable_posture_per_field() {
15746            let manifest_yaml = r#"name: tests-filterable
15747version: 0.1.0
15748description: filterable test schema
15749when_to_use: tests
15750types:
15751  - sample
15752relationships:
15753  mode: strict
15754  definitions:
15755    - name: PART_OF
15756      description: hier
15757      default_weight: 3.0
15758    - name: _default
15759      description: Fallback
15760      default_weight: 1.0
15761community:
15762  resolution: 1.0
15763  seed: 42
15764"#;
15765            let type_yaml = r#"name: sample
15766description: t
15767when_to_use: tests
15768sections:
15769  - key: body
15770    heading: Body
15771    required: true
15772    search_weight: 10.0
15773    catch_all: true
15774    write_rules: []
15775metadata_fields:
15776  - key: status
15777    description: state
15778    field_type: string
15779    filterable: equality
15780  - key: due_on
15781    description: a date
15782    field_type: date
15783    filterable: range
15784  - key: note
15785    description: freeform
15786    field_type: string
15787title_weight: 100.0
15788text_fields:
15789  - body
15790hierarchy_relationship: PART_OF
15791no_self_loop_relationships: []
15792updatable_fields:
15793  - title
15794  - body
15795health_required_fields:
15796  - body
15797staleness_threshold_days: 90
15798write_rules: []
15799"#;
15800            let schema = Arc::new(
15801                memstead_schema::load_schema_from_memory(
15802                    manifest_yaml,
15803                    &[("sample".to_string(), type_yaml.to_string())],
15804                )
15805                .expect("filterable fixture must parse"),
15806            );
15807            let payload = render::build_schema_payload(
15808                &schema,
15809                vec!["v".to_string()],
15810                render::SchemaVerbosity::Full,
15811                render::OriginClass::FirstParty,
15812            );
15813            let fields = payload["types"][0]["fields"]
15814                .as_array()
15815                .expect("fields array present");
15816            let field = |name: &str| {
15817                fields
15818                    .iter()
15819                    .find(|f| f["name"] == name)
15820                    .unwrap_or_else(|| panic!("field {name} present; got {payload}"))
15821            };
15822            assert_eq!(field("status")["filterable"], serde_json::json!("equality"));
15823            assert_eq!(field("due_on")["filterable"], serde_json::json!("range"));
15824            assert!(
15825                field("note")["filterable"].is_null(),
15826                "non-filterable field surfaces null, not an absent key"
15827            );
15828            // The engine-stamped `created_date` base field is
15829            // range-filterable (auto-prepended by the loader).
15830            assert_eq!(
15831                field("created_date")["filterable"],
15832                serde_json::json!("range"),
15833                "created_date must be range-filterable; got {payload}"
15834            );
15835        }
15836
15837        /// The schema-level `alias_target_rel_type` pointer surfaces at
15838        /// the top level of the schema payload so agents can predict
15839        /// from one introspection call which rel-type body wiki-links
15840        /// auto-emit. Schemas without the pointer ship no key at all.
15841        #[test]
15842        fn schema_payload_surfaces_alias_target_rel_type() {
15843            let manifest_yaml = r#"name: tests-alias
15844version: 0.1.0
15845description: alias test schema
15846when_to_use: tests
15847types:
15848  - sample
15849relationships:
15850  mode: strict
15851  definitions:
15852    - name: PART_OF
15853      description: hier
15854      default_weight: 3.0
15855    - name: REFERENCES
15856      description: ref
15857      default_weight: 0.5
15858    - name: _default
15859      description: Fallback
15860      default_weight: 1.0
15861alias_target_rel_type: REFERENCES
15862community:
15863  resolution: 1.0
15864  seed: 42
15865"#;
15866            let type_yaml = r#"name: sample
15867description: t
15868when_to_use: tests
15869sections:
15870  - key: body
15871    heading: Body
15872    required: true
15873    search_weight: 10.0
15874    catch_all: true
15875    write_rules: []
15876metadata_fields: []
15877title_weight: 100.0
15878text_fields:
15879  - body
15880hierarchy_relationship: PART_OF
15881no_self_loop_relationships: []
15882updatable_fields:
15883  - title
15884  - body
15885health_required_fields:
15886  - body
15887staleness_threshold_days: 90
15888write_rules: []
15889"#;
15890            let schema = Arc::new(
15891                memstead_schema::load_schema_from_memory(
15892                    manifest_yaml,
15893                    &[("sample".to_string(), type_yaml.to_string())],
15894                )
15895                .expect("alias fixture must parse"),
15896            );
15897            let payload = render::build_schema_payload(
15898                &schema,
15899                vec!["v".to_string()],
15900                render::SchemaVerbosity::Full,
15901                render::OriginClass::FirstParty,
15902            );
15903            assert_eq!(
15904                payload["alias_target_rel_type"].as_str(),
15905                Some("REFERENCES"),
15906                "alias_target_rel_type must surface at top level; got {payload}"
15907            );
15908        }
15909
15910        /// A schema omitting `alias_target_rel_type:` must not ship the
15911        /// key at all — keeps the wire envelope minimal for schemas
15912        /// that opt out of alias synthesis.
15913        #[test]
15914        fn schema_payload_omits_alias_target_rel_type_when_absent() {
15915            let manifest_yaml = r#"name: tests-alias-absent
15916version: 0.1.0
15917description: schema without alias pointer
15918when_to_use: tests
15919types:
15920  - sample
15921relationships:
15922  mode: strict
15923  definitions:
15924    - name: PART_OF
15925      description: hier
15926      default_weight: 3.0
15927    - name: _default
15928      description: Fallback
15929      default_weight: 1.0
15930community:
15931  resolution: 1.0
15932  seed: 42
15933"#;
15934            let type_yaml = r#"name: sample
15935description: t
15936when_to_use: tests
15937sections:
15938  - key: body
15939    heading: Body
15940    required: true
15941    search_weight: 10.0
15942    catch_all: true
15943    write_rules: []
15944metadata_fields: []
15945title_weight: 100.0
15946text_fields:
15947  - body
15948hierarchy_relationship: PART_OF
15949no_self_loop_relationships: []
15950updatable_fields:
15951  - title
15952  - body
15953health_required_fields:
15954  - body
15955staleness_threshold_days: 90
15956write_rules: []
15957"#;
15958            let schema = Arc::new(
15959                memstead_schema::load_schema_from_memory(
15960                    manifest_yaml,
15961                    &[("sample".to_string(), type_yaml.to_string())],
15962                )
15963                .expect("opt-out fixture must parse"),
15964            );
15965            let payload = render::build_schema_payload(
15966                &schema,
15967                vec!["v".to_string()],
15968                render::SchemaVerbosity::Full,
15969                render::OriginClass::FirstParty,
15970            );
15971            assert!(
15972                payload.get("alias_target_rel_type").is_none(),
15973                "key must be absent for schemas without the pointer; got {payload}"
15974            );
15975        }
15976
15977        /// A schema with a `cross_mem_relationships:`
15978        /// section surfaces it on the response as a top-level field
15979        /// with the same shape as the YAML (array of
15980        /// `{ to_schema, definitions }`). The intra-mem
15981        /// `relationships` block continues to round-trip.
15982        #[test]
15983        fn schema_payload_surfaces_cross_mem_relationships() {
15984            let manifest_yaml = r#"name: tests-cv
15985version: 0.1.0
15986description: cv test schema
15987when_to_use: tests
15988types:
15989  - sample
15990relationships:
15991  mode: strict
15992  definitions:
15993    - name: PART_OF
15994      description: hier
15995      default_weight: 3.0
15996    - name: _default
15997      description: fallback
15998      default_weight: 1.0
15999cross_mem_relationships:
16000  - to_schema: other
16001    definitions:
16002      - name: ADDRESSES
16003        description: outbound
16004        default_weight: 1.0
16005        source_types: [sample]
16006        target_types: [foreign_type]
16007community:
16008  resolution: 1.0
16009  seed: 42
16010"#;
16011            let type_yaml = r#"name: sample
16012description: t
16013when_to_use: tests
16014sections:
16015  - key: body
16016    heading: Body
16017    required: true
16018    search_weight: 10.0
16019    catch_all: true
16020    write_rules: []
16021metadata_fields: []
16022title_weight: 100.0
16023text_fields:
16024  - body
16025hierarchy_relationship: PART_OF
16026no_self_loop_relationships: []
16027updatable_fields:
16028  - title
16029  - body
16030health_required_fields:
16031  - body
16032staleness_threshold_days: 90
16033write_rules: []
16034"#;
16035            let schema = Arc::new(
16036                memstead_schema::load_schema_from_memory(
16037                    manifest_yaml,
16038                    &[("sample".to_string(), type_yaml.to_string())],
16039                )
16040                .expect("cv fixture must parse"),
16041            );
16042            let payload = render::build_schema_payload(
16043                &schema,
16044                vec!["v".to_string()],
16045                render::SchemaVerbosity::Full,
16046                render::OriginClass::FirstParty,
16047            );
16048            let cv = payload
16049                .get("cross_mem_relationships")
16050                .expect("cross_mem_relationships must surface at top level")
16051                .as_array()
16052                .expect("array");
16053            assert_eq!(cv.len(), 1);
16054            assert_eq!(cv[0]["to_schema"].as_str(), Some("other"));
16055            let defs = cv[0]["definitions"].as_array().expect("definitions array");
16056            assert_eq!(defs.len(), 1);
16057            assert_eq!(defs[0]["name"].as_str(), Some("ADDRESSES"));
16058            assert_eq!(
16059                defs[0]["source_types"].as_array().unwrap()[0].as_str(),
16060                Some("sample")
16061            );
16062            assert_eq!(
16063                defs[0]["target_types"].as_array().unwrap()[0].as_str(),
16064                Some("foreign_type")
16065            );
16066            // Intra-mem relationships block continues to round-trip.
16067            let intra = payload["relationships"].as_array().unwrap();
16068            assert!(intra.iter().any(|r| r["name"] == "PART_OF"));
16069        }
16070
16071        /// A schema with no `cross_mem_relationships:` declarations
16072        /// produces no top-level key — presence-of-key is the
16073        /// consumer's signal that the schema speaks cross-mem.
16074        #[test]
16075        fn schema_payload_includes_cross_mem_relationships_for_default() {
16076            // `default@1.0.0` declares a cross-mem REFERENCES relationship into
16077            // the `software` schema (the "knowledge mem → code mem" pairing), so
16078            // its schema payload must surface `cross_mem_relationships` naming
16079            // that entry. The omit-when-absent serialization contract (the key is
16080            // absent, never null/empty, when a schema declares no cross-mem) stays
16081            // covered by the sibling `schema_payload_omits_default_writing_guidance_when_absent`.
16082            let (server, tmp) = setup();
16083            let payload = create_and_get_payload(&server, &tmp, "cv");
16084            let schema = &payload["schema"];
16085            let cmr = schema.get("cross_mem_relationships").unwrap_or_else(|| {
16086                panic!("default now declares cross-mem into software → key must be present; got {schema}")
16087            });
16088            let s = cmr.to_string();
16089            assert!(
16090                s.contains("software") && s.contains("REFERENCES"),
16091                "the cross-mem payload names REFERENCES into software; got {cmr}"
16092            );
16093        }
16094
16095        /// A schema without `default_writing_guidance` produces no key at
16096        /// all (presence-of-key is the consumer's signal — never a null
16097        /// value or empty object).
16098        #[test]
16099        fn schema_payload_omits_default_writing_guidance_when_absent() {
16100            // Default schema carries no DWG; reuse the existing test
16101            // fixture's `default@1.0.0` payload.
16102            let (server, tmp) = setup();
16103            let payload = create_and_get_payload(&server, &tmp, "no-dwg");
16104            let schema = &payload["schema"];
16105            assert!(
16106                schema.get("default_writing_guidance").is_none(),
16107                "default schema has no DWG → key must be absent, not null/empty; got {schema}"
16108            );
16109        }
16110
16111        /// Item 05: the schema's `_default` rel-type is the internal
16112        /// weight-fallback knob (sets the edge weight every
16113        /// `_default`-less rel-type inherits) and is *not* a usable
16114        /// rel-type on `memstead_relate` (the relate path rejects it with
16115        /// `INVALID_REL_TYPE`). The schema response must not advertise
16116        /// it in `relationships[]` — pre-fix every agent reading the
16117        /// schema paid one round-trip per session learning the
16118        /// asymmetry by trial.
16119        #[test]
16120        fn schema_payload_omits_internal_default_rel_type() {
16121            // In-memory schema fixture — needs `_default` in the
16122            // relationship list so the suppression has something to
16123            // strip. Direct call to `build_schema_payload` so the
16124            // assertion runs against the helper that every consumer
16125            // (memstead_schema, memstead_overview, memstead_mem_create) uses.
16126            let manifest_yaml = r#"name: tests-no-default
16127version: 0.1.0
16128description: t
16129when_to_use: tests
16130types:
16131  - sample
16132relationships:
16133  mode: strict
16134  definitions:
16135    - name: PART_OF
16136      description: hier
16137      default_weight: 3.0
16138    - name: REFERENCES
16139      description: ref
16140      default_weight: 0.5
16141    - name: _default
16142      description: Fallback weight for any relationship not otherwise specified.
16143      default_weight: 1.0
16144community:
16145  resolution: 1.0
16146  seed: 42
16147"#;
16148            let type_yaml = r#"name: sample
16149description: t
16150when_to_use: tests
16151sections:
16152  - key: body
16153    heading: Body
16154    required: true
16155    search_weight: 10.0
16156    catch_all: true
16157    write_rules: []
16158metadata_fields: []
16159title_weight: 100.0
16160text_fields:
16161  - body
16162hierarchy_relationship: PART_OF
16163no_self_loop_relationships: []
16164updatable_fields:
16165  - title
16166  - body
16167health_required_fields:
16168  - body
16169staleness_threshold_days: 90
16170write_rules: []
16171"#;
16172            let schema = Arc::new(
16173                memstead_schema::load_schema_from_memory(
16174                    manifest_yaml,
16175                    &[("sample".to_string(), type_yaml.to_string())],
16176                )
16177                .expect("fixture must parse"),
16178            );
16179            let payload = render::build_schema_payload(
16180                &schema,
16181                vec!["v".to_string()],
16182                render::SchemaVerbosity::Full,
16183                render::OriginClass::FirstParty,
16184            );
16185            let rels = payload["relationships"]
16186                .as_array()
16187                .expect("relationships array present");
16188            // The user-facing rel-types survive; `_default` is filtered.
16189            let names: Vec<&str> = rels.iter().filter_map(|r| r["name"].as_str()).collect();
16190            assert!(
16191                names.contains(&"PART_OF"),
16192                "user-facing rel-types must survive; got {names:?}",
16193            );
16194            assert!(
16195                names.contains(&"REFERENCES"),
16196                "user-facing rel-types must survive; got {names:?}",
16197            );
16198            assert!(
16199                !names.contains(&"_default"),
16200                "internal `_default` weight fallback must not surface on the agent-facing relationship list; got {names:?}",
16201            );
16202        }
16203
16204        /// Schema response
16205        /// surfaces `acyclic` per rel-type and `no_self_loop_relationships`
16206        /// per type. Agents predict cycle-check + self-loop refusal from
16207        /// introspection without trial-and-error.
16208        #[test]
16209        fn schema_payload_carries_acyclic_and_no_self_loop_relationships() {
16210            let (server, tmp) = setup();
16211            let payload = create_and_get_payload(&server, &tmp, "introspection-cycle");
16212            // The default schema declares DEPENDS_ON as acyclic.
16213            let rels = payload["schema"]["relationships"]
16214                .as_array()
16215                .expect("relationships array present");
16216            let depends_on = rels
16217                .iter()
16218                .find(|r| r["name"] == "DEPENDS_ON")
16219                .expect("DEPENDS_ON rel-type present in default schema");
16220            assert_eq!(
16221                depends_on["acyclic"].as_bool(),
16222                Some(true),
16223                "DEPENDS_ON must carry acyclic=true: {depends_on}"
16224            );
16225            // The default schema's spec type lists [DEPENDS_ON, USES]
16226            // in no_self_loop_relationships.
16227            let types = payload["schema"]["types"]
16228                .as_array()
16229                .expect("types array present");
16230            let spec = types
16231                .iter()
16232                .find(|t| t["name"] == "spec")
16233                .expect("spec type present in default schema");
16234            let no_self_loop = spec["no_self_loop_relationships"]
16235                .as_array()
16236                .expect("no_self_loop_relationships array present");
16237            let names: Vec<&str> = no_self_loop.iter().filter_map(|v| v.as_str()).collect();
16238            assert!(
16239                names.contains(&"DEPENDS_ON") && names.contains(&"USES"),
16240                "spec's no_self_loop_relationships must list DEPENDS_ON and USES: {names:?}"
16241            );
16242        }
16243
16244        /// Sections in the payload carry their `write_rules` array.
16245        #[test]
16246        fn schema_payload_sections_carry_write_rules() {
16247            let (server, tmp) = setup();
16248            let payload = create_and_get_payload(&server, &tmp, "section-rules");
16249            let types = payload["schema"]["types"]
16250                .as_array()
16251                .expect("types array present");
16252
16253            // At least one type must have at least one section with a
16254            // populated write_rules array. Built-in `default@1.0.0` ships
16255            // genuine per-section rules for spec, decision, etc.
16256            let any_with_rules = types.iter().any(|t| {
16257                t["sections"]
16258                    .as_array()
16259                    .map(|sections| {
16260                        sections.iter().any(|s| {
16261                            s["write_rules"]
16262                                .as_array()
16263                                .map(|rules| !rules.is_empty())
16264                                .unwrap_or(false)
16265                        })
16266                    })
16267                    .unwrap_or(false)
16268            });
16269            assert!(
16270                any_with_rules,
16271                "at least one section must ship a non-empty write_rules array; types: {types:#?}"
16272            );
16273
16274            // Every section must have a `write_rules` field (even when
16275            // empty) so consumers can branch on its presence as
16276            // schema-grade metadata, not best-effort drift.
16277            for t in types {
16278                for section in t["sections"].as_array().unwrap_or(&Vec::new()) {
16279                    assert!(
16280                        section.get("write_rules").is_some(),
16281                        "every section must carry a write_rules field; type: {t:?}"
16282                    );
16283                }
16284            }
16285        }
16286
16287        /// Enum-typed metadata fields ship their `enum` value list — the
16288        /// agent reads the allowed values without a follow-up call.
16289        #[test]
16290        fn schema_payload_enum_fields_carry_allowed_values() {
16291            let (server, tmp) = setup();
16292            let payload = create_and_get_payload(&server, &tmp, "enum-fields");
16293            let types = payload["schema"]["types"].as_array().expect("types array");
16294
16295            // Built-in `default@1.0.0` has at least one enum-typed field
16296            // (e.g. `status` on spec). Pin that the enum surfaces.
16297            let any_with_enum = types.iter().any(|t| {
16298                t["fields"]
16299                    .as_array()
16300                    .map(|fields| fields.iter().any(|f| f.get("enum").is_some()))
16301                    .unwrap_or(false)
16302            });
16303            assert!(
16304                any_with_enum,
16305                "at least one metadata field must ship an `enum` array; types: {types:#?}"
16306            );
16307        }
16308
16309        /// Type-level `writing_guidance` and the relationship-vocabulary
16310        /// `when_to_use` strings must ship — these are the cure for the
16311        /// agent walking blind through the schema.
16312        #[test]
16313        fn schema_payload_carries_writing_guidance_and_when_to_use() {
16314            let (server, tmp) = setup();
16315            let payload = create_and_get_payload(&server, &tmp, "guidance-when-to-use");
16316            let schema = &payload["schema"];
16317
16318            let any_writing_guidance = schema["types"]
16319                .as_array()
16320                .map(|types| {
16321                    types.iter().any(|t| {
16322                        t["writing_guidance"]
16323                            .as_array()
16324                            .map(|g| !g.is_empty())
16325                            .unwrap_or(false)
16326                    })
16327                })
16328                .unwrap_or(false);
16329            assert!(
16330                any_writing_guidance,
16331                "type-level writing_guidance must ship for at least one type"
16332            );
16333
16334            let any_when_to_use = schema["relationships"]
16335                .as_array()
16336                .map(|rels| rels.iter().any(|r| r.get("when_to_use").is_some()))
16337                .unwrap_or(false);
16338            assert!(
16339                any_when_to_use,
16340                "relationships[].when_to_use must ship in the catalogue"
16341            );
16342        }
16343
16344        /// The schema payload from `memstead_mem_create` matches the schema
16345        /// payload from `memstead_schema(name=<resolved-schema>)` — pins that
16346        /// both surfaces share one helper (`build_schema_payload`) and do
16347        /// not drift. The priming contract anchors on `memstead_schema`
16348        /// rather than on overview.
16349        #[test]
16350        fn mem_create_payload_matches_memstead_schema() {
16351            let (server, tmp) = setup();
16352            let payload = create_and_get_payload(&server, &tmp, "byte-identical");
16353            let create_schema = payload["schema"].clone();
16354            let schema_ref = create_schema["ref"].as_str().unwrap().to_string();
16355
16356            // memstead_schema returns the schema body as a JSON
16357            // structured-content response — byte-equality with the
16358            // priming payload from memstead_mem_create is the contract,
16359            // at matching verbosity (both full here; the shared
16360            // omitted-param default is lite on both surfaces).
16361            let schema_result = server.memstead_schema(Parameters(SchemaParams {
16362                verbosity: Some("full".to_string()),
16363                name: Some(schema_ref.clone()),
16364                mem: None,
16365            }));
16366            assert!(
16367                !schema_result.is_error.unwrap_or(false),
16368                "memstead_schema must succeed: {schema_result:?}"
16369            );
16370            let schema_payload = schema_result
16371                .structured_content
16372                .clone()
16373                .expect("memstead_schema returns structured_content");
16374
16375            // The two payloads share one helper and must agree
16376            // field-by-field except for `used_by`, which is the mem
16377            // list at the moment of the call (mem_create's response
16378            // captures it during creation; the engine's reverse-index
16379            // computes the same set from the registered mems).
16380            let mut a = create_schema.clone();
16381            let mut b = schema_payload.clone();
16382            // Both should now list ["byte-identical"]. Strip the field
16383            // before comparison to keep the assertion explicit.
16384            if let Some(obj) = a.as_object_mut() {
16385                obj.remove("used_by");
16386            }
16387            if let Some(obj) = b.as_object_mut() {
16388                obj.remove("used_by");
16389            }
16390            assert_eq!(
16391                a, b,
16392                "mem_create.schema and memstead_schema must return identical payloads (modulo used_by)"
16393            );
16394
16395            // `used_by` itself must list the just-created mem on
16396            // both surfaces.
16397            for (label, payload) in [
16398                ("mem_create", &create_schema),
16399                ("memstead_schema", &schema_payload),
16400            ] {
16401                let used_by = payload["used_by"]
16402                    .as_array()
16403                    .unwrap_or_else(|| panic!("{label}.used_by must be an array; got {payload}"));
16404                let names: Vec<&str> = used_by.iter().filter_map(|v| v.as_str()).collect();
16405                assert!(
16406                    names.contains(&"byte-identical"),
16407                    "{label}.used_by must list the just-created mem; got {names:?}"
16408                );
16409            }
16410
16411            // Sanity guard against the legacy overview path: legacy
16412            // include=["schema_types"] no longer works, and full
16413            // schema bodies must not surface in overview anymore.
16414            let overview = server.memstead_overview(Parameters(OverviewParams {
16415                rebuild: Some(true),
16416                chunk: None,
16417                mem: Some("byte-identical".to_string()),
16418                include: None,
16419                token_budget: Some(32_000),
16420            }));
16421            let overview_text = extract_text(&overview);
16422            assert!(
16423                overview_text.contains("default@1.0.0"),
16424                "overview must reference the same schema ref"
16425            );
16426            assert!(
16427                !overview_text.contains("**Types:**"),
16428                "Types must NOT render under overview — moved to memstead_schema"
16429            );
16430            // Relationships and types no longer render under overview —
16431            // they live on memstead_schema's body, which we already
16432            // byte-equality-compared above.
16433            assert!(
16434                !overview_text.contains("**Relationships:**"),
16435                "Relationship vocabulary must NOT render under overview"
16436            );
16437        }
16438
16439        /// Shared read-envelope contract — the MCP `memstead_schema` path and a
16440        /// direct, rmcp-free call to the relocated
16441        /// `render::build_schema_payload` builder emit identical bytes.
16442        /// Proves schema-read is produced by one shared, transport-neutral
16443        /// builder reachable with no rmcp type in the path, so a future
16444        /// `/api/schema` is not a hand-mirrored third copy.
16445        #[test]
16446        fn memstead_schema_mcp_path_matches_direct_builder_bytes() {
16447            let (server, tmp) = setup();
16448            // Register a mem so `used_by` resolves to a known set.
16449            let _ = create_and_get_payload(&server, &tmp, "byte-identical-direct");
16450
16451            // MCP path: structured_content of the memstead_schema tool at
16452            // its omitted-param default (lite).
16453            let schema_result = server.memstead_schema(Parameters(SchemaParams {
16454                verbosity: None,
16455                name: Some("default@1.0.0".to_string()),
16456                mem: None,
16457            }));
16458            assert!(
16459                !schema_result.is_error.unwrap_or(false),
16460                "memstead_schema must succeed: {schema_result:?}"
16461            );
16462            let mcp_payload = schema_result
16463                .structured_content
16464                .clone()
16465                .expect("memstead_schema returns structured_content");
16466
16467            // Direct rmcp-free path: resolve the same schema Arc and the
16468            // same `used_by` the handler computes, then call the shared
16469            // builder in memstead-base::render with no rmcp type involved.
16470            let direct_payload = {
16471                let engine = server.unified_engine().lock().unwrap();
16472                let parsed: memstead_schema::SchemaRef =
16473                    "default@1.0.0".parse().expect("ref parses");
16474                let schema = find_schema_unified(&engine, &parsed)
16475                    .cloned()
16476                    .expect("default schema resolves");
16477                let canon = format!("{}@{}", schema.manifest.name, schema.version);
16478                let mut used_by: Vec<String> = engine
16479                    .mounts()
16480                    .iter()
16481                    .filter(|m| {
16482                        m.schema.as_ref().map(|s| s.to_string()).as_deref() == Some(canon.as_str())
16483                    })
16484                    .map(|m| m.mem.clone())
16485                    .collect();
16486                used_by.sort();
16487                render::build_schema_payload(
16488                    &schema,
16489                    used_by,
16490                    render::SchemaVerbosity::Lite,
16491                    render::OriginClass::FirstParty,
16492                )
16493            };
16494
16495            assert_eq!(
16496                mcp_payload, direct_payload,
16497                "MCP schema-read structured_content must equal the direct builder payload"
16498            );
16499            // Byte-level identity, not just structural equality.
16500            assert_eq!(
16501                serde_json::to_string(&mcp_payload).unwrap(),
16502                serde_json::to_string(&direct_payload).unwrap(),
16503                "serialized bytes must be identical across MCP and direct call"
16504            );
16505        }
16506    }
16507
16508    /// Every schema-bound failure carries recovery payload. Tests pin the per-code shape so
16509    /// the contract the agent reads on stumbling does not silently drift.
16510    mod recovery_payload {
16511        use super::*;
16512
16513        /// Pull the typed `{code, message, details}` envelope from an
16514        /// error-response's structured_content.
16515        fn envelope_payload(result: &CallToolResult) -> serde_json::Value {
16516            assert_eq!(result.is_error, Some(true), "expected error response");
16517            result
16518                .structured_content
16519                .as_ref()
16520                .cloned()
16521                .expect("structured_content present on errors")
16522        }
16523
16524        /// `MISSING_REQUIRED_SECTION` fires as a typed *error*
16525        /// envelope on the create path, not a warning. Pre-fix the
16526        /// section omission surfaced as a warning while the entity
16527        /// landed with empty placeholders — the resulting on-disk
16528        /// state then failed the install-time strict validator, so
16529        /// the export-then-install round-trip broke silently. The
16530        /// refusal carries the same `details` shape the warning
16531        /// historically shipped (per-section entries with
16532        /// `write_rules`, plus the top-level `type_guidance` map
16533        /// keyed by `entity_type`).
16534        #[test]
16535        fn missing_required_section_carries_type_guidance_top_level() {
16536            let (server, _tmp) = setup_dual_test_engine();
16537            let result = server.memstead_create(Parameters(CreateParams {
16538                anchors: None,
16539                title: "Bare Spec".to_string(),
16540                entity_type: "spec".to_string(),
16541                mem: Some("specs".to_string()),
16542                // Omit identity/purpose so MISSING_REQUIRED_SECTION fires.
16543                sections: None,
16544                metadata: None,
16545                relations: None,
16546                dry_run: Some(true),
16547                note: None,
16548                role: None,
16549            }));
16550            // create now refuses with a typed envelope; fish the
16551            // structured_content for the recovery payload.
16552            assert_eq!(
16553                result.is_error,
16554                Some(true),
16555                "missing required sections must refuse the create",
16556            );
16557            let payload = result
16558                .structured_content
16559                .as_ref()
16560                .cloned()
16561                .expect("error response present");
16562            assert_eq!(payload["code"], "MISSING_REQUIRED_SECTION");
16563            // Per-section payload mirrors the pre-fix warning shape.
16564            let sections = payload["details"]["sections"]
16565                .as_array()
16566                .cloned()
16567                .expect("details.sections present");
16568            assert!(
16569                !sections.is_empty(),
16570                "details.sections must list at least one missing key: {payload}",
16571            );
16572            let first = &sections[0];
16573            assert_eq!(first["entity_type"], "spec");
16574            assert!(
16575                first["write_rules"].is_array(),
16576                "per-section write_rules array must ship: {first:?}"
16577            );
16578
16579            // Top-level type_guidance map carries the entity type's
16580            // write_rules exactly once, keyed by `entity_type`.
16581            let guidance = payload["details"]["type_guidance"]
16582                .as_object()
16583                .expect("details.type_guidance map present");
16584            let spec_rules = guidance
16585                .get("spec")
16586                .and_then(|v| v.as_array())
16587                .expect("details.type_guidance.spec array present");
16588            assert!(
16589                !spec_rules.is_empty(),
16590                "type-level write_rules must be non-empty for `spec`"
16591            );
16592        }
16593
16594        /// F9 stable empty shape: `type_guidance` ships as `{}` even
16595        /// when no MissingRequiredSection / MissingRequiredField
16596        /// warnings fire, so consumers don't branch on field presence.
16597        #[test]
16598        fn type_guidance_ships_empty_shape_when_no_warnings() {
16599            let (server, _tmp) = setup_dual_test_engine();
16600            let result = server.memstead_create(Parameters(CreateParams {
16601                anchors: None,
16602                title: "Complete Spec".to_string(),
16603                entity_type: "spec".to_string(),
16604                mem: Some("specs".to_string()),
16605                sections: Some(indexmap::IndexMap::from_iter([
16606                    ("identity".to_string(), "what it is".to_string()),
16607                    ("purpose".to_string(), "why it exists".to_string()),
16608                ])),
16609                metadata: None,
16610                relations: None,
16611                dry_run: Some(true),
16612                note: None,
16613                role: None,
16614            }));
16615            let payload = result
16616                .structured_content
16617                .as_ref()
16618                .cloned()
16619                .expect("dry-run response present");
16620            let guidance = payload["type_guidance"]
16621                .as_object()
16622                .expect("type_guidance map present even on warning-free create");
16623            assert!(
16624                guidance.is_empty(),
16625                "no warnings → empty type_guidance, got {guidance:?}"
16626            );
16627        }
16628
16629        /// `INVALID_REL_TYPE` (error) carries the full schema relationship
16630        /// vocabulary plus a nearest-match suggestion when the typo is
16631        /// close to a declared rel.
16632        #[test]
16633        fn invalid_rel_type_carries_allowed_and_suggestion() {
16634            let (server, _tmp) = setup_dual_test_engine();
16635            // Typo: PART_O instead of PART_OF
16636            let result = server.memstead_relate(Parameters(RelateParams {
16637                relations: vec![RelateOpInput {
16638                    from: "specs--entity-a".to_string(),
16639                    to: "specs--entity-b".to_string(),
16640                    r#type: "PART_O".to_string(),
16641                    remove: None,
16642                    description: None,
16643                }],
16644                note: None,
16645                role: None,
16646                dry_run: None,
16647            }));
16648            let env = envelope_payload(&result);
16649            assert_eq!(env["code"].as_str(), Some("INVALID_REL_TYPE"));
16650            let allowed = env["details"]["allowed"]
16651                .as_array()
16652                .expect("allowed[] present");
16653            assert!(
16654                !allowed.is_empty(),
16655                "allowed[] must list the schema vocabulary"
16656            );
16657            // Each entry has `name` and `when_to_use` (Option may be None).
16658            assert!(allowed.iter().all(|h| h["name"].is_string()));
16659            // Strsim hits PART_OF for PART_O.
16660            assert_eq!(
16661                env["details"]["suggestion"].as_str(),
16662                Some("PART_OF"),
16663                "nearest-match suggestion should point at PART_OF"
16664            );
16665        }
16666
16667        /// `INVALID_REL_TYPE` also fires on syntactic violations — and
16668        /// even there the recovery payload ships the allowed vocabulary.
16669        #[test]
16670        fn invalid_rel_type_syntactic_still_carries_allowed() {
16671            let (server, _tmp) = setup_dual_test_engine();
16672            // Spaces are syntactically illegal.
16673            let result = server.memstead_relate(Parameters(RelateParams {
16674                relations: vec![RelateOpInput {
16675                    from: "specs--entity-a".to_string(),
16676                    to: "specs--entity-b".to_string(),
16677                    r#type: "alt rel type".to_string(),
16678                    remove: None,
16679                    description: None,
16680                }],
16681                note: None,
16682                role: None,
16683                dry_run: None,
16684            }));
16685            let env = envelope_payload(&result);
16686            assert_eq!(env["code"].as_str(), Some("INVALID_REL_TYPE"));
16687            assert!(
16688                env["details"]["allowed"]
16689                    .as_array()
16690                    .map(|a| !a.is_empty())
16691                    .unwrap_or(false),
16692                "syntactic-error path must still ship allowed[]"
16693            );
16694        }
16695
16696        /// `INVALID_ENUM_VALUE` (error) carries `allowed`, the field's
16697        /// `field_description`, a nearest-match `suggestion`, and the
16698        /// type's `type_write_rules`.
16699        #[test]
16700        fn invalid_enum_value_carries_full_recovery_payload() {
16701            let (server, _tmp) = setup_dual_test_engine();
16702            let mut metadata = IndexMap::new();
16703            metadata.insert("level".to_string(), "M99".to_string());
16704            let result = server.memstead_create(Parameters(CreateParams {
16705                anchors: None,
16706                title: "Bad Level".to_string(),
16707                entity_type: "spec".to_string(),
16708                mem: Some("specs".to_string()),
16709                sections: Some(IndexMap::from_iter([
16710                    ("identity".to_string(), "x".to_string()),
16711                    ("purpose".to_string(), "x".to_string()),
16712                ])),
16713                metadata: Some(metadata),
16714                relations: None,
16715                dry_run: Some(true),
16716                note: None,
16717                role: None,
16718            }));
16719            let env = envelope_payload(&result);
16720            assert_eq!(env["code"].as_str(), Some("INVALID_ENUM_VALUE"));
16721            assert_eq!(env["details"]["field"].as_str(), Some("level"));
16722            let allowed = env["details"]["allowed"]
16723                .as_array()
16724                .expect("allowed[] present");
16725            assert!(allowed.iter().any(|v| v == "M0"));
16726            assert!(
16727                env["details"]["field_description"].is_string(),
16728                "field_description must ship as String (may be empty); got: {env:?}"
16729            );
16730            assert_eq!(env["details"]["entity_type"].as_str(), Some("spec"));
16731            assert!(
16732                env["details"]["type_write_rules"]
16733                    .as_array()
16734                    .map(|a| !a.is_empty())
16735                    .unwrap_or(false),
16736                "type-level write_rules must ship for `spec`"
16737            );
16738        }
16739
16740        /// `REQUIRED_FIELD_UNSET` (error) — try to drop a field with a
16741        /// `default_value` (i.e. effectively required). Carries
16742        /// `field_description`, `enum_values` (when applicable), and
16743        /// `type_write_rules`.
16744        #[test]
16745        fn required_field_unset_carries_full_recovery_payload() {
16746            // `EngineError::RequiredFieldUnset` ships `field`,
16747            // `entity_type`, `field_description`, `enum_values`,
16748            // `type_write_rules` in the recovery envelope.
16749            let (server, _tmp) = setup_dual_test_engine();
16750            // Read the current hash for entity-a.
16751            let read = server.memstead_entity(Parameters(EntityParams {
16752                id: "specs--entity-a".to_string(),
16753                sections: None,
16754                include_relations: None,
16755                include_context: None,
16756                token_budget: None,
16757                chunk: None,
16758                include_provenance: None,
16759            }));
16760            let read_text = extract_text(&read);
16761            let hash = read_text
16762                .lines()
16763                .find(|l| l.starts_with("_hash:"))
16764                .map(|l| l.trim_start_matches("_hash:").trim().to_string())
16765                .expect("_hash present");
16766
16767            // Try to unset `level` — required (default_value = M0, not optional).
16768            let result = server.memstead_update(Parameters(UpdateParams {
16769                anchors: None,
16770                relations_unset: None,
16771                anchors_unset: None,
16772                id: "specs--entity-a".to_string(),
16773                expected_hash: hash,
16774                sections: None,
16775                append_sections: None,
16776                patch_sections: None,
16777                metadata: None,
16778                metadata_unset: Some(vec!["level".to_string()]),
16779                dry_run: Some(true),
16780                note: None,
16781                role: None,
16782                declare_relations: None,
16783            }));
16784            let env = envelope_payload(&result);
16785            assert_eq!(env["code"].as_str(), Some("REQUIRED_FIELD_UNSET"));
16786            assert_eq!(env["details"]["field"].as_str(), Some("level"));
16787            assert_eq!(env["details"]["entity_type"].as_str(), Some("spec"));
16788            assert!(env["details"]["field_description"].is_string());
16789            let enums = env["details"]["enum_values"]
16790                .as_array()
16791                .expect("enum_values present");
16792            assert!(enums.iter().any(|v| v == "M0"));
16793            assert!(
16794                env["details"]["type_write_rules"]
16795                    .as_array()
16796                    .map(|a| !a.is_empty())
16797                    .unwrap_or(false)
16798            );
16799        }
16800
16801        /// Regression — `UNKNOWN_SECTION` keeps its existing
16802        /// `details.declared` + `suggestion` shape so additive changes
16803        /// do not accidentally rewrite a code that
16804        /// already followed the recovery-payload pattern.
16805        #[test]
16806        fn unknown_section_regression() {
16807            let (server, _tmp) = setup_dual_test_engine();
16808            let result = server.memstead_create(Parameters(CreateParams {
16809                anchors: None,
16810                title: "Probe".to_string(),
16811                entity_type: "spec".to_string(),
16812                mem: Some("specs".to_string()),
16813                sections: Some(IndexMap::from_iter([(
16814                    "idntity".to_string(),
16815                    "typo".to_string(),
16816                )])),
16817                metadata: None,
16818                relations: None,
16819                dry_run: Some(true),
16820                note: None,
16821                role: None,
16822            }));
16823            let env = envelope_payload(&result);
16824            assert_eq!(env["code"].as_str(), Some("UNKNOWN_SECTION"));
16825            assert!(env["details"]["declared"].is_array());
16826            assert_eq!(
16827                env["details"]["suggestion"].as_str(),
16828                Some("identity"),
16829                "strsim must point at the closest declared key"
16830            );
16831        }
16832
16833        /// Regression — `UNKNOWN_METADATA_FIELD` keeps its shape.
16834        #[test]
16835        fn unknown_metadata_field_regression() {
16836            let (server, _tmp) = setup_dual_test_engine();
16837            let mut metadata = IndexMap::new();
16838            metadata.insert("levle".to_string(), "M0".to_string());
16839            let result = server.memstead_create(Parameters(CreateParams {
16840                anchors: None,
16841                title: "Probe".to_string(),
16842                entity_type: "spec".to_string(),
16843                mem: Some("specs".to_string()),
16844                sections: Some(IndexMap::from_iter([
16845                    ("identity".to_string(), "x".to_string()),
16846                    ("purpose".to_string(), "x".to_string()),
16847                ])),
16848                metadata: Some(metadata),
16849                relations: None,
16850                dry_run: Some(true),
16851                note: None,
16852                role: None,
16853            }));
16854            let env = envelope_payload(&result);
16855            assert_eq!(env["code"].as_str(), Some("UNKNOWN_METADATA_FIELD"));
16856            assert!(env["details"]["declared"].is_array());
16857            assert_eq!(env["details"]["suggestion"].as_str(), Some("level"));
16858        }
16859    }
16860
16861    /// `[[wiki-link]]` patterns in section content silently created
16862    /// stubs and a REFERENCES edge with no warning. Now surfaces as
16863    /// `INLINE_WIKI_LINK_AUTO_STUBBED` so an agent illustrating link
16864    /// syntax in prose notices the side-effect immediately.
16865    mod inline_wiki_link_warning {
16866        use super::*;
16867
16868        #[test]
16869        fn create_with_inline_link_to_unresolved_target_emits_warning() {
16870            // Under the alias model body wiki-links must be backed by
16871            // an atomic relation declaration; that relation auto-stubs
16872            // the absent target via the relate path and surfaces
16873            // `AUTO_STUB_CREATED` for agent review (the parser-side
16874            // inline-link auto-stub warning is structurally
16875            // unreachable now — the filter that drops aliased targets
16876            // empties `inline_links` before the scan runs).
16877            let (server, _tmp) = setup_dual_test_engine();
16878            let mut sections = IndexMap::new();
16879            sections.insert("identity".to_string(), "Probe.".to_string());
16880            sections.insert(
16881                "purpose".to_string(),
16882                "Example link form: [[ghost-target]] for documentation.".to_string(),
16883            );
16884            let result = server.memstead_create(Parameters(CreateParams {
16885                anchors: None,
16886                title: "Inline Demo".to_string(),
16887                entity_type: "spec".to_string(),
16888                mem: Some("specs".to_string()),
16889                sections: Some(sections),
16890                metadata: None,
16891                relations: Some(vec![crate::tools::mutation::RelationInput {
16892                    r#type: "USES".to_string(),
16893                    to: "specs--ghost-target".to_string(),
16894                    description: None,
16895                }]),
16896                dry_run: Some(true),
16897                note: None,
16898                role: None,
16899            }));
16900            let payload = result
16901                .structured_content
16902                .as_ref()
16903                .cloned()
16904                .expect("dry-run response present");
16905            let relations_declared = payload["relations_declared"]
16906                .as_array()
16907                .cloned()
16908                .expect("relations_declared echoed on response");
16909            assert_eq!(relations_declared.len(), 1);
16910            assert_eq!(
16911                relations_declared[0]["target_was_stubbed"].as_bool(),
16912                Some(true),
16913                "absent target must be flagged as stubbed in relations_declared"
16914            );
16915        }
16916
16917        /// Symmetry between `memstead_create` and `memstead_update`: both
16918        /// surface absent declared targets as auto-stubbed via the
16919        /// `relations_declared` echo (under the alias model the
16920        /// only auto-stub path is the explicit relation, so this is
16921        /// the surface to lock).
16922        #[test]
16923        fn create_and_update_emit_matching_inline_wiki_link_warnings() {
16924            let (server, _tmp) = setup_dual_test_engine();
16925
16926            let mut create_sections = IndexMap::new();
16927            create_sections.insert("identity".to_string(), "Probe.".to_string());
16928            create_sections.insert(
16929                "purpose".to_string(),
16930                "documents [[update-ghost]] usage".to_string(),
16931            );
16932            let create_res = server.memstead_create(Parameters(CreateParams {
16933                anchors: None,
16934                title: "Symmetry Create".to_string(),
16935                entity_type: "spec".to_string(),
16936                mem: Some("specs".to_string()),
16937                sections: Some(create_sections),
16938                metadata: None,
16939                relations: Some(vec![crate::tools::mutation::RelationInput {
16940                    r#type: "USES".to_string(),
16941                    to: "specs--update-ghost".to_string(),
16942                    description: None,
16943                }]),
16944                dry_run: Some(true),
16945                note: None,
16946                role: None,
16947            }));
16948            let create_payload = create_res
16949                .structured_content
16950                .as_ref()
16951                .cloned()
16952                .expect("create dry-run response present");
16953            let create_declared = create_payload["relations_declared"]
16954                .as_array()
16955                .cloned()
16956                .expect("create must echo relations_declared");
16957            assert_eq!(create_declared.len(), 1);
16958            assert_eq!(
16959                create_declared[0]["target_was_stubbed"].as_bool(),
16960                Some(true)
16961            );
16962
16963            let mut update_sections = IndexMap::new();
16964            update_sections.insert(
16965                "purpose".to_string(),
16966                "documents [[update-ghost]] usage".to_string(),
16967            );
16968            let update_res = server.memstead_update(Parameters(UpdateParams {
16969                anchors: None,
16970                relations_unset: None,
16971                anchors_unset: None,
16972                id: "specs--entity-a".to_string(),
16973                expected_hash: String::new(),
16974                sections: Some(update_sections),
16975                append_sections: None,
16976                patch_sections: None,
16977                metadata: None,
16978                metadata_unset: None,
16979                dry_run: Some(true),
16980                note: None,
16981                role: None,
16982                declare_relations: Some(vec![crate::tools::mutation::RelationInput {
16983                    r#type: "USES".to_string(),
16984                    to: "specs--update-ghost".to_string(),
16985                    description: None,
16986                }]),
16987            }));
16988            assert!(
16989                !update_res.is_error.unwrap_or(false),
16990                "update dry-run must succeed: {}",
16991                extract_text(&update_res),
16992            );
16993            let update_payload = update_res
16994                .structured_content
16995                .as_ref()
16996                .cloned()
16997                .expect("update dry-run response present");
16998            let update_declared = update_payload["relations_declared"]
16999                .as_array()
17000                .cloned()
17001                .expect("update must echo relations_declared");
17002            assert_eq!(update_declared.len(), 1);
17003            assert_eq!(
17004                update_declared[0]["target_was_stubbed"].as_bool(),
17005                Some(true)
17006            );
17007
17008            assert_eq!(
17009                create_declared[0]["target"], update_declared[0]["target"],
17010                "same target on both sides",
17011            );
17012        }
17013
17014        /// No warning when the inline link points at an entity that
17015        /// already exists — only auto-CREATED stubs surface.
17016        #[test]
17017        fn create_with_inline_link_to_existing_target_omits_warning() {
17018            let (server, _tmp) = setup_dual_test_engine();
17019            let mut sections = IndexMap::new();
17020            sections.insert("identity".to_string(), "Probe.".to_string());
17021            sections.insert(
17022                "purpose".to_string(),
17023                "Real link: [[entity-a]] (already in store).".to_string(),
17024            );
17025            let result = server.memstead_create(Parameters(CreateParams {
17026                anchors: None,
17027                title: "Real Link Demo".to_string(),
17028                entity_type: "spec".to_string(),
17029                mem: Some("specs".to_string()),
17030                sections: Some(sections),
17031                metadata: None,
17032                relations: None,
17033                dry_run: Some(true),
17034                note: None,
17035                role: None,
17036            }));
17037            let payload = result
17038                .structured_content
17039                .as_ref()
17040                .cloned()
17041                .expect("dry-run response present");
17042            let warnings = payload["warnings"].as_array().cloned().unwrap_or_default();
17043            assert!(
17044                !warnings
17045                    .iter()
17046                    .any(|w| w["code"] == "INLINE_WIKI_LINK_AUTO_STUBBED"),
17047                "no warning expected when the inline link resolves to an existing entity"
17048            );
17049        }
17050    }
17051
17052    /// F1 (coprobe-coherence-notice-fixes): reload drift must reach the
17053    /// agent on *error* responses too, on the same channel split a
17054    /// success carries — the full `mem_changed` notice on
17055    /// `structured_content`, the `MEM_RELOADED` admonition on the text
17056    /// channel. Pre-fix the read-path 404 early-return consumed the
17057    /// drained notice and surfaced it on neither channel, silently
17058    /// swallowing the whole reload window; the mutation error arms
17059    /// carried the notice on `structured_content` but never the text
17060    /// warning line.
17061    mod f1_drift_on_error_paths {
17062        use super::*;
17063        use memstead_base::vcs::{Actor, ClientId};
17064        use memstead_git_branch::test_support::init_real_mem_repo;
17065        use memstead_git_branch::workspace_store::engine_from_workspace_root;
17066
17067        fn client() -> ClientId {
17068            ClientId {
17069                name: "sibling".to_string(),
17070                version: "0".to_string(),
17071            }
17072        }
17073
17074        /// Create a `spec` (identity + purpose seeded) through the
17075        /// server, asserting success; returns the response `_hash`.
17076        fn create_spec(server: &McpServer, title: &str) -> String {
17077            let mut sections = indexmap::IndexMap::new();
17078            sections.insert("identity".to_string(), "identity body".to_string());
17079            sections.insert("purpose".to_string(), "purpose body".to_string());
17080            let r = server.memstead_create(Parameters(CreateParams {
17081                anchors: None,
17082                title: title.to_string(),
17083                entity_type: "spec".to_string(),
17084                mem: Some("specs".to_string()),
17085                sections: Some(sections),
17086                metadata: None,
17087                relations: None,
17088                dry_run: None,
17089                note: None,
17090                role: None,
17091            }));
17092            assert!(
17093                !r.is_error.unwrap_or(false),
17094                "create {title}: {}",
17095                extract_text(&r)
17096            );
17097            let sc = r.structured_content.as_ref().expect("create envelope");
17098            sc["_hash"].as_str().expect("create _hash").to_string()
17099        }
17100
17101        fn read_entity(server: &McpServer, id: &str) -> CallToolResult {
17102            server.memstead_entity(Parameters(EntityParams {
17103                id: id.to_string(),
17104                include_relations: None,
17105                include_context: None,
17106                sections: None,
17107                token_budget: None,
17108                chunk: None,
17109                include_provenance: None,
17110            }))
17111        }
17112
17113        /// Wholesale-replace the `purpose` section on a sibling engine,
17114        /// gated on `expected_hash`.
17115        fn sibling_update_purpose(
17116            b: &mut memstead_base::Engine,
17117            id: &EntityId,
17118            expected_hash: String,
17119            body: &str,
17120        ) {
17121            let mut sections = indexmap::IndexMap::new();
17122            sections.insert("purpose".to_string(), body.to_string());
17123            b.update_entity(
17124                memstead_base::UpdateEntityArgs {
17125                    anchors: Vec::new(),
17126                    id: id.clone(),
17127                    expected_hash: Some(expected_hash),
17128                    sections,
17129                    append_sections: indexmap::IndexMap::new(),
17130                    patch_sections: indexmap::IndexMap::new(),
17131                    metadata: indexmap::IndexMap::new(),
17132                    metadata_unset: Vec::new(),
17133                    dry_run: false,
17134                    declare_relations: Vec::new(),
17135                    relations_unset: Vec::new(),
17136                    anchors_unset: Vec::new(),
17137                },
17138                Actor::Cli,
17139                Some(&client()),
17140                None,
17141            )
17142            .expect("sibling update commits");
17143        }
17144
17145        /// The notices array a response carries on `structured_content`,
17146        /// or `None` when the `mem_changed` key is absent.
17147        fn mem_changed(res: &CallToolResult) -> Option<serde_json::Value> {
17148            res.structured_content
17149                .as_ref()
17150                .and_then(|sc| sc.get("mem_changed").cloned())
17151        }
17152
17153        #[test]
17154        fn entity_not_found_after_sibling_delete_carries_window_notice_and_warning_line() {
17155            let tmp = TempDir::new().unwrap();
17156            init_real_mem_repo(tmp.path(), &[("specs", "default@1.0.0")]);
17157            let engine_a = engine_from_workspace_root(tmp.path()).expect("engine A boots");
17158            let server = McpServer::new(engine_a, crate::config::DEFAULT_TOKEN_BUDGET);
17159
17160            // A creates X, Y (touched in the window) and Z (the
17161            // untouched bystander for the no-leak follow-on check).
17162            let x_hash = create_spec(&server, "Entity X");
17163            let y_hash = create_spec(&server, "Entity Y");
17164            create_spec(&server, "Entity Z");
17165
17166            // Sibling B boots at A's current head — sees X, Y, Z. In one
17167            // window (two commits) B modifies Y and deletes X.
17168            let mut b = engine_from_workspace_root(tmp.path()).expect("engine B boots");
17169            let x = EntityId::new("specs", "entity-x");
17170            let y = EntityId::new("specs", "entity-y");
17171            sibling_update_purpose(&mut b, &y, y_hash, "sibling-edited Y");
17172            b.delete_entity(
17173                memstead_base::DeleteEntityArgs {
17174                    id: x.clone(),
17175                    expected_hash: Some(x_hash),
17176                },
17177                Actor::Cli,
17178                Some(&client()),
17179                None,
17180            )
17181            .expect("sibling delete X commits");
17182
17183            // A reads the now-deleted X: reloads across the whole window,
17184            // returns ENTITY_NOT_FOUND, and the drift must ride both
17185            // channels.
17186            let r = read_entity(&server, "specs--entity-x");
17187            assert_eq!(r.is_error, Some(true), "deleted entity reads as error");
17188            let text = extract_text(&r);
17189            assert!(
17190                text.contains("ENTITY_NOT_FOUND"),
17191                "text carries the code: {text}"
17192            );
17193            assert!(
17194                text.contains("Engine snapshot reloaded"),
17195                "404 text carries the MEM_RELOADED admonition: {text}",
17196            );
17197
17198            let vc = mem_changed(&r).expect("404 carries mem_changed on structured_content");
17199            let notices = vc.as_array().expect("notices is an array");
17200            assert_eq!(notices.len(), 1, "one notice for the one reloaded mem");
17201            let entries = notices[0]["changes"]["entries"]
17202                .as_array()
17203                .expect("detailed notice carries entries");
17204            let removed_x = entries
17205                .iter()
17206                .any(|e| e["id"] == "specs--entity-x" && e["action"] == "removed");
17207            let modified_y = entries
17208                .iter()
17209                .any(|e| e["id"] == "specs--entity-y" && e["action"] == "updated");
17210            assert!(removed_x, "window notice lists X as removed: {entries:?}");
17211            assert!(
17212                modified_y,
17213                "window notice lists the sibling Y edit in the same window: {entries:?}",
17214            );
17215
17216            // No-leak + quiescence: the drain on the 404 path must not
17217            // leak into the next op, and A's head is now current, so a
17218            // read of the untouched Z carries no notice.
17219            let z = read_entity(&server, "specs--entity-z");
17220            assert!(
17221                !z.is_error.unwrap_or(false),
17222                "Z reads cleanly: {}",
17223                extract_text(&z)
17224            );
17225            assert!(
17226                mem_changed(&z).is_none(),
17227                "quiescent follow-on read carries no notice (no leak): {:?}",
17228                z.structured_content,
17229            );
17230            assert!(
17231                !extract_text(&z).contains("Engine snapshot reloaded"),
17232                "quiescent follow-on read carries no drift admonition",
17233            );
17234        }
17235
17236        #[test]
17237        fn update_hash_mismatch_carries_notice_and_warning_line() {
17238            let tmp = TempDir::new().unwrap();
17239            init_real_mem_repo(tmp.path(), &[("specs", "default@1.0.0")]);
17240            let engine_a = engine_from_workspace_root(tmp.path()).expect("engine A boots");
17241            let server = McpServer::new(engine_a, crate::config::DEFAULT_TOKEN_BUDGET);
17242
17243            // A creates X and remembers its hash.
17244            let stale_hash = create_spec(&server, "Entity X");
17245
17246            // Sibling B modifies X, advancing the head and invalidating
17247            // A's remembered hash.
17248            let mut b = engine_from_workspace_root(tmp.path()).expect("engine B boots");
17249            let x = EntityId::new("specs", "entity-x");
17250            let live_hash = b.get_entity(&x).expect("B sees X").content_hash.clone();
17251            sibling_update_purpose(&mut b, &x, live_hash, "sibling-edited X");
17252
17253            // A updates X with the now-stale hash: the engine reloads
17254            // before the write (stashing the notice), then the CAS fails
17255            // → HASH_MISMATCH. The notice already rode structured_content
17256            // pre-fix; the text channel must now carry the warning line.
17257            let mut sections = indexmap::IndexMap::new();
17258            sections.insert("purpose".to_string(), "A's racing edit".to_string());
17259            let r = server.memstead_update(Parameters(UpdateParams {
17260                anchors: None,
17261                relations_unset: None,
17262                anchors_unset: None,
17263                id: "specs--entity-x".to_string(),
17264                expected_hash: stale_hash,
17265                sections: Some(sections),
17266                append_sections: None,
17267                patch_sections: None,
17268                metadata: None,
17269                metadata_unset: None,
17270                dry_run: None,
17271                declare_relations: None,
17272                note: None,
17273                role: None,
17274            }));
17275            assert_eq!(r.is_error, Some(true), "stale-hash write refuses");
17276            let text = extract_text(&r);
17277            assert!(
17278                text.contains("HASH_MISMATCH"),
17279                "text carries the refusal code: {text}"
17280            );
17281            assert!(
17282                text.contains("Engine snapshot reloaded"),
17283                "HASH_MISMATCH text now carries the MEM_RELOADED warning line: {text}",
17284            );
17285            let vc = mem_changed(&r).expect("HASH_MISMATCH carries mem_changed (regression guard)");
17286            let notices = vc.as_array().expect("notices array");
17287            assert_eq!(notices.len(), 1, "one notice for the collision reload");
17288            let entries = notices[0]["changes"]["entries"]
17289                .as_array()
17290                .expect("entries");
17291            assert!(
17292                entries
17293                    .iter()
17294                    .any(|e| e["id"] == "specs--entity-x" && e["action"] == "updated"),
17295                "notice lists the sibling X edit that caused the collision: {entries:?}",
17296            );
17297        }
17298    }
17299}