#![cfg(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_full_workspace(root: &Path, mems: &[(&str, &str)]) {
seed_full_workspace_with_toml(root, mems, WORKSPACE_TOML_BODY);
}
fn seed_full_workspace_with_toml(root: &Path, mems: &[(&str, &str)], workspace_toml: &str) {
use memstead_base::WorkspaceStoreAdapter;
use memstead_schema::SchemaRef;
memstead_git_branch::test_support::init_real_mem_repo(root, mems);
let memstead = root.join(".memstead");
std::fs::create_dir_all(memstead.join("state")).unwrap();
std::fs::write(memstead.join("workspace.toml"), workspace_toml).unwrap();
let gitdir = root.join("mem-repo").join(".git");
let mounts: Vec<memstead_base::Mount> = mems
.iter()
.map(|(name, schema)| {
let pin: SchemaRef = schema.parse().unwrap();
memstead_base::Mount {
mem: (*name).to_string(),
schema: Some(pin),
storage: memstead_base::MountStorage::GitBranch {
gitdir: gitdir.clone(),
branch: (*name).to_string(),
},
capability: memstead_base::MountCapability::Write,
lifecycle: memstead_base::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
}
})
.collect();
let workspace = memstead_base::Workspace {
mounts,
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 {
Self::start_with_args(cwd, &[])
}
fn start_with_args(cwd: &Path, args: &[&str]) -> Self {
let mut cmd = Command::new(memstead_mcp_bin());
cmd.current_dir(cwd)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.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 full_memstead_entity_emits_typed_envelope_for_missing_id() {
let tmp = TempDir::new().unwrap();
seed_empty_workspace(tmp.path());
memstead_git_branch::test_support::init_real_mem_repo(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 full_memstead_search_succeeds_on_empty_seeded_workspace() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_overview_succeeds_on_empty_seeded_workspace() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_overview", json!({}));
let text = assert_success_envelope(&result);
for anchor in [
"## Mems",
"## Schemas",
"## Communities",
"## Lifecycle Namespaces",
] {
assert!(
text.contains(anchor),
"full overview missing {anchor:?}: {text:?}"
);
}
assert!(
text.contains("demo"),
"full overview missing mem name: {text:?}"
);
}
#[test]
fn full_memstead_schema_unknown_name_emits_entity_not_found() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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\"",
);
}
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 full_memstead_create_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_create_unknown_type_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_health_succeeds_on_seeded_workspace() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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");
assert!(
body.get("writable_mems").is_some(),
"full health response missing writable_mems: {body}"
);
}
#[test]
fn full_memstead_changes_since_bad_cursor_returns_invalid_cursor() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let bad = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let result = harness.call_tool(
"memstead_changes_since",
json!({ "mem": "demo", "since": bad }),
);
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(is_error, "a bad since cursor must error: {result}");
let sc = result
.get("structuredContent")
.expect("structuredContent missing on error envelope");
assert_eq!(
sc.get("code").and_then(Value::as_str),
Some("INVALID_CURSOR"),
"bad since must carry the typed INVALID_CURSOR code, not MEM_ERROR: {sc}",
);
assert_eq!(
sc.get("details")
.and_then(|d| d.get("since"))
.and_then(Value::as_str),
Some(bad),
"the offending SHA must ride untruncated in details.since: {sc}",
);
}
#[test]
fn full_default_writable_mem_is_stable_after_second_mem() {
const TOML: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(tmp.path(), &[("demo", "default@1.0.0")], TOML);
let mut harness = WireHarness::start(tmp.path());
let sections = json!({ "identity": "the identity", "purpose": "the purpose" });
let c1 = harness.call_tool(
"memstead_create",
json!({ "title": "First", "entity_type": "spec", "sections": sections }),
);
assert_create_success_shape(&c1, "demo--first", "demo");
let cv = harness.call_tool(
"memstead_mem_create",
json!({ "name": "aaa", "location": "mems/aaa", "schema": "default@1.0.0" }),
);
let _ = assert_success_envelope(&cv);
let c2 = harness.call_tool(
"memstead_create",
json!({ "title": "Second", "entity_type": "spec", "sections": sections }),
);
assert_create_success_shape(&c2, "demo--second", "demo");
let health = harness.call_tool("memstead_health", json!({}));
let hbody = health
.get("structuredContent")
.expect("structuredContent missing on health success");
assert_eq!(
hbody.get("default_writable_mem").and_then(Value::as_str),
Some("demo"),
"memstead_health must name the stable default: {hbody}",
);
let c3 = harness.call_tool(
"memstead_create",
json!({ "mem": "aaa", "title": "Third", "entity_type": "spec", "sections": sections }),
);
assert_create_success_shape(&c3, "aaa--third", "aaa");
}
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 full_memstead_update_stale_hash_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_update_succeeds_and_rotates_hash() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_delete_succeeds_and_entity_becomes_unreadable() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_relate_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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"),
"full relate `rel_type` drifted: {body}"
);
assert!(
body.get("type").is_none(),
"full must not carry `type` (lean field name): {body}"
);
assert_eq!(
entry.get("action").and_then(Value::as_str),
Some("added"),
"full relate `action` drifted: {body}"
);
assert!(
body.get("action").is_none(),
"`action` belongs inside results[], never at the top level: {body}"
);
}
#[test]
fn full_memstead_rename_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_rename_same_slug_emits_typed_warning() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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()),
);
assert_eq!(
body.get("new_id").and_then(Value::as_str),
Some(id.as_str()),
);
let warnings = body
.get("warnings")
.and_then(Value::as_array)
.expect("full rename success must carry warnings[]");
let codes: Vec<&str> = warnings
.iter()
.filter_map(|w| w.get("code").and_then(Value::as_str))
.collect();
assert!(
codes.contains(&"TITLE_NORMALIZED_TO_SLUG_NOOP"),
"expected TITLE_NORMALIZED_TO_SLUG_NOOP warning, got codes={codes:?}: {body}"
);
}
#[test]
fn full_memstead_reload_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool("memstead_reload", json!({}));
let _ = assert_success_envelope(&result);
assert!(
result.get("structuredContent").is_some(),
"reload response missing structuredContent: {result}"
);
}
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 full_memstead_delete_with_incoming_refs_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_memstead_changes_since_returns_typed_success_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let _ = create_and_get_id_hash(&mut harness, "First");
let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let result = harness.call_tool(
"memstead_changes_since",
json!({ "mem": "demo", "since": empty_tree }),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on changes_since success");
assert!(
body.get("changes").is_some(),
"full changes_since response missing `changes[]`: {body}"
);
assert!(
body.get("entries").is_none(),
"full response unexpectedly carries lean's `entries[]`: {body}"
);
}
#[test]
fn full_memstead_changes_since_wide_window_uses_authoritative_rename_map() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let (rename_id, _) = create_and_get_id_hash(&mut harness, "Leading And Trailing Whitespace");
let (other_a, _) = create_and_get_id_hash(&mut harness, "Adjacent Memo Alpha");
let (other_b, _) = create_and_get_id_hash(&mut harness, "Adjacent Memo Beta");
let entity_read = harness.call_tool("memstead_entity", json!({ "id": rename_id }));
let entity_text = assert_success_envelope(&entity_read);
let pre_hash = entity_text
.lines()
.find_map(|l| l.strip_prefix("_hash: "))
.map(|s| s.trim_matches('"').to_string())
.expect("entity text must carry _hash");
let last_seed_create = harness.call_tool("memstead_entity", json!({ "id": other_b }));
let _ = assert_success_envelope(&last_seed_create);
let cursor_capture = harness.call_tool(
"memstead_changes_since",
json!({
"mem": "demo",
"since": "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
}),
);
let _ = assert_success_envelope(&cursor_capture);
let head_sha = cursor_capture
.get("structuredContent")
.and_then(|c| c.get("head"))
.and_then(Value::as_str)
.expect("changes_since must echo head for cursor capture")
.to_string();
let entity_a_read = harness.call_tool("memstead_entity", json!({ "id": other_a }));
let a_text = assert_success_envelope(&entity_a_read);
let other_a_hash = a_text
.lines()
.find_map(|l| l.strip_prefix("_hash: "))
.map(|s| s.trim_matches('"').to_string())
.expect("entity_a missing _hash");
let update_a = harness.call_tool(
"memstead_update",
json!({
"id": other_a,
"expected_hash": other_a_hash,
"sections": {
"identity": "Some adjacent content overlapping with the rename target.",
},
}),
);
let _ = assert_success_envelope(&update_a);
let renamed = harness.call_tool(
"memstead_rename",
json!({
"id": rename_id,
"new_title": "Whitespace Memo Renamed",
"expected_hash": pre_hash,
}),
);
let renamed_body = renamed
.get("structuredContent")
.expect("rename response missing body");
let new_id = renamed_body
.get("new_id")
.and_then(Value::as_str)
.expect("rename response missing new_id")
.to_string();
let feed = harness.call_tool(
"memstead_changes_since",
json!({ "mem": "demo", "since": head_sha }),
);
let _ = assert_success_envelope(&feed);
let body = feed
.get("structuredContent")
.expect("changes_since missing structuredContent");
let changes = body
.get("changes")
.and_then(Value::as_array)
.expect("changes_since missing changes[]");
let renames: Vec<&Value> = changes
.iter()
.filter(|ev| ev.get("action").and_then(Value::as_str) == Some("renamed"))
.collect();
assert_eq!(
renames.len(),
1,
"wide-window changes_since must surface exactly one renamed event; \
got {}. changes={:#?}",
renames.len(),
changes,
);
let only_rename = renames[0];
assert_eq!(
only_rename.get("from_id").and_then(Value::as_str),
Some(rename_id.as_str()),
"renamed.from_id drifted: {only_rename}",
);
assert_eq!(
only_rename.get("to_id").and_then(Value::as_str),
Some(new_id.as_str()),
"renamed.to_id drifted: {only_rename}",
);
for ev in changes {
let action = ev.get("action").and_then(Value::as_str).unwrap_or_default();
if action == "renamed" {
continue;
}
let id = ev.get("id").and_then(Value::as_str).unwrap_or_default();
let from_id = ev
.get("from_id")
.and_then(Value::as_str)
.unwrap_or_default();
let to_id = ev.get("to_id").and_then(Value::as_str).unwrap_or_default();
assert_ne!(id, other_b.as_str(), "other_b mispaired: {ev}");
assert_ne!(from_id, other_b.as_str(), "other_b as rename source: {ev}");
assert_ne!(to_id, other_b.as_str(), "other_b as rename target: {ev}");
}
}
#[test]
fn full_memstead_changes_since_include_notes_false_strips_notes_and_memstead_ref() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let _ = create_and_get_id_hash(&mut harness, "Noteless");
let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let result = harness.call_tool(
"memstead_changes_since",
json!({ "mem": "demo", "since": empty_tree, "include_notes": false }),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing");
assert!(
body.get("notes").is_none(),
"include_notes: false must strip notes[] from the wire: {body}",
);
assert!(
body.get("memstead_ref").is_none(),
"include_notes: false must strip memstead_ref from the wire: {body}",
);
}
#[test]
fn full_memstead_entity_returns_structured_envelope_alongside_markdown() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let (id, hash) = create_and_get_id_hash(&mut harness, "Structured Subject");
let result = harness.call_tool("memstead_entity", json!({ "id": id }));
let _ = assert_success_envelope(&result);
let text = result
.get("content")
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|c| c.get("text"))
.and_then(Value::as_str)
.expect("entity response missing text-channel markdown");
assert!(
text.contains("# Structured Subject"),
"text channel must carry rendered markdown: {text}",
);
let body = result
.get("structuredContent")
.expect("memstead_entity must populate structured_content");
assert_eq!(
body.get("_hash").and_then(Value::as_str),
Some(hash.as_str()),
"structured._hash must match the create response's content_hash: {body}",
);
assert_eq!(body.get("id").and_then(Value::as_str), Some(id.as_str()),);
assert_eq!(body.get("mem").and_then(Value::as_str), Some("demo"),);
assert_eq!(
body.get("type").and_then(Value::as_str),
Some("spec"),
"structured.type drifted: {body}",
);
assert!(
body.get("sections").and_then(Value::as_object).is_some(),
"structured.sections must be a JSON object: {body}",
);
assert!(
body.get("relationships")
.and_then(Value::as_array)
.is_some(),
"structured.relationships must be a JSON array: {body}",
);
assert!(
body.get("_tokens").and_then(Value::as_u64).is_some(),
"structured._tokens must be a non-negative integer: {body}",
);
}
#[test]
fn full_memstead_search_returns_structured_envelope_alongside_markdown() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let _ = create_and_get_id_hash(&mut harness, "Authorization Flow");
let _ = create_and_get_id_hash(&mut harness, "Anchor Memo");
let result = harness.call_tool("memstead_search", json!({ "query": { "any": ["Anchor"] } }));
let _ = assert_success_envelope(&result);
let text = result
.get("content")
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|c| c.get("text"))
.and_then(Value::as_str)
.expect("search response missing text-channel markdown");
assert!(
text.contains("_total:"),
"text channel must carry rendered markdown frontmatter: {text}",
);
let body = result
.get("structuredContent")
.expect("memstead_search must populate structured_content");
assert!(
body.get("_total").and_then(Value::as_u64).is_some(),
"structured._total must be present: {body}",
);
assert!(
body.get("_returned").and_then(Value::as_u64).is_some(),
"structured._returned must be present: {body}",
);
assert!(
body.get("_offset").and_then(Value::as_u64).is_some(),
"structured._offset must be present: {body}",
);
assert!(
body.get("_total_tokens").and_then(Value::as_u64).is_some(),
"structured._total_tokens must be present: {body}",
);
let hits = body
.get("hits")
.and_then(Value::as_array)
.expect("structured.hits must be an array");
assert!(!hits.is_empty(), "expected ≥1 hit: {body}");
let hit = &hits[0];
assert!(
hit.get("id").and_then(Value::as_str).is_some(),
"hit.id missing: {hit}",
);
assert!(
hit.get("score").and_then(Value::as_f64).is_some(),
"hit.score must be a float (no precision loss vs engine f32): {hit}",
);
}
#[test]
fn full_memstead_entity_structured_relationships_carry_typed_shape() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let (from, _) = create_and_get_id_hash(&mut harness, "Rel Source");
let (to, _) = create_and_get_id_hash(&mut harness, "Rel Target");
let _ = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": from, "to": to, "type": "PART_OF" }] }),
);
let result = harness.call_tool("memstead_entity", json!({ "id": from }));
let body = result
.get("structuredContent")
.expect("missing structured_content");
let relationships = body
.get("relationships")
.and_then(Value::as_array)
.expect("structured.relationships must be an array");
assert!(
!relationships.is_empty(),
"expected ≥1 relationship after relate: {body}",
);
let rel = &relationships[0];
assert_eq!(rel.get("rel_type").and_then(Value::as_str), Some("PART_OF"),);
assert_eq!(rel.get("target").and_then(Value::as_str), Some(to.as_str()),);
assert_eq!(
rel.get("source").and_then(Value::as_str),
Some("explicit"),
"structured.relationships[].source pinned to `explicit`: {rel}",
);
}
#[test]
fn full_memstead_changes_since_include_notes_true_carries_notes_and_rename_note() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let (id, hash) = create_and_get_id_hash(&mut harness, "Renaming Subject");
let renamed = harness.call_tool(
"memstead_rename",
json!({
"id": id,
"new_title": "After Rename",
"expected_hash": hash,
}),
);
let _ = assert_success_envelope(&renamed);
let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let feed = harness.call_tool(
"memstead_changes_since",
json!({ "mem": "demo", "since": empty_tree, "include_notes": true }),
);
let _ = assert_success_envelope(&feed);
let body = feed
.get("structuredContent")
.expect("structuredContent missing");
let notes = body
.get("notes")
.and_then(Value::as_array)
.expect("include_notes: true must surface notes[]");
assert!(
notes
.iter()
.any(|n| { n.get("tool_verb").and_then(Value::as_str) == Some("rename") }),
"rename note missing from notes[]: {body}",
);
}
#[test]
fn full_auto_stub_then_update_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_rename_stub_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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 full_relate_from_stub_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
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}"
);
}
#[test]
fn full_memstead_mem_create_returns_typed_success_envelope() {
const WORKSPACE_TOML_WITH_CREATE_RULE: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("demo", "default@1.0.0")],
WORKSPACE_TOML_WITH_CREATE_RULE,
);
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool(
"memstead_mem_create",
json!({
"name": "fresh",
"location": "mems/fresh",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&result);
let body = result
.get("structuredContent")
.expect("structuredContent missing on mem_create success");
assert!(
body.get("name").is_some() || body.get("mem").is_some(),
"mem_create response missing name/mem: {body}"
);
}
#[test]
fn full_memstead_mem_delete_returns_typed_success_envelope() {
const WORKSPACE_TOML_WITH_LIFECYCLE_RULES: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n\
\n\
[[mem_management.delete]]\n\
pattern = \"*\"\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("demo", "default@1.0.0")],
WORKSPACE_TOML_WITH_LIFECYCLE_RULES,
);
let mut harness = WireHarness::start(tmp.path());
let create = harness.call_tool(
"memstead_mem_create",
json!({
"name": "ephemeral",
"location": "mems/ephemeral",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&create);
let del = harness.call_tool("memstead_mem_delete", json!({ "name": "ephemeral" }));
let _ = assert_success_envelope(&del);
assert!(
del.get("structuredContent").is_some(),
"mem_delete response missing structuredContent: {del}"
);
}
#[test]
fn full_mem_delete_preserves_allowlist_rules_so_recreate_succeeds() {
const WORKSPACE_TOML: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
[cross_mem_links]\n\
ephemeral = [\"demo\"]\n\
\n\
[[mem_management.create]]\n\
pattern = \"ephemeral\"\n\
schemas = [\"default@1.0.0\"]\n\
\n\
[[mem_management.delete]]\n\
pattern = \"ephemeral\"\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(tmp.path(), &[("demo", "default@1.0.0")], WORKSPACE_TOML);
let mut harness = WireHarness::start(tmp.path());
let create = harness.call_tool(
"memstead_mem_create",
json!({
"name": "ephemeral",
"location": "mems/ephemeral",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&create);
let del = harness.call_tool("memstead_mem_delete", json!({ "name": "ephemeral" }));
let _ = assert_success_envelope(&del);
let after =
std::fs::read_to_string(tmp.path().join(".memstead").join("workspace.toml")).unwrap();
assert_eq!(
after.matches("pattern = \"ephemeral\"").count(),
2,
"delete must preserve the create+delete allowlist rules; got:\n{after}",
);
assert!(
!after.contains("ephemeral = [\"demo\"]"),
"delete must scrub the deleted mem's dangling cross-link grant; got:\n{after}",
);
let recreate = harness.call_tool(
"memstead_mem_create",
json!({
"name": "ephemeral",
"location": "mems/ephemeral",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&recreate);
}
#[test]
fn full_operator_mode_bypasses_empty_allowlist_via_mcp() {
const WORKSPACE_TOML_NO_RULES: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("demo", "default@1.0.0")],
WORKSPACE_TOML_NO_RULES,
);
{
let mut harness = WireHarness::start(tmp.path());
let agent_attempt = harness.call_tool(
"memstead_mem_create",
json!({
"name": "fresh",
"location": "mems/fresh",
"schema": "default@1.0.0",
}),
);
let is_error = agent_attempt
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(
is_error,
"agent-mode create against empty allowlist must error: {agent_attempt}"
);
let structured = agent_attempt
.get("structuredContent")
.expect("structuredContent missing on agent-mode envelope");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("MEM_PATH_NOT_ALLOWED"),
"agent-mode rejection must carry MEM_PATH_NOT_ALLOWED: {structured}"
);
assert_eq!(
structured
.get("details")
.and_then(|d| d.get("reason"))
.and_then(Value::as_str),
Some("no_allowlist_configured"),
"details.reason drifted: {structured}"
);
}
{
let mut harness = WireHarness::start_with_args(tmp.path(), &["--operator-mode"]);
let create = harness.call_tool(
"memstead_mem_create",
json!({
"name": "fresh",
"location": "mems/fresh",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&create);
let del = harness.call_tool("memstead_mem_delete", json!({ "name": "fresh" }));
let _ = assert_success_envelope(&del);
}
}
#[test]
fn full_memstead_overview_surfaces_operator_mode_bypass() {
const WORKSPACE_TOML: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(tmp.path(), &[("demo", "default@1.0.0")], WORKSPACE_TOML);
{
let mut harness = WireHarness::start(tmp.path());
let overview = harness.call_tool("memstead_overview", json!({}));
let text = assert_success_envelope(&overview);
assert!(
!text.contains("--operator-mode"),
"agent-mode overview must NOT mention operator-mode: {text}"
);
}
{
let mut harness = WireHarness::start_with_args(tmp.path(), &["--operator-mode"]);
let overview = harness.call_tool("memstead_overview", json!({}));
let text = assert_success_envelope(&overview);
assert!(
text.contains("--operator-mode"),
"operator-mode overview must mention the flag: {text}"
);
assert!(
text.contains("MEM_REFERENCED_BY_POLICY"),
"operator-mode overview must name the bypassed safeguard: {text}"
);
}
}
#[test]
fn full_memstead_mem_create_writes_refs_heads_branch_in_mounts_json() {
const WORKSPACE_TOML_WITH_CREATE_RULE: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n\
\n\
[[mem_management.create]]\n\
pattern = \"namespace/*\"\n\
schemas = [\"default@1.0.0\"]\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("demo", "default@1.0.0")],
WORKSPACE_TOML_WITH_CREATE_RULE,
);
let mut harness = WireHarness::start(tmp.path());
let flat = harness.call_tool(
"memstead_mem_create",
json!({
"name": "fresh",
"location": "mems/fresh",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&flat);
let hier = harness.call_tool(
"memstead_mem_create",
json!({
"name": "namespace/scoped",
"location": "mems/scoped",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&hier);
let mounts_json_path = tmp
.path()
.join(".memstead")
.join("state")
.join("mounts.json");
let on_disk = std::fs::read_to_string(&mounts_json_path)
.expect("mounts.json must exist after mem_create");
assert!(
on_disk.contains("\"branch\": \"refs/heads/fresh\""),
"flat-layout mem must persist refs/heads/<name>, got: {on_disk}"
);
assert!(
on_disk.contains("\"branch\": \"refs/heads/namespace/scoped\""),
"hierarchical mem must persist refs/heads/<full-name>, got: {on_disk}"
);
assert!(
on_disk.contains("\"mem\": \"namespace/scoped\""),
"hierarchical mem identity is the full path in mounts.json, got: {on_disk}"
);
}
#[test]
fn full_memstead_relate_with_forbidden_description_emits_typed_envelope() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let (from, _) = create_and_get_id_hash(&mut harness, "Forbid Source");
let (to, _) = create_and_get_id_hash(&mut harness, "Forbid Target");
let result = harness.call_tool(
"memstead_relate",
json!({ "relations": [{ "from": from,
"to": to,
"type": "REFERENCES",
"description": "should be refused" }] }),
);
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(
is_error,
"expected isError=true on description-forbidden relate: {result}",
);
let structured = result
.get("structuredContent")
.expect("structuredContent missing on description-forbidden relate");
assert_eq!(
structured.get("code").and_then(Value::as_str),
Some("DESCRIPTION_NOT_PERMITTED"),
"wire code regressed to non-typed: {structured}",
);
let details = structured
.get("details")
.expect("DESCRIPTION_NOT_PERMITTED must carry details");
assert_eq!(
details.get("rel_type").and_then(Value::as_str),
Some("REFERENCES"),
"details.rel_type drifted: {details}",
);
assert_eq!(
details.get("from_id").and_then(Value::as_str),
Some(from.as_str()),
"details.from_id drifted: {details}",
);
assert_eq!(
details.get("to_id").and_then(Value::as_str),
Some(to.as_str()),
"details.to_id drifted: {details}",
);
}
#[test]
fn full_memstead_update_body_wikilink_auto_synthesises_alias_relation() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("demo", "default@1.0.0")]);
let mut harness = WireHarness::start(tmp.path());
let (source, source_hash) = create_and_get_id_hash(&mut harness, "WikiSource");
let (target, _) = create_and_get_id_hash(&mut harness, "WikiTarget");
let result = harness.call_tool(
"memstead_update",
json!({
"id": source,
"expected_hash": source_hash,
"sections": {
"identity": format!("see [[{target}]] for context"),
},
}),
);
let is_error = result
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false);
assert!(
!is_error,
"alias-synthesis must satisfy the validator and let the body land: {result}",
);
let entity = harness.call_tool("memstead_entity", json!({ "id": source }));
let relationships = entity
.get("structuredContent")
.and_then(|sc| sc.get("relationships"))
.and_then(Value::as_array)
.expect("relationships[] missing from structured envelope");
let has_ref = relationships.iter().any(|r| {
r.get("rel_type").and_then(Value::as_str) == Some("REFERENCES")
&& r.get("target").and_then(Value::as_str) == Some(target.as_str())
});
assert!(
has_ref,
"REFERENCES → target must surface in relationships[]; got {relationships:?}",
);
}
const TIER_C_WORKSPACE_TOML: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
\n\
[[mem_management.create]]\n\
pattern = \"*\"\n\
schemas = [\"default@1.0.0\"]\n\
\n\
[[mem_management.delete]]\n\
pattern = \"*\"\n\
";
#[test]
fn full_memstead_workspace_grant_cross_link_round_trip() {
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("source", "default@1.0.0"), ("target", "default@1.0.0")],
TIER_C_WORKSPACE_TOML,
);
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool(
"memstead_workspace_grant_cross_link",
json!({ "from": "source", "to": "target" }),
);
let _ = assert_success_envelope(&result);
let body =
std::fs::read_to_string(tmp.path().join(".memstead").join("workspace.toml")).unwrap();
assert!(
body.contains("[cross_mem_links]"),
"grant must write the cross_mem_links section; got:\n{body}",
);
assert!(
body.contains("source = [\"target\"]"),
"grant must record the source → [target] entry; got:\n{body}",
);
}
#[test]
fn full_memstead_workspace_grant_cross_link_idempotent_with_warning() {
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("source", "default@1.0.0"), ("target", "default@1.0.0")],
TIER_C_WORKSPACE_TOML,
);
let mut harness = WireHarness::start(tmp.path());
let _ = harness.call_tool(
"memstead_workspace_grant_cross_link",
json!({ "from": "source", "to": "target" }),
);
let body_before =
std::fs::read_to_string(tmp.path().join(".memstead").join("workspace.toml")).unwrap();
let result = harness.call_tool(
"memstead_workspace_grant_cross_link",
json!({ "from": "source", "to": "target" }),
);
let text = assert_success_envelope(&result);
let body_after =
std::fs::read_to_string(tmp.path().join(".memstead").join("workspace.toml")).unwrap();
assert_eq!(
body_before, body_after,
"duplicate grant must not rewrite the file",
);
let structured = result
.get("structuredContent")
.expect("structuredContent missing");
let warnings = structured
.get("warnings")
.and_then(Value::as_array)
.expect("warnings array missing");
assert!(
warnings
.iter()
.any(|w| w.get("code").and_then(Value::as_str) == Some("GRANT_ALREADY_PRESENT")),
"duplicate grant must emit GRANT_ALREADY_PRESENT in the warnings array; got:\n{structured}\n(text: {text})",
);
}
#[test]
fn full_memstead_workspace_revoke_cross_link_idempotent_when_absent() {
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("source", "default@1.0.0"), ("target", "default@1.0.0")],
TIER_C_WORKSPACE_TOML,
);
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool(
"memstead_workspace_revoke_cross_link",
json!({ "from": "source", "to": "target" }),
);
let _ = assert_success_envelope(&result);
let structured = result
.get("structuredContent")
.expect("structuredContent missing");
let warnings = structured
.get("warnings")
.and_then(Value::as_array)
.expect("warnings array missing");
assert!(
warnings
.iter()
.any(|w| w.get("code").and_then(Value::as_str) == Some("GRANT_NOT_FOUND")),
"no-op revoke must emit GRANT_NOT_FOUND in the warnings array; got:\n{structured}",
);
}
#[test]
fn full_memstead_workspace_allow_create_round_trip() {
const EMPTY_TOML: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(tmp.path(), &[("seed", "default@1.0.0")], EMPTY_TOML);
let mut harness = WireHarness::start(tmp.path());
let result = harness.call_tool(
"memstead_workspace_allow_create",
json!({
"pattern": "exec-*",
"schemas": ["default@1.0.0"],
}),
);
let _ = assert_success_envelope(&result);
let body =
std::fs::read_to_string(tmp.path().join(".memstead").join("workspace.toml")).unwrap();
assert!(
body.contains("[[mem_management.create]]"),
"allow_create must write the section header; got:\n{body}",
);
assert!(
body.contains("pattern = \"exec-*\""),
"allow_create must record the pattern; got:\n{body}",
);
}
#[test]
fn full_allow_create_differing_schemas_refused_stored_unchanged() {
const EMPTY_TOML: &str = "\
format = \"memstead-git-branch-2\"\n\
\n\
[persistence_adapter]\n\
name = \"file-two-layer\"\n\
";
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(tmp.path(), &[("seed", "default@1.0.0")], EMPTY_TOML);
let mut harness = WireHarness::start(tmp.path());
let first = harness.call_tool(
"memstead_workspace_allow_create",
json!({ "pattern": "scratch", "schemas": ["software@0.1.0"] }),
);
let _ = assert_success_envelope(&first);
let differ = harness.call_tool(
"memstead_workspace_allow_create",
json!({ "pattern": "scratch", "schemas": ["nonexistent@9.9.9"] }),
);
assert!(
differ
.get("isError")
.and_then(Value::as_bool)
.unwrap_or(false),
"differing-schemas re-add must be an error envelope: {differ}",
);
let structured = differ
.get("structuredContent")
.expect("structured payload present");
assert_eq!(
structured["code"], "RULE_EXISTS_SCHEMAS_DIFFER",
"payload: {structured}"
);
assert_eq!(
structured["details"]["stored_schemas"],
json!(["software@0.1.0"]),
"refusal names the stored schemas: {structured}",
);
assert_eq!(
structured["details"]["requested_schemas"],
json!(["nonexistent@9.9.9"]),
"refusal names the requested schemas: {structured}",
);
assert!(
structured["details"]["recovery"]
.as_str()
.is_some_and(|s| s.contains("revoke")),
"refusal points at the revoke-then-readd recovery: {structured}",
);
let body =
std::fs::read_to_string(tmp.path().join(".memstead").join("workspace.toml")).unwrap();
assert!(
body.contains("software@0.1.0"),
"stored pins stay put; got:\n{body}"
);
assert!(
!body.contains("nonexistent@9.9.9"),
"rejected pins not written; got:\n{body}"
);
let same = harness.call_tool(
"memstead_workspace_allow_create",
json!({ "pattern": "scratch", "schemas": ["software@0.1.0"] }),
);
let _ = assert_success_envelope(&same);
}
#[test]
fn full_f7_dynamic_mem_lifecycle_completes_via_mcp_only() {
let tmp = TempDir::new().unwrap();
seed_full_workspace_with_toml(
tmp.path(),
&[("source", "default@1.0.0")],
TIER_C_WORKSPACE_TOML,
);
let mut harness = WireHarness::start_with_args(tmp.path(), &["--operator-mode"]);
let create = harness.call_tool(
"memstead_mem_create",
json!({
"name": "target",
"location": "mems/target",
"schema": "default@1.0.0",
}),
);
let _ = assert_success_envelope(&create);
let grant = harness.call_tool(
"memstead_workspace_grant_cross_link",
json!({ "from": "source", "to": "target" }),
);
let _ = assert_success_envelope(&grant);
let revoke = harness.call_tool(
"memstead_workspace_revoke_cross_link",
json!({ "from": "source", "to": "target" }),
);
let _ = assert_success_envelope(&revoke);
let delete = harness.call_tool("memstead_mem_delete", json!({ "name": "target" }));
let _ = assert_success_envelope(&delete);
}
#[test]
fn friction_ledger_records_both_surfaces_and_serves_the_axis() {
let tmp = TempDir::new().unwrap();
seed_empty_workspace(tmp.path());
let ledger_path = tmp
.path()
.join(".memstead")
.join("state")
.join("friction")
.join("refusals.jsonl");
let entries = |path: &Path| -> Vec<Value> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str(l).expect("every ledger line parses"))
.collect()
};
let cli_bin = Path::new(memstead_mcp_bin())
.parent()
.expect("binary has a parent dir")
.join("memstead");
assert!(
cli_bin.exists(),
"memstead CLI binary not built — run the workspace test surface (run-tests.sh)"
);
let mut harness = WireHarness::start(tmp.path());
let refused = harness.call_tool(
"memstead_entity",
json!({ "id": "ghost--entity", "sections": [] }),
);
assert_eq!(refused["isError"], true, "{refused}");
let after_mcp = entries(&ledger_path);
assert_eq!(after_mcp.len(), 1, "one entry per refused MCP call");
assert_eq!(after_mcp[0]["surface"], "mcp");
assert_eq!(after_mcp[0]["verb"], "memstead_entity");
assert_eq!(
after_mcp[0]["code"], refused["structuredContent"]["code"],
"ledger code matches the served refusal"
);
assert!(after_mcp[0]["ts"].as_u64().unwrap() > 0);
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args(["--json", "entity", "ghost--entity"])
.output()
.expect("run memstead CLI");
assert!(!out.status.success(), "CLI fixture call must refuse");
let after_cli = entries(&ledger_path);
assert_eq!(after_cli.len(), 2, "one entry per refused CLI call");
assert_eq!(after_cli[1]["surface"], "cli");
assert_eq!(after_cli[1]["verb"], "entity");
let ok = harness.call_tool("memstead_health", json!({}));
assert!(ok["isError"] != true, "{ok}");
let ok_cli = Command::new(&cli_bin)
.current_dir(tmp.path())
.args(["--json", "health"])
.output()
.expect("run memstead CLI");
assert!(ok_cli.status.success());
assert_eq!(
entries(&ledger_path).len(),
2,
"successful calls append nothing"
);
let served = harness.call_tool("memstead_health", json!({ "include": ["friction"] }));
assert!(served["isError"] != true, "{served}");
let axis = &served["structuredContent"]["friction"];
assert_eq!(axis["total"], 2, "{served}");
assert_eq!(axis["by_verb"]["mcp:memstead_entity"], 1);
assert_eq!(axis["by_verb"]["cli:entity"], 1);
assert_eq!(axis["recent_24h"]["total"], 2);
}
#[test]
fn negative_finding_writes_on_both_surfaces_and_is_leaf_exempt() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("proc", "ingest@0.5.0")]);
let mut harness = WireHarness::start(tmp.path());
let ok = harness.call_tool(
"memstead_create",
json!({
"title": "No rollback runbook in the source tree",
"entity_type": "negative_finding",
"mem": "proc",
"sections": {
"sought": "A rollback runbook for failed deploys.",
"search_path": "Full read of docs/ops; grep for rollback and revert across docs/.",
"finding": "Nothing — deploys are documented forward-only."
}
}),
);
assert!(
ok["isError"] != true,
"legal negative_finding must land: {ok}"
);
assert_eq!(
ok["structuredContent"]["id"], "proc--no-rollback-runbook-in-the-source-tree",
"{ok}"
);
let missing = harness.call_tool(
"memstead_create",
json!({
"title": "Half a finding",
"entity_type": "negative_finding",
"mem": "proc",
"sections": { "sought": "Something." }
}),
);
assert_eq!(missing["isError"], true, "{missing}");
assert_eq!(
missing["structuredContent"]["code"], "MISSING_REQUIRED_SECTION",
"{missing}"
);
let cli_bin = Path::new(memstead_mcp_bin())
.parent()
.expect("binary has a parent dir")
.join("memstead");
assert!(cli_bin.exists(), "memstead CLI binary not built");
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"create",
"--mem",
"proc",
"--title",
"No SLA stated for the batch queue",
"--type",
"negative_finding",
"--section",
"sought=A latency or delivery SLA for the batch queue.",
"--section",
"search_path=Skim of the queue chapter; grep for SLA and latency across docs/.",
"--section",
"finding=Nothing — the queue is documented without service guarantees.",
])
.output()
.expect("run memstead CLI");
assert!(
out.status.success(),
"legal CLI negative_finding must land: {}",
String::from_utf8_lossy(&out.stdout)
);
let bad = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"create",
"--mem",
"proc",
"--title",
"Bad finding",
"--type",
"negative_finding",
"--section",
"sought=X.",
"--section",
"search_path=Y.",
"--section",
"finding=Z.",
"--section",
"bogus_section=nope",
])
.output()
.expect("run memstead CLI");
assert!(!bad.status.success());
let body: Value = serde_json::from_slice(&bad.stdout).expect("CLI --json refusal parses");
assert_eq!(body["code"], "UNKNOWN_SECTION", "{body}");
let health = harness.call_tool("memstead_health", json!({ "include": ["orphans"] }));
assert!(health["isError"] != true, "{health}");
let orphans = serde_json::to_string(&health["structuredContent"]["orphans"]).unwrap();
assert!(
!orphans.contains("no-rollback-runbook") && !orphans.contains("no-sla-stated"),
"leaf-typed negative findings must not appear as orphans: {orphans}"
);
let leaf = &health["structuredContent"]["leaf_entities_by_type"];
assert_eq!(leaf["ingest@0.5.0:negative_finding"], 2, "{health}");
}
#[test]
fn open_questions_axis_is_include_gated_and_refuses_unknown_mem_typed() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("specs", "default@1.3.0")]);
let mut harness = WireHarness::start(tmp.path());
let plain = harness.call_tool("memstead_health", json!({}));
assert!(plain["isError"] != true, "{plain}");
assert!(
plain["structuredContent"].get("open_questions").is_none(),
"axis must be include-gated: {plain}"
);
let served = harness.call_tool("memstead_health", json!({ "include": ["open_questions"] }));
assert!(served["isError"] != true, "{served}");
let axis = &served["structuredContent"]["open_questions"];
assert_eq!(axis["_item_cap"], 20, "{served}");
assert_eq!(axis["specs"]["total_open"], 0, "{served}");
assert_eq!(axis["specs"]["stubs"]["count"], 0);
assert!(
!serde_json::to_string(&served)
.unwrap()
.contains("\"INTERNAL\""),
"no leaf of the axis is INTERNAL: {served}"
);
let ghost = harness.call_tool(
"memstead_health",
json!({ "include": ["open_questions"], "mem": "ghost" }),
);
assert_eq!(ghost["isError"], true, "{ghost}");
assert_eq!(ghost["structuredContent"]["code"], "UNKNOWN_MEM", "{ghost}");
}
#[test]
fn stale_derivations_axis_is_include_gated_and_refuses_unknown_mem_typed() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("specs", "default@1.3.0")]);
let mut harness = WireHarness::start(tmp.path());
let plain = harness.call_tool("memstead_health", json!({}));
assert!(plain["isError"] != true, "{plain}");
assert!(
plain["structuredContent"]
.get("stale_derivations")
.is_none(),
"axis must be include-gated: {plain}"
);
let served = harness.call_tool(
"memstead_health",
json!({ "include": ["stale_derivations"] }),
);
assert!(served["isError"] != true, "{served}");
let axis = &served["structuredContent"]["stale_derivations"];
assert_eq!(
axis["specs"],
json!([]),
"undeclared schema → empty list: {served}"
);
assert!(
!serde_json::to_string(&served)
.unwrap()
.contains("\"INTERNAL\""),
"no leaf is INTERNAL: {served}"
);
let ghost = harness.call_tool(
"memstead_health",
json!({ "include": ["stale_derivations"], "mem": "ghost" }),
);
assert_eq!(ghost["isError"], true, "{ghost}");
assert_eq!(ghost["structuredContent"]["code"], "UNKNOWN_MEM", "{ghost}");
}
#[test]
fn checks_health_axis_serves_unconfirmable_without_caller_identity() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("specs", "default@1.3.0")]);
let mut harness = WireHarness::start(tmp.path());
let cli_bin = Path::new(memstead_mcp_bin())
.parent()
.expect("binary has a parent dir")
.join("memstead");
for title in [
"Alpha Claim",
"Beta Claim",
"Gamma Claim",
"Delta Claim",
"Epsilon Claim",
"Zeta Claim",
] {
let created = harness.call_tool(
"memstead_create",
json!({
"title": title,
"entity_type": "spec",
"mem": "specs",
"sections": { "identity": "I.", "purpose": "P." },
"role": "author"
}),
);
assert!(created["isError"] != true, "{created}");
}
let r = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--alpha-claim", "verdict": "ok", "role": "checker" }),
);
assert!(r["isError"] != true, "{r}");
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"--role",
"checker",
"check",
"specs--beta-claim",
"--verdict",
"ok",
])
.output()
.expect("run memstead CLI check");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
let r = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--gamma-claim", "verdict": "ok" }),
);
assert!(r["isError"] != true, "{r}");
let r = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--epsilon-claim", "verdict": "ok", "role": "checker" }),
);
assert!(r["isError"] != true, "{r}");
let read = harness.call_tool("memstead_entity", json!({ "id": "specs--epsilon-claim" }));
let eps_hash = read["structuredContent"]["_hash"]
.as_str()
.unwrap()
.to_string();
let r = harness.call_tool(
"memstead_update",
json!({
"id": "specs--epsilon-claim",
"expected_hash": eps_hash,
"sections": { "purpose": "P2." },
"role": "author"
}),
);
assert!(r["isError"] != true, "{r}");
let r = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--zeta-claim", "verdict": "failed", "role": "checker" }),
);
assert!(r["isError"] != true, "{r}");
let health = harness.call_tool("memstead_health", json!({ "include": ["checks"] }));
assert!(health["isError"] != true, "{health}");
let axis = &health["structuredContent"]["checks"]["specs"];
assert_eq!(axis["checked_ok"], 3, "{axis}");
assert_eq!(axis["check_stale"], 1, "{axis}");
assert_eq!(axis["check_failed"], 1, "{axis}");
assert!(axis["never_checked"].as_u64().unwrap() >= 1, "{axis}");
let gate = &axis["independence"];
assert_eq!(gate["self_checked"]["items"], json!([]), "{gate}");
assert_eq!(gate["confirmed_independent"]["items"], json!([]), "{gate}");
assert_eq!(
gate["unconfirmable"]["items"],
json!([
"specs--alpha-claim",
"specs--beta-claim",
"specs--gamma-claim"
]),
"{gate}"
);
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args(["--json", "health", "--include", "checks"])
.output()
.expect("run memstead CLI health");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(
v["checks"]["specs"]["independence"]["unconfirmable"]["items"],
json!([
"specs--alpha-claim",
"specs--beta-claim",
"specs--gamma-claim"
]),
"{v}"
);
}
#[test]
fn check_operation_records_derives_state_and_mutates_nothing() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("specs", "default@1.3.0")]);
let mut harness = WireHarness::start(tmp.path());
let cli_bin = Path::new(memstead_mcp_bin())
.parent()
.expect("binary has a parent dir")
.join("memstead");
assert!(cli_bin.exists(), "memstead CLI binary not built");
let created = harness.call_tool(
"memstead_create",
json!({
"title": "Checked Claim",
"entity_type": "spec",
"mem": "specs",
"sections": { "identity": "I.", "purpose": "P." },
"role": "author"
}),
);
assert!(created["isError"] != true, "{created}");
let hash = created["structuredContent"]["_hash"]
.as_str()
.unwrap()
.to_string();
let commit_before = created["structuredContent"]["commit_sha"]
.as_str()
.unwrap()
.to_string();
let read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--checked-claim", "include_provenance": true }),
);
assert_eq!(
read["structuredContent"]["mutation_provenance"]["check_state"], "never_checked",
"{read}"
);
let bad = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--checked-claim", "verdict": "passed" }),
);
assert_eq!(bad["isError"], true, "{bad}");
assert_eq!(bad["structuredContent"]["code"], "INVALID_VERDICT", "{bad}");
assert!(
serde_json::to_string(&bad["structuredContent"]["details"]["allowed"])
.unwrap()
.contains("failed"),
"vocabulary named: {bad}"
);
let missing = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--no-such-entity", "verdict": "ok" }),
);
assert_eq!(missing["isError"], true, "{missing}");
assert_eq!(
missing["structuredContent"]["code"], "ENTITY_NOT_FOUND",
"{missing}"
);
let checked = harness.call_tool(
"memstead_check",
json!({
"entity": "specs--checked-claim",
"verdict": "ok",
"method": "diffed against source spec",
"role": "checker"
}),
);
assert!(checked["isError"] != true, "{checked}");
assert_eq!(checked["structuredContent"]["check_state"], "checked_ok");
assert_eq!(checked["structuredContent"]["role"], "checker");
let read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--checked-claim", "include_provenance": true }),
);
let sc = &read["structuredContent"];
assert_eq!(
sc["_hash"].as_str().unwrap(),
hash,
"check must not touch _hash"
);
assert_eq!(
sc["mutation_provenance"]["check_state"], "checked_ok",
"{sc}"
);
let last = &sc["mutation_provenance"]["last_check"];
assert_eq!(last["verdict"], "ok");
assert_eq!(last["role"], "checker");
assert_eq!(last["method"], "diffed against source spec");
let gitdir = tmp.path().join("mem-repo").join(".git");
let head = Command::new("git")
.args([
"--git-dir",
gitdir.to_str().unwrap(),
"rev-parse",
"refs/heads/specs",
])
.output()
.expect("git rev-parse");
assert_eq!(
String::from_utf8_lossy(&head.stdout).trim(),
commit_before,
"a check must not produce a mem commit"
);
let updated = harness.call_tool(
"memstead_update",
json!({
"id": "specs--checked-claim",
"expected_hash": hash,
"sections": { "purpose": "P2." },
"role": "author"
}),
);
assert!(updated["isError"] != true, "{updated}");
let read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--checked-claim", "include_provenance": true }),
);
assert_eq!(
read["structuredContent"]["mutation_provenance"]["check_state"], "check_stale",
"{read}"
);
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"--role",
"verifier",
"check",
"specs--checked-claim",
"--verdict",
"ok",
])
.output()
.expect("run memstead CLI check");
assert!(
out.status.success(),
"CLI check must land: {}",
String::from_utf8_lossy(&out.stdout)
);
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["check_state"], "checked_ok", "{v}");
assert_eq!(v["role"], "verifier");
let read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--checked-claim", "include_provenance": true }),
);
assert_eq!(
read["structuredContent"]["mutation_provenance"]["check_state"], "checked_ok",
"{read}"
);
let failed = harness.call_tool(
"memstead_check",
json!({ "entity": "specs--checked-claim", "verdict": "failed", "role": "checker" }),
);
assert!(failed["isError"] != true, "{failed}");
assert_eq!(failed["structuredContent"]["check_state"], "check_failed");
let ledger = std::fs::read_to_string(
tmp.path()
.join(".memstead")
.join("state")
.join("checks")
.join("checks.jsonl"),
)
.expect("check ledger exists");
assert_eq!(
ledger.lines().count(),
3,
"append-only: every check kept: {ledger}"
);
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"check",
"specs--checked-claim",
"--verdict",
"maybe",
])
.output()
.expect("run memstead CLI check");
assert!(!out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["code"], "INVALID_VERDICT", "{v}");
}
#[test]
fn declared_roles_are_recorded_in_append_only_history_on_both_backends() {
let tmp = TempDir::new().unwrap();
seed_full_workspace(tmp.path(), &[("specs", "default@1.3.0")]);
let mut harness = WireHarness::start(tmp.path());
let created = harness.call_tool(
"memstead_create",
json!({
"title": "Derived Conclusion",
"entity_type": "spec",
"mem": "specs",
"sections": { "identity": "I.", "purpose": "P." },
"role": "author"
}),
);
assert!(created["isError"] != true, "{created}");
let hash = created["structuredContent"]["_hash"]
.as_str()
.unwrap()
.to_string();
let bad = harness.call_tool(
"memstead_create",
json!({
"title": "Nope",
"entity_type": "spec",
"mem": "specs",
"sections": { "identity": "I.", "purpose": "P." },
"role": "reviewer"
}),
);
assert_eq!(bad["isError"], true, "{bad}");
assert_eq!(bad["structuredContent"]["code"], "INVALID_ROLE", "{bad}");
assert!(
serde_json::to_string(&bad["structuredContent"]["details"]["allowed"])
.unwrap()
.contains("checker"),
"vocabulary named: {bad}"
);
let cli_bin = Path::new(memstead_mcp_bin())
.parent()
.expect("binary has a parent dir")
.join("memstead");
assert!(cli_bin.exists(), "memstead CLI binary not built");
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"--role",
"checker",
"update",
"specs--derived-conclusion",
"--expected-hash",
&hash,
"--append",
"purpose= Checked.",
])
.output()
.expect("run memstead CLI");
assert!(
out.status.success(),
"checker update must land: {}",
String::from_utf8_lossy(&out.stdout)
);
let plain = harness.call_tool(
"memstead_create",
json!({
"title": "Plain Entity",
"entity_type": "spec",
"mem": "specs",
"sections": { "identity": "I.", "purpose": "P." }
}),
);
assert!(plain["isError"] != true, "{plain}");
let bad_cli = Command::new(&cli_bin)
.current_dir(tmp.path())
.args(["--json", "--role", "boss", "entity", "specs--plain-entity"])
.output()
.expect("run memstead CLI");
assert!(!bad_cli.status.success());
let v: Value = serde_json::from_slice(&bad_cli.stdout).unwrap();
assert_eq!(v["code"], "INVALID_ROLE", "{v}");
assert!(
v["message"]
.as_str()
.unwrap()
.contains("author, checker, verifier"),
"vocabulary named: {v}"
);
let log = Command::new("git")
.arg("--git-dir")
.arg(tmp.path().join("mem-repo").join(".git"))
.args(["log", "--format=%H%n%B%n---", "refs/heads/specs"])
.output()
.expect("git log");
let log = String::from_utf8_lossy(&log.stdout).to_string();
let commits: Vec<&str> = log.split("\n---").collect();
let author_commit = commits
.iter()
.find(|c| c.contains("create specs--derived-conclusion"))
.expect("create commit present");
assert!(
author_commit.contains("Role: author"),
"author role recorded: {author_commit}"
);
let checker_commit = commits
.iter()
.find(|c| c.contains("update specs--derived-conclusion"))
.expect("update commit present");
assert!(
checker_commit.contains("Role: checker"),
"checker role recorded: {checker_commit}"
);
let plain_commit = commits
.iter()
.find(|c| c.contains("create specs--plain-entity"))
.expect("plain create commit present");
assert!(
!plain_commit.contains("Role:"),
"unspecified role records NO trailer: {plain_commit}"
);
let folder = TempDir::new().unwrap();
let ws = folder.path().join("plainws");
std::fs::create_dir_all(&ws).unwrap();
let ok = Command::new(&cli_bin)
.current_dir(&ws)
.args(["quickstart"])
.output()
.expect("quickstart");
assert!(
ok.status.success(),
"{}",
String::from_utf8_lossy(&ok.stderr)
);
let ok = Command::new(&cli_bin)
.current_dir(&ws)
.args([
"--role",
"verifier",
"create",
"--title",
"Ledger Roled",
"--type",
"memo",
"--section",
"claim=Recorded.",
"--section",
"context=Role test.",
])
.output()
.expect("folder create");
assert!(
ok.status.success(),
"{}",
String::from_utf8_lossy(&ok.stdout)
);
let ok = Command::new(&cli_bin)
.current_dir(&ws)
.args([
"--role",
"checker",
"update",
"plainws--ledger-roled",
"--force",
"--section",
"claim=Checked.",
])
.output()
.expect("folder update");
assert!(
ok.status.success(),
"{}",
String::from_utf8_lossy(&ok.stdout)
);
let ledger =
std::fs::read_to_string(ws.join("plainws").join(".memstead").join("changes.jsonl"))
.or_else(|_| std::fs::read_to_string(ws.join(".memstead").join("changes.jsonl")));
let ledger = match ledger {
Ok(l) => l,
Err(_) => {
let mut found = String::new();
for entry in std::fs::read_dir(&ws).unwrap().flatten() {
let p = entry.path().join(".memstead").join("changes.jsonl");
if p.exists() {
found = std::fs::read_to_string(p).unwrap();
break;
}
}
found
}
};
assert!(
ledger.contains("\"role\":\"verifier\""),
"folder ledger records the create role: {ledger}"
);
assert!(
ledger.contains("\"role\":\"checker\""),
"folder ledger records the update role: {ledger}"
);
let plain_read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--derived-conclusion" }),
);
assert!(plain_read["isError"] != true, "{plain_read}");
assert!(
plain_read["structuredContent"]
.get("mutation_provenance")
.is_none(),
"default entity reads carry no provenance block: {plain_read}"
);
let read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--derived-conclusion", "include_provenance": true }),
);
assert!(read["isError"] != true, "{read}");
let prov = &read["structuredContent"]["mutation_provenance"];
assert_eq!(prov["created_by"]["role"], "author", "{prov}");
assert_eq!(prov["last_modified_by"]["role"], "checker", "{prov}");
assert!(
prov["created_by"]["client"].as_str().is_some(),
"identity recorded: {prov}"
);
assert!(prov["created_by"]["timestamp"].as_i64().unwrap() > 0);
assert_ne!(
prov["created_by"]["role"], prov["last_modified_by"]["role"],
"author≠checker distinguishable from records"
);
let read = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--plain-entity", "include_provenance": true }),
);
let prov = &read["structuredContent"]["mutation_provenance"];
assert_eq!(prov["created_by"]["role"], "unspecified", "{prov}");
let hash_now = read_hash_of(&mut harness, "specs--derived-conclusion");
let reread = harness.call_tool(
"memstead_entity",
json!({ "id": "specs--derived-conclusion", "include_provenance": true }),
);
assert_eq!(
reread["structuredContent"]["_hash"], hash_now,
"provenance reads are pure"
);
assert_eq!(
reread["structuredContent"]["mutation_provenance"]["created_by"]["role"], "author",
"the later checker update never altered the creation record"
);
let out = Command::new(&cli_bin)
.current_dir(tmp.path())
.args([
"--json",
"entity",
"specs--derived-conclusion",
"--provenance",
])
.output()
.expect("run memstead CLI");
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(
v["mutation_provenance"]["created_by"]["role"], "author",
"{v}"
);
assert_eq!(
v["mutation_provenance"]["last_modified_by"]["role"],
"checker"
);
let out = Command::new(&cli_bin)
.current_dir(&ws)
.args(["--json", "entity", "plainws--ledger-roled", "--provenance"])
.output()
.expect("run memstead CLI");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
let p = &v["mutation_provenance"];
assert_eq!(p["created_by"]["role"], "verifier", "folder parity: {v}");
assert_eq!(
p["last_modified_by"]["role"], "checker",
"folder parity: {v}"
);
assert!(p["created_by"]["timestamp"].as_i64().unwrap() > 0);
}
fn read_hash_of(harness: &mut WireHarness, id: &str) -> Value {
let r = harness.call_tool("memstead_entity", json!({ "id": id }));
r["structuredContent"]["_hash"].clone()
}