#![cfg(not(feature = "mem-repo"))]
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use tempfile::TempDir;
const WORKSPACE_TOML_BODY: &str = "format = \"memstead-git-branch-2\"\n\n\
[persistence_adapter]\nname = \"file-two-layer\"\n";
const MOUNTS_JSON_BODY_EMPTY: &str = r#"{ "format": "memstead-mounts-3", "mounts": [] }"#;
fn memstead_mcp_bin() -> &'static str {
env!("CARGO_BIN_EXE_memstead-mcp")
}
fn seed_empty_workspace(root: &Path) {
let memstead = root.join(".memstead");
std::fs::create_dir_all(memstead.join("state")).unwrap();
std::fs::write(memstead.join("workspace.toml"), WORKSPACE_TOML_BODY).unwrap();
std::fs::write(
memstead.join("state").join("mounts.json"),
MOUNTS_JSON_BODY_EMPTY,
)
.unwrap();
}
fn seed_folder_workspace(root: &Path, mem_name: &str) {
use memstead_base::WorkspaceStoreAdapter;
use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
use memstead_schema::SchemaRef;
let pin: SchemaRef = "default@1.0.0".parse().unwrap();
let cfg = WorkspaceConfig::new(mem_name, pin.clone());
write_workspace_config(root, &cfg).unwrap();
let memstead = root.join(".memstead");
std::fs::create_dir_all(memstead.join("state")).unwrap();
std::fs::write(memstead.join("workspace.toml"), WORKSPACE_TOML_BODY).unwrap();
let workspace = memstead_base::Workspace {
mounts: vec![memstead_base::Mount {
mem: mem_name.to_string(),
schema: Some(pin),
storage: memstead_base::MountStorage::Folder {
path: root.to_path_buf(),
},
capability: memstead_base::MountCapability::Write,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
}],
settings: memstead_base::WorkspaceSettings::default(),
};
memstead_base::FileWorkspaceStore::new()
.save_state(root, &workspace)
.unwrap();
}
struct WireHarness {
child: Option<Child>,
stdin: Option<ChildStdin>,
reader: BufReader<ChildStdout>,
next_id: i64,
}
impl WireHarness {
fn start(cwd: &Path) -> Self {
let mut child = Command::new(memstead_mcp_bin())
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn memstead-mcp — confirm the binary built before running tests");
let stdin = child.stdin.take().expect("child stdin");
let stdout = child.stdout.take().expect("child stdout");
let mut harness = Self {
child: Some(child),
stdin: Some(stdin),
reader: BufReader::new(stdout),
next_id: 0,
};
harness.handshake();
harness
}
fn handshake(&mut self) {
let id = self.send_request(
"initialize",
json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "wire-shape-test", "version": "0" }
}),
);
let _ = self.read_response(id, Duration::from_secs(10));
self.send_notification("notifications/initialized", json!({}));
}
fn send_request(&mut self, method: &str, params: Value) -> i64 {
self.next_id += 1;
let id = self.next_id;
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let line = serde_json::to_string(&body).unwrap();
let stdin = self.stdin.as_mut().expect("stdin open");
writeln!(stdin, "{line}").expect("write request");
stdin.flush().expect("flush");
id
}
fn send_notification(&mut self, method: &str, params: Value) {
let body = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let line = serde_json::to_string(&body).unwrap();
let stdin = self.stdin.as_mut().expect("stdin open");
writeln!(stdin, "{line}").expect("write notification");
stdin.flush().expect("flush");
}
fn read_response(&mut self, want_id: i64, timeout: Duration) -> Value {
let deadline = Instant::now() + timeout;
let mut line = String::new();
loop {
if Instant::now() >= deadline {
panic!("no JSON-RPC response with id={want_id} within {timeout:?}");
}
line.clear();
match self.reader.read_line(&mut line) {
Ok(0) => panic!("stdout EOF before id={want_id} reply"),
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue, };
if value.get("id").and_then(|v| v.as_i64()) == Some(want_id) {
return value;
}
}
Err(_) => panic!("stdout read error before id={want_id} reply"),
}
}
}
fn call_tool(&mut self, name: &str, arguments: Value) -> Value {
let id = self.send_request(
"tools/call",
json!({ "name": name, "arguments": arguments }),
);
let response = self.read_response(id, Duration::from_secs(15));
if let Some(err) = response.get("error") {
return json!({ "_jsonrpc_error": err });
}
response
.get("result")
.cloned()
.expect("tools/call response must carry `result`")
}
}
impl Drop for WireHarness {
fn drop(&mut self) {
drop(self.stdin.take());
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn assert_error_envelope(result: &Value, expected_code: &str, expected_message: &str) {
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "expected isError=true on error path: {result}");
let structured = result
.get("structuredContent")
.expect("structuredContent missing — wire envelope drifted");
let code = structured
.get("code")
.and_then(Value::as_str)
.expect("structured.code missing");
assert_eq!(
code, expected_code,
"code drifted; structured payload = {structured}"
);
let msg = structured
.get("message")
.and_then(Value::as_str)
.unwrap_or_default();
assert_eq!(
msg, expected_message,
"message bytes drifted from pinned shape"
);
}
#[test]
fn lean_memstead_entity_emits_typed_envelope_for_missing_id() {
let tmp = TempDir::new().unwrap();
seed_empty_workspace(tmp.path());
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_entity", json!({ "id": "specs--does-not-exist" }));
assert_error_envelope(
&result,
"ENTITY_NOT_FOUND",
"entity not found: specs--does-not-exist",
);
}
fn assert_success_envelope(result: &Value) -> String {
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(!is_error, "expected success but got isError=true: {result}");
let content = result
.get("content")
.and_then(Value::as_array)
.expect("content[] missing — wire envelope drifted");
assert!(
!content.is_empty(),
"content[] empty — wire envelope drifted"
);
let first = &content[0];
let kind = first
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
assert_eq!(kind, "text", "content[0].type drifted: {first}");
first
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
#[test]
fn lean_memstead_search_succeeds_on_empty_seeded_workspace() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_search", json!({}));
let text = assert_success_envelope(&result);
for marker in ["_total: 0", "_returned: 0", "_offset: 0"] {
assert!(
text.contains(marker),
"search response missing {marker:?}: {text:?}"
);
}
}
#[test]
fn lean_memstead_overview_succeeds_on_empty_seeded_workspace() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_overview", json!({}));
let text = assert_success_envelope(&result);
assert!(
text.contains("## Mems"),
"overview missing ## Mems anchor: {text:?}"
);
assert!(
text.contains("## Schemas"),
"overview missing ## Schemas anchor: {text:?}"
);
assert!(text.contains("demo"), "overview missing mem name: {text:?}");
}
#[test]
fn lean_memstead_schema_unknown_name_emits_entity_not_found() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_schema", json!({ "name": "not-a-schema" }));
assert_error_envelope(
&result,
"ENTITY_NOT_FOUND",
"schema not found: \"not-a-schema\" — workspace pins default@1.0.0",
);
}
fn assert_create_success_shape(result: &Value, expected_id: &str, expected_mem: &str) {
let _text = assert_success_envelope(result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on create success");
for field in ["id", "title", "mem", "_hash", "warnings"] {
assert!(
body.get(field).is_some(),
"create response missing {field:?}: {body}"
);
}
assert_eq!(
body.get("id").and_then(Value::as_str),
Some(expected_id),
"create id drifted from slug rule: {body}"
);
assert_eq!(
body.get("mem").and_then(Value::as_str),
Some(expected_mem),
"create response mem drifted: {body}"
);
}
#[test]
fn lean_memstead_create_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool(
"memstead_create",
json!({
"title": "First",
"entity_type": "spec",
"sections": { "identity": "the identity", "purpose": "the purpose" },
}),
);
assert_create_success_shape(&result, "demo--first", "demo");
}
#[test]
fn lean_memstead_create_unknown_type_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool(
"memstead_create",
json!({ "title": "X", "entity_type": "totally-not-a-type" }),
);
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "expected isError on unknown type: {result}");
let structured = result
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("UNKNOWN_ENTITY_TYPE"),
"code drifted: {structured}"
);
let msg = structured
.get("message")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
msg.contains("totally-not-a-type"),
"message missing rejected type name: {msg:?}"
);
assert!(
msg.contains("Declared types:") || msg.contains("declared types:"),
"message missing declared-types prefix: {msg:?}"
);
}
#[test]
fn lean_memstead_health_succeeds_on_seeded_workspace() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_health", json!({}));
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on health success");
for field in [
"missing_fields",
"orphan_count",
"stale_entities",
"stub_count",
] {
assert!(
body.get(field).is_some(),
"lean health response missing {field:?}: {body}"
);
}
assert!(
body.get("writable_mems").is_none(),
"lean health unexpectedly carries writable_mems — \
if this is intended, update the pin: {body}"
);
}
fn create_and_get_id_hash(harness: &mut WireHarness, title: &str) -> (String, String) {
let result = harness.call_tool(
"memstead_create",
json!({
"title": title,
"entity_type": "spec",
"sections": {
"identity": "seed identity",
"purpose": "seed purpose",
},
}),
);
let body = result
.get("structuredContent")
.expect("create response missing structuredContent");
let id = body
.get("id")
.and_then(Value::as_str)
.expect("create response missing id")
.to_string();
let hash = body
.get("_hash")
.and_then(Value::as_str)
.expect("create response missing content_hash")
.to_string();
(id, hash)
}
fn assert_hash_mismatch_envelope(result: &Value, expected_id: &str, expected_current: &str) {
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "expected isError=true on stale hash: {result}");
let structured = result
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("HASH_MISMATCH"),
"code drifted: {structured}"
);
let details = structured
.get("details")
.expect("HASH_MISMATCH must carry details");
assert_eq!(
details.get("id").and_then(Value::as_str),
Some(expected_id),
"details.id drifted: {details}"
);
assert_eq!(
details.get("current").and_then(Value::as_str),
Some(expected_current),
"details.current drifted: {details}"
);
assert!(
details.get("is_stub").is_some(),
"details.is_stub missing — recovery payload contract drifted: {details}"
);
}
#[test]
fn lean_memstead_update_stale_hash_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (id, real_hash) = create_and_get_id_hash(&mut harness, "Locked");
let stale_hash = "0".repeat(64);
let result = harness.call_tool(
"memstead_update",
json!({
"id": id,
"expected_hash": stale_hash,
"sections": { "identity": "new body" },
}),
);
assert_hash_mismatch_envelope(&result, &id, &real_hash);
}
#[test]
fn lean_memstead_update_succeeds_and_rotates_hash() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (id, original_hash) = create_and_get_id_hash(&mut harness, "Updatable");
let result = harness.call_tool(
"memstead_update",
json!({
"id": id,
"expected_hash": original_hash,
"sections": { "identity": "rewritten body" },
}),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on update success");
let new_hash = body
.get("_hash")
.and_then(Value::as_str)
.expect("update response missing content_hash");
assert_ne!(
new_hash, original_hash,
"content_hash did not rotate after section rewrite: {body}"
);
}
#[test]
fn lean_memstead_delete_succeeds_and_entity_becomes_unreadable() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (id, hash) = create_and_get_id_hash(&mut harness, "Doomed");
let del = harness.call_tool(
"memstead_delete",
json!({ "id": id, "expected_hash": hash }),
);
let _ = assert_success_envelope(&del);
let read = harness.call_tool("memstead_entity", json!({ "id": id }));
assert_error_envelope(
&read,
"ENTITY_NOT_FOUND",
&format!("entity not found: {id}"),
);
}
#[test]
fn lean_memstead_relate_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (from, _) = create_and_get_id_hash(&mut harness, "Source");
let (to, _) = create_and_get_id_hash(&mut harness, "Target");
let result = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": from, "to": to, "type": "USES" }] }),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on relate success");
let entry = body
.get("results")
.and_then(|r| r.get(0))
.expect("plural envelope carries results[0]");
assert_eq!(
entry.get("from").and_then(Value::as_str),
Some(from.as_str()),
"relate `from` drifted: {body}"
);
assert_eq!(
entry.get("to").and_then(Value::as_str),
Some(to.as_str()),
"relate `to` drifted: {body}"
);
assert_eq!(
entry.get("rel_type").and_then(Value::as_str),
Some("USES"),
"lean relate `rel_type` drifted: {body}"
);
assert!(
body.get("rel_type").is_none(),
"`rel_type` belongs inside results[], never at the top level: {body}"
);
assert_eq!(
entry.get("action").and_then(Value::as_str),
Some("added"),
"lean relate `action` drifted: {body}"
);
assert!(
body.get("action").is_none(),
"`action` belongs inside results[], never at the top level: {body}"
);
}
#[test]
fn lean_memstead_rename_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (id, hash) = create_and_get_id_hash(&mut harness, "Old Title");
let result = harness.call_tool(
"memstead_rename",
json!({ "id": id, "new_title": "New Title", "expected_hash": hash }),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on rename success");
assert_eq!(
body.get("old_id").and_then(Value::as_str),
Some(id.as_str()),
"old_id drifted: {body}"
);
assert_eq!(
body.get("new_id").and_then(Value::as_str),
Some("demo--new-title"),
"new_id drifted from slug rule: {body}"
);
}
#[test]
fn lean_memstead_rename_same_slug_silent_noop() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (id, hash) = create_and_get_id_hash(&mut harness, "First");
let result = harness.call_tool(
"memstead_rename",
json!({ "id": id, "new_title": "First", "expected_hash": hash }),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
body.get("old_id").and_then(Value::as_str),
Some(id.as_str()),
"old_id drifted: {body}"
);
assert_eq!(
body.get("new_id").and_then(Value::as_str),
Some(id.as_str()),
"new_id should equal old_id for same-slug rename: {body}"
);
let warnings = body
.get("warnings")
.and_then(Value::as_array)
.expect("lean rename must now carry a warnings array");
assert!(
warnings
.iter()
.any(|w| w.get("code").and_then(Value::as_str) == Some("TITLE_NORMALIZED_TO_SLUG_NOOP")),
"same-slug rename must surface TITLE_NORMALIZED_TO_SLUG_NOOP: {body}"
);
}
fn assert_has_incoming_refs_envelope(result: &Value, expected_target: &str, expected_source: &str) {
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(
is_error,
"expected isError on delete with referrers: {result}"
);
let structured = result
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("HAS_INCOMING_REFS"),
"code drifted: {structured}"
);
let details = structured
.get("details")
.expect("details missing on HAS_INCOMING_REFS");
assert_eq!(
details.get("id").and_then(Value::as_str),
Some(expected_target),
"details.id drifted: {details}"
);
let referrers = details
.get("referrers")
.and_then(Value::as_array)
.expect("details.referrers[] missing");
assert!(
!referrers.is_empty(),
"details.referrers[] is empty: {details}"
);
let first = &referrers[0];
assert_eq!(
first.get("from_id").and_then(Value::as_str),
Some(expected_source),
"referrer.from_id drifted: {first}"
);
assert_eq!(
first.get("capability").and_then(Value::as_str),
Some("write"),
"referrer.capability drifted: {first}"
);
let rel_types = first
.get("rel_types")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("referrer.rel_types missing: {first}"));
assert!(
!rel_types.is_empty(),
"referrer.rel_types must carry ≥1 entry: {first}"
);
assert!(
first.get("mem").and_then(Value::as_str).is_some(),
"referrer.mem missing: {first}"
);
}
#[test]
fn lean_memstead_delete_with_incoming_refs_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (source, _) = create_and_get_id_hash(&mut harness, "Referrer");
let (target, target_hash) = create_and_get_id_hash(&mut harness, "Referenced");
let relate = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": source, "to": target, "type": "USES" }] }),
);
let _ = assert_success_envelope(&relate);
let del = harness.call_tool(
"memstead_delete",
json!({ "id": target, "expected_hash": target_hash }),
);
assert_has_incoming_refs_envelope(&del, &target, &source);
}
#[test]
fn lean_memstead_changes_since_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let _ = create_and_get_id_hash(&mut harness, "First");
let result = harness.call_tool(
"memstead_changes_since",
json!({ "mem": "demo", "since": "" }),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on changes_since success");
for field in ["since", "count", "entries"] {
assert!(
body.get(field).is_some(),
"lean changes_since response missing {field:?}: {body}"
);
}
let count = body.get("count").and_then(Value::as_u64).unwrap_or(0);
assert!(
count >= 1,
"expected at least one changelog entry after create: {body}"
);
}
#[test]
fn lean_auto_stub_then_update_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (source, _) = create_and_get_id_hash(&mut harness, "Source");
let stub_id = "demo--ghost";
let relate = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": source, "to": stub_id, "type": "USES" }] }),
);
let _ = assert_success_envelope(&relate);
let body = relate
.get("structuredContent")
.expect("structuredContent missing on relate");
let warnings = body
.get("warnings")
.and_then(Value::as_array)
.expect("relate-to-absent-target must carry warnings[]");
let codes: Vec<&str> = warnings
.iter()
.filter_map(|w| w.get("code").and_then(Value::as_str))
.collect();
assert!(
codes.contains(&"AUTO_STUB_CREATED"),
"expected AUTO_STUB_CREATED warning, got codes={codes:?}: {body}"
);
let update = harness.call_tool(
"memstead_update",
json!({
"id": stub_id,
"expected_hash": "",
"sections": { "identity": "promotion-attempt" },
}),
);
let is_error = update
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "expected isError on stub update: {update}");
let structured = update
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("STUB_NOT_UPDATABLE"),
"code drifted: {structured}"
);
}
#[test]
fn lean_rename_stub_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (source, _) = create_and_get_id_hash(&mut harness, "Source");
let stub_id = "demo--ghost";
let _ = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": source, "to": stub_id, "type": "USES" }] }),
);
let rename = harness.call_tool(
"memstead_rename",
json!({ "id": stub_id, "new_title": "Promoted", "expected_hash": "" }),
);
let is_error = rename
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "expected isError on stub rename: {rename}");
let structured = rename
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("STUB_NOT_RENAMABLE"),
"code drifted: {structured}"
);
}
#[test]
fn lean_relate_from_stub_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_folder_workspace(tmp.path(), "demo");
let mut harness = WireHarness::start(tmp.path());
let (source, _) = create_and_get_id_hash(&mut harness, "Real");
let stub_id = "demo--ghost";
let _ = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": source, "to": stub_id, "type": "USES" }] }),
);
let result = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": stub_id, "to": source, "type": "USES" }] }),
);
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "expected isError on relate-from-stub: {result}");
let structured = result
.get("structuredContent")
.expect("structuredContent missing");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("STUB_CANNOT_RELATE"),
"code drifted: {structured}"
);
}