1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0
//! MCP `memory_promote` handler.
use crate::mcp::param_names;
use crate::mcp::registry::McpTool;
use crate::models::Tier;
use crate::{db, validate};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::Path;
// --- D1.6 (#987): per-tool McpTool impl for `memory_promote` (lifecycle family) ---
/// v0.7.0 #972 D1.6 (#987) — request body for `memory_promote`.
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct PromoteRequest {
pub id: String,
#[schemars(
description = "#831: 'mid' keeps expires_at; 'long' clears it. Downgrades rejected."
)]
#[serde(default)]
pub target_tier: Option<String>,
/// Task 1.7: clone target (must be a proper ancestor).
#[serde(default)]
pub to_namespace: Option<String>,
}
/// v0.7.0 #972 D1.6 (#987) — `McpTool` impl for `memory_promote`.
#[allow(dead_code)]
pub struct PromoteTool;
impl McpTool for PromoteTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_PROMOTE
}
fn description() -> &'static str {
"Promote a memory to long (or chosen tier) / ancestor namespace."
}
fn docs() -> &'static str {
"Default: bump to long (clears expiry); short->long and mid->long are single-call. #831: target_tier ('mid'|'long') stops on intermediate. Task 1.7: to_namespace clones to an ancestor + derived_from link."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<PromoteRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Lifecycle.name()
}
}
#[cfg(test)]
mod d1_6_987_tests {
//! D1.6 (#987) — schema parity for `memory_promote`.
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn promote_parity_987() {
let derived = derived_props_for::<PromoteRequest>();
assert_property_set_parity("memory_promote", &derived);
assert_descriptions_match("memory_promote", &derived);
}
#[test]
fn promote_tool_metadata_987() {
assert_eq!(PromoteTool::name(), "memory_promote");
assert_eq!(PromoteTool::family(), "lifecycle");
}
}
pub(super) fn handle_promote(
conn: &rusqlite::Connection,
db_path: &Path,
params: &Value,
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())?;
// Resolve prefix if exact ID not found; capture the memory so governance
// has owner context (Task 1.9).
let target = if let Some(m) = db::get(conn, id).map_err(|e| e.to_string())? {
m
} else if let Some(m) = db::get_by_prefix(conn, id).map_err(|e| e.to_string())? {
m
} else {
return Err(crate::errors::msg::MEMORY_NOT_FOUND.into());
};
let resolved_id = target.id.clone();
// P5 (G9): snapshot fields needed for the post-success webhook.
let snapshot_namespace = target.namespace.clone();
let snapshot_owner: Option<String> = target
.metadata
.get(param_names::AGENT_ID)
.and_then(|v| v.as_str())
.map(str::to_string);
// Task 1.9: governance enforcement (promote-side).
{
use crate::models::{GovernanceDecision, GovernedAction};
let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
.map_err(|e| e.to_string())?;
let mem_owner = target
.metadata
.get(param_names::AGENT_ID)
.and_then(|v| v.as_str())
.map(str::to_string);
let payload = json!({
"id": resolved_id,
"to_namespace": params["to_namespace"].as_str(),
});
match db::enforce_governance(
conn,
GovernedAction::Promote,
&target.namespace,
&agent_id,
Some(&resolved_id),
mem_owner.as_deref(),
&payload,
)
.map_err(|e| e.to_string())?
{
GovernanceDecision::Allow => {}
GovernanceDecision::Deny(refusal) => {
return Err(crate::governance::deny_message(
"promote",
crate::governance::DenyGate::Governance,
&refusal.reason,
));
}
GovernanceDecision::Pending(pending_id) => {
// v0.7.0 K4 — see the store-side companion call.
crate::subscriptions::dispatch_approval_requested(conn, &pending_id, db_path);
return Ok(json!({
"status": "pending",
"pending_id": pending_id,
"reason": crate::errors::msg::GOVERNANCE_REQUIRES_APPROVAL,
"action": "promote",
"memory_id": resolved_id,
}));
}
}
}
// Task 1.7: optional vertical promotion to an ancestor namespace.
// When `to_namespace` is supplied, clone (don't move) the memory to the
// target and link clone → source with `derived_from`. Original is
// untouched; tier is NOT changed by this path.
if let Some(to_ns) = params["to_namespace"].as_str() {
validate::validate_namespace(to_ns).map_err(|e| e.to_string())?;
let clone_id =
db::promote_to_namespace(conn, &resolved_id, to_ns).map_err(|e| e.to_string())?;
// P5 (G9): fire `memory_promote` webhook for vertical mode AFTER
// the clone commits. memory_id = source id (subscribers can
// distinguish via `mode` and `clone_id` in the details block).
let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
mode: "vertical".to_string(),
tier: None,
to_namespace: Some(to_ns.to_string()),
clone_id: Some(clone_id.clone()),
})
.ok();
crate::subscriptions::dispatch_event_with_details(
conn,
crate::mcp::registry::tool_names::MEMORY_PROMOTE,
&resolved_id,
&snapshot_namespace,
snapshot_owner.as_deref(),
db_path,
details,
);
return Ok(json!({
"promoted": true,
"mode": "vertical",
"source_id": resolved_id,
"clone_id": clone_id,
"to_namespace": to_ns,
}));
}
// Default: tier promotion to long (historical behavior). Issue #831
// — accept an optional `target_tier` parameter so callers can land
// on `mid` as an intermediate step instead of jumping straight to
// `long`. Omitting `target_tier` preserves the historical
// highest-reachable-tier behaviour (short→long / mid→long in a
// single call), which the v0.7.0 CLAUDE.md docs pin under
// "Data Model" + "Recall Pipeline → Touch operations".
//
// The string literals in the match arms below are the canonical
// wire deserializer for `target_tier`; they pair byte-for-byte with
// `Tier::as_str` outputs (see `src/models/memory.rs`). Per pm-v3.1
// PR6 (#1174), this site is intentionally kept as raw literals
// because it consumes caller-supplied wire input — anywhere else
// that *constructs* a tier wire value routes through
// `Tier::<X>.as_str()`.
// v0.7.0 F-C6 fix (issue #1432): route the tier wire string through
// the canonical `Tier::from_str` SSOT instead of an inline match
// that duplicates the parser body. The promote-specific guard
// (reject Short as a downgrade target) stays explicit; the
// unrecognized-tier and missing-value paths preserve byte-equal
// error messages.
let target_tier = match params["target_tier"].as_str() {
None => Tier::Long,
Some("short") => {
return Err(
"target_tier 'short' is not a valid promote target (would be a downgrade)".into(),
);
}
Some(other) => match Tier::from_str(other) {
Some(t) => t,
None => {
return Err(format!(
"target_tier must be one of 'mid' or 'long' (got '{other}')"
));
}
},
};
// Mid-tier promotions must KEEP a live expires_at (mid is a
// 7-day-TTL bucket, not permanent). `db::update`'s expires_at
// contract: `Some("")` clears, `None` preserves the existing
// value. Long is permanent → clear. Mid → preserve whatever
// expiry the row already had (the upstream touch path is what
// refreshes it).
let expires_at_arg: Option<&str> = match target_tier {
Tier::Long => Some(""), // empty string clears expires_at
Tier::Mid | Tier::Short => None, // preserve existing expiry
};
let (found, _) = db::update(
conn,
&resolved_id,
None,
None,
Some(&target_tier),
None,
None,
None,
None,
expires_at_arg,
None,
)
.map_err(|e| e.to_string())?;
if !found {
return Err(crate::errors::msg::MEMORY_NOT_FOUND.into());
}
// P5 (G9): fire `memory_promote` webhook for the default tier-upgrade
// path AFTER the update commits. The webhook `tier` field reflects
// the requested target (long by default, or whatever `target_tier`
// resolved to).
let tier_str = target_tier.as_str().to_string();
let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
mode: "tier".to_string(),
tier: Some(tier_str.clone()),
to_namespace: None,
clone_id: None,
})
.ok();
crate::subscriptions::dispatch_event_with_details(
conn,
"memory_promote",
&resolved_id,
&snapshot_namespace,
snapshot_owner.as_deref(),
db_path,
details,
);
Ok(json!({"promoted": true, "mode": "tier", "id": resolved_id, "tier": tier_str}))
}
// ---- C-5 (#699): close lib-tier gaps in promote.rs (currently 93.39%).
// The MCP envelope path already exercises governance Allow/Deny/Pending,
// vertical mode, and the tier-promote happy path. These tests bolt down
// the `id is required` and validator-error branches that the high-level
// dispatcher tests don't hit at the lib-only tier. ----
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn open_conn() -> rusqlite::Connection {
crate::db::open(Path::new(":memory:")).expect("open in-memory db")
}
#[test]
fn handle_promote_missing_id_errors() {
// Line 16: `id is required`.
let conn = open_conn();
let err = handle_promote(&conn, Path::new(":memory:"), &json!({}), None).unwrap_err();
assert!(err.contains("id"), "got: {err}");
}
#[test]
fn handle_promote_invalid_id_maps_validator_error() {
// Line 17: `validate_id(id).map_err(...)`. A non-UUID string is
// rejected by the validator.
let conn = open_conn();
let err = handle_promote(
&conn,
Path::new(":memory:"),
&json!({"id": "not-a-uuid"}),
None,
)
.unwrap_err();
assert!(!err.is_empty(), "expected non-empty validator error");
}
#[test]
fn handle_promote_unknown_uuid_returns_memory_not_found() {
// Line 25: `memory not found` when both `db::get` and
// `db::get_by_prefix` return None.
let conn = open_conn();
let err = handle_promote(
&conn,
Path::new(":memory:"),
&json!({"id": "00000000-0000-0000-0000-000000000000"}),
None,
)
.unwrap_err();
assert!(err.contains("not found"), "got: {err}");
}
}