store: voiding a fact drops its memory vectors (V-470)

Revert voided the fact row and left the vector, so recall kept serving the
voided fact's utterance and the documented repair reported success on a box that
stayed broken. There was no way to repair a poisoned box at all.

DeletePrefix covers every vector for the key, earlier rows included: their values
are superseded, and a superseded value has no business claiming a turn. It is
best-effort — the audit trail is already committed, and a fact that is voided but
still recallable beats a void that failed.
This commit is contained in:
2026-08-03 13:40:13 +04:00
parent 6645f64c3e
commit f3fa6b353a
3 changed files with 100 additions and 0 deletions
+21
View File
@@ -148,6 +148,27 @@ func (m *MemoryStore) Delete(ctx context.Context, id string) error {
return nil
}
// DeletePrefix removes every vector whose id starts with prefix and returns
// how many went. Same escaping as ByPrefix, so a key containing % or _ cannot
// widen the delete.
//
// It exists for the repair half of a revert (#470). Voiding a fact row left
// its vector in the index, so recall kept serving the voided fact's utterance
// and the documented repair did not repair.
func (m *MemoryStore) DeletePrefix(ctx context.Context, prefix string) (int64, error) {
pattern := escapeLike(prefix) + "%"
res, err := m.db.ExecContext(ctx,
`DELETE FROM memory_vectors WHERE id LIKE ? ESCAPE '\'`, pattern)
if err != nil {
return 0, fmt.Errorf("memory: delete prefix %q: %w", prefix, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("memory: delete prefix %q: rows affected: %w", prefix, err)
}
return n, nil
}
// escapeLike neutralises the LIKE wildcards in a literal prefix.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)