use crate::embeddings::Embed;
use crate::hnsw::VectorIndex;
use crate::mcp::param_names;
use crate::mcp::registry::McpTool;
use crate::models::{EditSource, Tier};
use crate::storage::VersionConflict;
use crate::{db, validate};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct UpdateRequest {
pub id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub priority: Option<i64>,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub metadata: Option<serde_json::Map<String, Value>>,
#[schemars(description = "#884 If-Match; mismatch → 409 envelope.")]
#[serde(default)]
pub expected_version: Option<i64>,
#[schemars(
description = "#888/#1600 'human'/'agent'=in-place; 'llm'/'hook'=archive+supersede; omitted derives from caller id (ai:* => agent)."
)]
#[serde(default)]
pub edit_source: Option<String>,
#[schemars(description = "#906 update source_uri.")]
#[serde(default)]
pub source_uri: Option<String>,
}
#[allow(dead_code)]
pub struct UpdateTool;
impl McpTool for UpdateTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_UPDATE
}
fn description() -> &'static str {
"Update an existing memory by ID (only provided fields change)."
}
fn docs() -> &'static str {
"Partial update by id. Omitted fields preserved. Tier monotone-only. metadata.agent_id preserved."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<UpdateRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Lifecycle.name()
}
}
#[cfg(test)]
mod d1_6_987_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn update_parity_987() {
let derived = derived_props_for::<UpdateRequest>();
assert_property_set_parity("memory_update", &derived);
assert_descriptions_match("memory_update", &derived);
}
#[test]
fn update_tool_metadata_987() {
assert_eq!(UpdateTool::name(), "memory_update");
assert_eq!(UpdateTool::family(), "lifecycle");
}
}
pub(super) fn handle_update(
conn: &rusqlite::Connection,
params: &Value,
embedder: Option<&dyn Embed>,
vector_index: Option<&VectorIndex>,
mcp_client: Option<&str>,
) -> Result<Value, String> {
let id = params["id"]
.as_str()
.ok_or(crate::errors::msg::ID_REQUIRED)?;
validate::validate_id(id).map_err(|e| e.to_string())?;
let resolved_id = if db::get(conn, id).map_err(|e| e.to_string())?.is_some() {
id.to_string()
} else if let Some(mem) = db::get_by_prefix(conn, id).map_err(|e| e.to_string())? {
mem.id
} else {
return Err(crate::errors::msg::MEMORY_NOT_FOUND.into());
};
let title = params["title"].as_str();
let content = params["content"].as_str();
let tier = params["tier"].as_str().and_then(Tier::from_str);
let namespace = params["namespace"].as_str();
let tags: Option<Vec<String>> = params["tags"].as_array().map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
});
let priority = params["priority"]
.as_i64()
.map(|p| i32::try_from(p).unwrap_or(i32::MAX));
let confidence = params["confidence"].as_f64();
let expires_at = params["expires_at"].as_str();
let source_uri = params["source_uri"].as_str();
let expected_version = params["expected_version"].as_i64();
let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
.map_err(|e| e.to_string())?;
let edit_source = match params[param_names::EDIT_SOURCE].as_str() {
Some(s) => EditSource::from_str(s).ok_or_else(|| {
format!(
"invalid edit_source '{s}' (expected {})",
EditSource::ALL.map(|v| v.as_str()).join("|")
)
})?,
None => EditSource::default_for_agent_id(&agent_id),
};
if let Some(t) = title {
validate::validate_title(t).map_err(|e| e.to_string())?;
}
if let Some(c) = content {
validate::validate_content(c).map_err(|e| e.to_string())?;
}
if let Some(ns) = &namespace {
validate::validate_namespace(ns).map_err(|e| e.to_string())?;
}
if let Some(ref t) = tags {
validate::validate_tags(t).map_err(|e| e.to_string())?;
}
if let Some(p) = priority {
validate::validate_priority(p).map_err(|e| e.to_string())?;
}
if let Some(c) = confidence {
validate::validate_confidence(c).map_err(|e| e.to_string())?;
}
if let Some(ts) = expires_at {
validate::validate_expires_at_format(ts).map_err(|e| e.to_string())?;
}
if let Some(uri) = source_uri {
validate::validate_source_uri(uri).map_err(|e| e.to_string())?;
}
let metadata = if params["metadata"].is_object() {
let m = params["metadata"].clone();
validate::validate_metadata(&m).map_err(|e| e.to_string())?;
let existing = db::get(conn, &resolved_id)
.map_err(|e| e.to_string())?
.map_or_else(|| serde_json::json!({}), |m| m.metadata);
Some(crate::identity::preserve_agent_id(&existing, &m))
} else {
None
};
{
let existing = db::get(conn, &resolved_id)
.map_err(|e| e.to_string())?
.ok_or(crate::errors::msg::MEMORY_NOT_FOUND)?;
let effective_namespace = namespace.unwrap_or(existing.namespace.as_str()).to_string();
let mem_owner = existing
.metadata
.get(param_names::AGENT_ID)
.and_then(|v| v.as_str())
.map(str::to_string);
let gate_payload = json!({
"id": resolved_id,
"title": title.unwrap_or(existing.title.as_str()),
"namespace": effective_namespace,
});
use crate::permissions::{Op, PermissionContext, Permissions};
let ctx = PermissionContext {
op: Op::MemoryStore,
namespace: effective_namespace.clone(),
agent_id: agent_id.clone(),
payload: gate_payload.clone(),
};
match Permissions::evaluate(&ctx, &[]) {
crate::permissions::Decision::Allow | crate::permissions::Decision::Modify(_) => {}
crate::permissions::Decision::Deny(reason) => {
return Err(crate::governance::deny_message(
"update",
crate::governance::DenyGate::PermissionRule,
&reason,
));
}
crate::permissions::Decision::Ask(prompt) => {
return Ok(json!({
"status": "ask",
"reason": prompt,
"action": "update",
"memory_id": resolved_id,
}));
}
}
use crate::models::{GovernanceDecision, GovernedAction};
match db::enforce_governance(
conn,
GovernedAction::Store,
&effective_namespace,
&agent_id,
Some(&resolved_id),
mem_owner.as_deref(),
&gate_payload,
)
.map_err(|e| e.to_string())?
{
GovernanceDecision::Allow => {}
GovernanceDecision::Deny(refusal) => {
return Err(crate::governance::deny_message(
"update",
crate::governance::DenyGate::Governance,
&refusal.reason,
));
}
GovernanceDecision::Pending(pending_id) => {
return Ok(json!({
"status": "pending",
"pending_id": pending_id,
"reason": crate::errors::msg::GOVERNANCE_REQUIRES_APPROVAL,
"action": "update",
"memory_id": resolved_id,
}));
}
}
}
if edit_source.appends_and_archives() {
let result = db::update_with_archive_on_supersede(
conn,
&resolved_id,
title,
content,
tier.as_ref(),
namespace,
tags.as_ref(),
priority,
confidence,
expires_at,
metadata.as_ref(),
source_uri,
expected_version,
edit_source,
)
.map_err(|e| conflict_or_string(&e))?;
if let Some(emb) = embedder {
let new_id = &result.new_id;
let mem = db::get(conn, new_id).map_err(|e| e.to_string())?;
if let Some(ref m) = mem {
let text = crate::embeddings::embedding_document(&m.title, &m.content);
if let Ok(embedding) = emb.embed(&text) {
let _ = db::set_embedding(conn, new_id, &embedding);
if let Some(idx) = vector_index {
idx.remove(new_id);
idx.insert(new_id.clone(), embedding);
}
}
}
}
let new_mem = db::get(conn, &result.new_id).map_err(|e| e.to_string())?;
return Ok(json!({
"updated": true,
"edit_source": edit_source.as_str(),
"memory": new_mem,
"superseded_id": result.archived_id,
"new_id": result.new_id,
}));
}
let (found, content_changed) = db::update_with_expected_version(
conn,
&resolved_id,
title,
content,
tier.as_ref(),
namespace,
tags.as_ref(),
priority,
confidence,
expires_at,
metadata.as_ref(),
source_uri,
expected_version,
)
.map_err(|e| conflict_or_string(&e))?;
if !found {
return Err(crate::errors::msg::MEMORY_NOT_FOUND.into());
}
if content_changed && let Some(emb) = embedder {
let mem = db::get(conn, &resolved_id).map_err(|e| e.to_string())?;
if let Some(ref m) = mem {
let text = crate::embeddings::embedding_document(&m.title, &m.content);
if let Ok(embedding) = emb.embed(&text) {
let _ = db::set_embedding(conn, &resolved_id, &embedding);
if let Some(idx) = vector_index {
idx.remove(&resolved_id);
idx.insert(resolved_id.clone(), embedding);
}
}
}
}
let mem = db::get(conn, &resolved_id).map_err(|e| e.to_string())?;
Ok(json!({
"updated": true,
"edit_source": edit_source.as_str(),
"memory": mem,
}))
}
fn conflict_or_string(e: &anyhow::Error) -> String {
if let Some(vc) = e.downcast_ref::<VersionConflict>() {
json!({
"status": "conflict",
"id": vc.id,
"expected_version": vc.expected,
"current_version": vc.current,
})
.to_string()
} else {
e.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embeddings::test_support::MockEmbedder;
use crate::hnsw::VectorIndex;
use crate::models::{Memory, Tier as MTier};
use crate::storage as db;
fn fresh_conn() -> rusqlite::Connection {
db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
fn make_mem(title: &str) -> Memory {
let now = chrono::Utc::now().to_rfc3339();
Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: MTier::Mid,
namespace: "test".to_string(),
title: title.to_string(),
content: format!("body for {title}"),
tags: vec!["a".to_string()],
priority: 5,
confidence: 0.5,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: json!({"agent_id": "ai:owner"}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
}
}
#[test]
fn happy_path_updates_all_fields_no_embedder() {
let conn = fresh_conn();
let mem = make_mem("orig");
let id = db::insert(&conn, &mem).expect("ins");
let out = handle_update(
&conn,
&json!({
"id": id,
"title": "new title",
"content": "new body content here",
"tier": MTier::Long.as_str(),
"namespace": "ns2",
"tags": ["x", "y"],
"priority": 7,
"confidence": 0.9,
"expires_at": "2030-01-01T00:00:00Z",
"metadata": {"k": "v"},
}),
None,
None,
None,
)
.expect("ok");
assert_eq!(out["updated"].as_bool(), Some(true));
let m = &out["memory"];
assert_eq!(m["title"].as_str(), Some("new title"));
assert_eq!(m["namespace"].as_str(), Some("ns2"));
assert_eq!(
m["metadata"]["agent_id"].as_str(),
Some("ai:owner"),
"agent_id must be preserved through update"
);
}
#[test]
fn prefix_resolution_branch() {
let conn = fresh_conn();
let mut mem = make_mem("p");
mem.id = "fedcba98-1111-2222-3333-444455556666".to_string();
let _ = db::insert(&conn, &mem).expect("ins");
let out = handle_update(
&conn,
&json!({"id": "fedcba98", "title": "renamed"}),
None,
None,
None,
)
.expect("prefix ok");
assert_eq!(out["memory"]["title"].as_str(), Some("renamed"));
}
#[test]
fn embedder_some_path_reembeds_when_content_changes() {
let conn = fresh_conn();
let mem = make_mem("xyz");
let id = db::insert(&conn, &mem).expect("ins");
let mock = MockEmbedder::new_local().expect("mock");
let idx = VectorIndex::empty();
let out = handle_update(
&conn,
&json!({"id": id.clone(), "content": "completely new content"}),
Some(&mock as &dyn crate::embeddings::Embed),
Some(&idx),
None,
)
.expect("ok");
assert_eq!(out["updated"].as_bool(), Some(true));
let emb = db::get_embedding(&conn, &id).expect("ok").expect("some");
assert_eq!(emb.len(), 384);
}
#[test]
fn embedder_some_path_skips_when_content_unchanged() {
let conn = fresh_conn();
let mem = make_mem("nochange");
let id = db::insert(&conn, &mem).expect("ins");
let mock = MockEmbedder::new_local().expect("mock");
let out = handle_update(
&conn,
&json!({"id": id.clone(), "tags": ["new-tag"]}),
Some(&mock as &dyn crate::embeddings::Embed),
None,
None,
)
.expect("ok");
assert_eq!(out["updated"].as_bool(), Some(true));
let emb = db::get_embedding(&conn, &id).expect("ok");
assert!(emb.is_none());
}
#[test]
fn missing_id_errors() {
let conn = fresh_conn();
let err = handle_update(&conn, &json!({}), None, None, None).unwrap_err();
assert!(err.contains("id is required"));
}
#[test]
fn invalid_id_format_errors() {
let conn = fresh_conn();
let err = handle_update(&conn, &json!({"id": ""}), None, None, None).unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn unknown_id_errors() {
let conn = fresh_conn();
let err = handle_update(
&conn,
&json!({"id": "11111111-aaaa-bbbb-cccc-dddddddddddd", "title": "x"}),
None,
None,
None,
)
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn invalid_title_errors() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("ins");
let err =
handle_update(&conn, &json!({"id": id, "title": ""}), None, None, None).unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_content_errors() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("ins");
let err =
handle_update(&conn, &json!({"id": id, "content": ""}), None, None, None).unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_namespace_errors() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("ins");
let err = handle_update(
&conn,
&json!({"id": id, "namespace": "has space"}),
None,
None,
None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_priority_errors() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("ins");
let err =
handle_update(&conn, &json!({"id": id, "priority": 99}), None, None, None).unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_confidence_errors() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("ins");
let err = handle_update(
&conn,
&json!({"id": id, "confidence": 5.0}),
None,
None,
None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn invalid_expires_at_errors() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("ins");
let err = handle_update(
&conn,
&json!({"id": id, "expires_at": "not-a-date"}),
None,
None,
None,
)
.unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn metadata_preserves_existing_agent_id() {
let conn = fresh_conn();
let mem = make_mem("immut");
let id = db::insert(&conn, &mem).expect("ins");
let out = handle_update(
&conn,
&json!({"id": id, "metadata": {"agent_id": "ai:other", "note": "hi"}}),
None,
None,
None,
)
.expect("ok");
assert_eq!(
out["memory"]["metadata"]["agent_id"].as_str(),
Some("ai:owner"),
"agent_id immutable"
);
assert_eq!(out["memory"]["metadata"]["note"].as_str(), Some("hi"));
}
#[test]
fn idempotent_repeated_update() {
let conn = fresh_conn();
let mem = make_mem("idem");
let id = db::insert(&conn, &mem).expect("ins");
let one = handle_update(&conn, &json!({"id": &id, "priority": 8}), None, None, None)
.expect("ok 1");
let two = handle_update(&conn, &json!({"id": &id, "priority": 8}), None, None, None)
.expect("ok 2");
assert_eq!(one["updated"], two["updated"]);
}
#[test]
fn edit_source_llm_appends_and_archives_with_embedder() {
let conn = fresh_conn();
let mem = make_mem("pre-supersede");
let id = db::insert(&conn, &mem).expect("ins");
let mock = MockEmbedder::new_local().expect("mock");
let idx = VectorIndex::empty();
let out = handle_update(
&conn,
&json!({
"id": &id,
"content": "llm-rewritten content body",
"edit_source": "llm",
}),
Some(&mock as &dyn crate::embeddings::Embed),
Some(&idx),
None,
)
.expect("supersede ok");
assert_eq!(out["updated"].as_bool(), Some(true));
assert_eq!(out["edit_source"].as_str(), Some("llm"));
assert_eq!(out["superseded_id"].as_str(), Some(id.as_str()));
let new_id = out["new_id"].as_str().expect("new_id present");
assert_ne!(new_id, id);
let new_mem = &out["memory"];
assert_eq!(
new_mem["content"].as_str(),
Some("llm-rewritten content body")
);
assert_eq!(
new_mem["metadata"]["superseded_id"].as_str(),
Some(id.as_str())
);
let emb = db::get_embedding(&conn, new_id)
.expect("emb ok")
.expect("some");
assert_eq!(emb.len(), 384);
}
#[test]
fn edit_source_hook_appends_and_archives_no_embedder() {
let conn = fresh_conn();
let mem = make_mem("pre-hook");
let id = db::insert(&conn, &mem).expect("ins");
let out = handle_update(
&conn,
&json!({
"id": &id,
"title": "hook-edited title",
"edit_source": "hook",
}),
None,
None,
None,
)
.expect("hook supersede ok");
assert_eq!(out["edit_source"].as_str(), Some("hook"));
assert_eq!(out["superseded_id"].as_str(), Some(id.as_str()));
let new_id = out["new_id"].as_str().expect("new_id present");
assert_ne!(new_id, id);
assert_eq!(out["memory"]["title"].as_str(), Some("hook-edited title"));
assert!(
db::get_embedding(&conn, new_id).expect("ok").is_none(),
"no embedder ⇒ no embedding persisted on the new row"
);
}
#[test]
fn expected_version_conflict_returns_json_envelope() {
let conn = fresh_conn();
let mem = make_mem("verconflict");
let id = db::insert(&conn, &mem).expect("ins");
let _ = handle_update(&conn, &json!({"id": &id, "priority": 6}), None, None, None)
.expect("bump");
let err = handle_update(
&conn,
&json!({
"id": &id,
"title": "stale write",
"expected_version": 1,
}),
None,
None,
None,
)
.unwrap_err();
let v: serde_json::Value = serde_json::from_str(&err).expect("json envelope");
assert_eq!(v["status"].as_str(), Some("conflict"));
assert_eq!(v["id"].as_str(), Some(id.as_str()));
assert_eq!(v["expected_version"].as_i64(), Some(1));
assert_eq!(v["current_version"].as_i64(), Some(2));
}
#[test]
fn source_uri_valid_passes_through_and_invalid_rejects() {
let conn = fresh_conn();
let mem = make_mem("srcuri");
let id = db::insert(&conn, &mem).expect("ins");
let ok = handle_update(
&conn,
&json!({"id": &id, "source_uri": "doc:internal-ref-42"}),
None,
None,
None,
)
.expect("valid source_uri");
assert_eq!(ok["updated"].as_bool(), Some(true));
assert_eq!(
ok["memory"]["source_uri"].as_str(),
Some("doc:internal-ref-42")
);
let err = handle_update(
&conn,
&json!({"id": &id, "source_uri": "example.com/no-scheme"}),
None,
None,
None,
)
.unwrap_err();
assert!(!err.is_empty(), "source_uri must be rejected");
assert!(
err.to_lowercase().contains("source uri")
|| err.to_lowercase().contains("source_uri")
|| err.to_lowercase().contains("scheme"),
"error should reference source uri / scheme; got: {err}"
);
}
fn install_write_owner_policy(conn: &rusqlite::Connection, ns: &str, owner: &str) {
use crate::models::{ApproverType, CorePolicy, GovernancePolicy, default_metadata};
let policy = GovernancePolicy {
core: CorePolicy {
write: crate::models::GovernanceLevel::Owner,
approver: ApproverType::Human,
..CorePolicy::default()
},
..Default::default()
};
let mut metadata = default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(owner.to_string()),
);
obj.insert(
"governance".to_string(),
serde_json::to_value(&policy).unwrap(),
);
}
let mut standard = make_mem("std");
standard.namespace = format!("_standards-{ns}");
standard.title = format!("std-{ns}");
standard.metadata = metadata;
let sid = db::insert(conn, &standard).expect("insert standard");
db::set_namespace_standard(conn, ns, &sid, None).expect("set standard");
}
#[test]
fn governance_deny_blocks_update_by_non_owner() {
let _gate = crate::config::lock_permissions_mode_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let conn = fresh_conn();
let ns = "gov-deny-upd";
install_write_owner_policy(&conn, ns, "ai:alice");
let mut mem = make_mem("target");
mem.namespace = ns.to_string();
mem.metadata = json!({"agent_id": "ai:alice"});
let id = db::insert(&conn, &mem).expect("insert");
let err = handle_update(
&conn,
&json!({"id": id, "title": "evil rewrite", "agent_id": "ai:eve"}),
None,
None,
None,
)
.unwrap_err();
assert!(
err.contains("governance") || err.contains("denied") || err.contains("owner"),
"non-owner update must be gated; got: {err}"
);
crate::config::clear_permissions_mode_override_for_test();
}
#[test]
fn issue_1600_explicit_agent_edit_source_mutates_in_place() {
let conn = fresh_conn();
let mem = make_mem("agent-inplace");
let id = db::insert(&conn, &mem).expect("ins");
let out = handle_update(
&conn,
&json!({
"id": &id,
"content": "agent-edited content body",
"edit_source": "agent",
}),
None,
None,
None,
)
.expect("agent edit ok");
assert_eq!(out["updated"].as_bool(), Some(true));
assert_eq!(out["edit_source"].as_str(), Some("agent"));
assert!(
out.get("superseded_id").is_none() && out.get("new_id").is_none(),
"#1600: agent edits must NOT route append-and-archive"
);
assert_eq!(out["memory"]["id"].as_str(), Some(id.as_str()));
assert_eq!(
out["memory"]["content"].as_str(),
Some("agent-edited content body")
);
}
#[test]
fn issue_1600_unknown_edit_source_errors_listing_valid_values() {
let conn = fresh_conn();
let mem = make_mem("robot-reject");
let id = db::insert(&conn, &mem).expect("ins");
let err = handle_update(
&conn,
&json!({"id": &id, "title": "should not land", "edit_source": "robot"}),
None,
None,
None,
)
.unwrap_err();
assert!(
err.contains("invalid edit_source 'robot'"),
"must name the rejected value; got: {err}"
);
for valid in EditSource::ALL {
assert!(
err.contains(valid.as_str()),
"error must list '{}' in the valid set; got: {err}",
valid.as_str()
);
}
let row = db::get(&conn, &id).expect("get").expect("row");
assert_eq!(row.title, "robot-reject", "row must be untouched");
}
#[test]
fn issue_1600_omitted_edit_source_derives_from_caller_id() {
let conn = fresh_conn();
let mem = make_mem("derive-default");
let id = db::insert(&conn, &mem).expect("ins");
let out = handle_update(
&conn,
&json!({"id": &id, "priority": 7, "agent_id": "ai:grok-4@dogfood:pid-9"}),
None,
None,
None,
)
.expect("ok");
assert_eq!(
out["edit_source"].as_str(),
Some("agent"),
"#1600: omitted edit_source + ai:-prefixed caller must default to agent"
);
assert!(out.get("new_id").is_none(), "agent default stays in-place");
let out = handle_update(
&conn,
&json!({"id": &id, "priority": 8, "agent_id": "host:box-1"}),
None,
None,
None,
)
.expect("ok");
assert_eq!(
out["edit_source"].as_str(),
Some("human"),
"non-ai callers keep the historical human default"
);
}
#[test]
fn governance_allows_update_in_ungoverned_namespace() {
let conn = fresh_conn();
let mem = make_mem("ok");
let id = db::insert(&conn, &mem).expect("insert");
let out = handle_update(
&conn,
&json!({"id": id, "title": "fine rewrite"}),
None,
None,
None,
)
.expect("ungoverned update should pass the gate");
assert_eq!(out["updated"].as_bool(), Some(true));
}
}