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
//! Database supersede operations.
use chrono::Utc;
use rusqlite::{Transaction, TransactionBehavior, params};
use uuid::Uuid;
use crate::memory::lifecycle::{MemoryImportance, MemoryStatus};
use super::{Error, NewRow, Result, vec_to_blob};
impl super::Database {
/// Atomically supersede an old memory with a new one at the `medium`
/// importance and `active` status defaults.
///
/// In a single transaction:
/// 1. Inserts the new memory
/// 2. Sets old memory's status to 'superseded' and superseded_by to new ID
///
/// Delegates to [`Self::supersede_with_importance`], the one supersede
/// INSERT, so the two paths cannot drift — and binds both lifecycle columns
/// by name rather than leaving either to the column DEFAULT. This is the
/// entry point for a caller that names neither level nor status; a caller
/// that does name them goes through [`crate::MemoryStore::supersede_with`],
/// which is how the CLI and MCP supersede paths reach the INSERT.
#[allow(dead_code)] // Public API for library consumers (e.g., kide)
pub fn supersede(
&self,
project_id: &str,
content: &str,
embedding: &[f32],
metadata: Option<&str>,
memory_type: &str,
old_id: &str,
) -> Result<String> {
self.supersede_with_importance(
&NewRow {
project_id,
content,
metadata,
memory_type,
status: "active",
importance: MemoryImportance::default().as_str(),
},
embedding,
old_id,
)
}
/// Atomically supersede an old memory with a new row whose lifecycle
/// columns are bound explicitly.
///
/// Both `importance` and `status` come from `row`, never from the column
/// DEFAULT: `vipune add --supersedes <id> --importance critical --status
/// candidate` creates that row through here, and an omitted column silently
/// reset what the operator had just asked for — the level costs the row the
/// `high`/`critical` prune protection that keys off it (issue #238), the
/// status silently hid a `candidate` replacement under `active` (issue #258).
/// The caller supplies both in the canonical spelling, and the INSERT binds
/// them; a `candidate` replacement is deliberately hidden from default
/// search/list until it is activated (`vipune update <id> --status active`).
///
/// Only `active` and `candidate` rows may be superseded. The status column
/// has no CHECK constraint and this body — the one INSERT every supersede
/// crosses, including the public [`Self::supersede`] wrapper above it — is the
/// last thing that can refuse: re-superseding an already-`superseded` row
/// overwrites its `superseded_by` and forks the chain into a row nothing
/// points at, and superseding a `deprecated` row resurrects one `prune`
/// retired. The verdict comes from [`MemoryStatus::stored_is_retired`], the
/// one predicate the insert rule shares, so a new variant moves the guard and
/// the column together and a retired status in any casing or padding cannot
/// slip past a raw string comparison. The refusal always quotes the canonical
/// [`MemoryStatus::as_str`], never the raw column, so the message the operator
/// reads is one spelling rather than whatever `import` left there — the
/// row's own `superseded_by` is still reported verbatim, since that is an id.
///
/// # Errors
///
/// [`Error::NotFound`] when `old_id` is absent from `row.project_id` — which
/// includes a row that exists under another project, as the scoped `WHERE`
/// cannot tell the two apart — [`Error::InvalidInput`] when the row is already
/// `superseded` or `deprecated` (naming its current status, and its
/// `superseded_by` when set), and a storage error if the write itself fails.
/// The guard runs inside the write transaction, which is opened
/// [`TransactionBehavior::Immediate`] so the write lock is taken *before* the
/// read it checks: a refusal writes nothing, and no other process can retire
/// the row between that read and the UPDATE.
pub(crate) fn supersede_with_importance(
&self,
row: &NewRow<'_>,
embedding: &[f32],
old_id: &str,
) -> Result<String> {
// `Immediate`, not the deferred default: `BEGIN IMMEDIATE` takes the write
// lock at BEGIN and holds it to the commit, so the guard's read below and
// the two writes after it are one unit no other writer interleaves with.
// A deferred transaction takes no lock for a plain read, so the guard
// would check a snapshot and only then discover it cannot write.
// `Transaction::new_unchecked` is the `&Connection` form of rusqlite's
// `unchecked_transaction`, which this `&self` method needs; the no-nesting
// guarantee those two trade `&mut self` for is held here by this being the
// only transaction any path opens on this connection.
let tx = Transaction::new_unchecked(&self.conn, TransactionBehavior::Immediate)?;
// Verify old memory exists, belongs to this project (the scoped WHERE
// enforces it), and has not already been retired. The status is decoded
// as `Option<String>`: a NULL column is not a retired one, and decoding it
// as `String` would turn that row into a storage error instead.
let old = {
let mut stmt = tx.prepare(
"SELECT status, superseded_by FROM memories WHERE id = ?1 AND project_id = ?2",
)?;
let mut rows = stmt.query_map([old_id, row.project_id], |r| {
Ok((
r.get::<_, Option<String>>(0)?,
r.get::<_, Option<String>>(1)?,
))
})?;
rows.next().transpose()?.ok_or_else(|| {
Error::NotFound(format!("memory to supersede not found: {}", old_id))
})?
};
// The re-supersede guard. Parsed through the enum rather than compared to
// string literals, so a retired row written below the store's validating
// edges (`import`, `Database::update`) is refused whatever its casing or
// padding. A status outside the vocabulary is not a retired one and stays
// supersedeable — failing closed would make such a row impossible to
// replace — and a NULL status is not retired either.
if let Some(stored) = old.0.as_deref()
&& MemoryStatus::stored_is_retired(stored)
{
// Parsed once and rendered through the enum, so the message quotes
// the canonical spelling rather than `stored`: the column is
// unvalidated text and the operator's error should read like the
// vocabulary the CLI documents.
let mut msg = match MemoryStatus::from_str(stored.trim()) {
Ok(canonical) => format!(
"cannot supersede memory {old_id}: status is already '{}'",
canonical.as_str()
),
// Unreachable while `stored_is_retired` parses the same way; if
// the two ever disagreed the message degrades to the raw column
// rather than inventing a status name.
Err(_) => format!("cannot supersede memory {old_id}: status is already '{stored}'"),
};
if let Some(by) = old.1 {
msg.push_str(&format!(" (superseded_by {by})"));
}
return Err(Error::InvalidInput(msg));
}
let new_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let blob = vec_to_blob(embedding)?;
tx.execute(
"INSERT INTO memories (id, project_id, content, embedding, metadata, created_at, updated_at, type, status, importance)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
&new_id,
row.project_id,
row.content,
&blob,
row.metadata,
&now,
&now,
row.memory_type,
row.status,
row.importance
],
)?;
tx.execute(
"UPDATE memories SET status = 'superseded', superseded_by = ?1, updated_at = ?2 WHERE id = ?3",
params![&new_id, &now, old_id],
)?;
tx.commit()?;
Ok(new_id)
}
}