JuliusBrussee/caveman · error
memory changed during supersede
Error message
memory %s changed during supersede
What it means
Supersede detected that the old memory's row was not the single current row it read at the start (RowsAffected != 1 for the expire UPDATE). This is a concurrency/invariant guard: something else expired, superseded, or deleted the memory between the SELECT and the UPDATE inside the transaction, and the store refuses to build lineage on a moved target.
Solutions
- Re-read the memory and retry the Supersede once the concurrent operation settles.
- Serialize supersede operations for the same memory ID (per-ID mutex or application-level lock).
- Treat the error as the other write winning and reconcile using History(oldID).
Example fix
// before
mem, err := store.Supersede(oldID, text)
// after
mem, err := store.Supersede(oldID, text)
if err != nil && strings.Contains(err.Error(), "changed during supersede") {
time.Sleep(50 * time.Millisecond)
mem, err = store.Supersede(oldID, text) // retry after concurrent writer settles
} Defensive patterns
Strategy: retry
Try / catch
mem, err := store.Supersede(oldID, text)
if err != nil && strings.Contains(err.Error(), "changed during supersede") {
time.Sleep(50 * time.Millisecond)
return store.Supersede(oldID, text)
} Prevention
- Use a per-memory-ID lock/mutex around supersede calls
- Don't run expiry/cleanup jobs concurrently with user supersedes
- Reconcile with History(oldID) after a concurrent-write error
When it happens
Trigger: Calling Store.Supersede(oldID, newText) concurrently with another Supersede/Forget/expiry on the same oldID such that the conditional UPDATE matches 0 or multiple rows.
Common situations: Two CLI sessions or goroutines superseding the same memory at once; a background cleanup job expiring the memory mid-operation; logical clock/visibility anomalies between transactions.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- Cave Build lock changed during validation; refusing Claude…
- file changed while securing
- forget begin
- changed while planning MCP update; refusing overwrite
- MCP config target changed while acquiring lock; refusing…
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/8ee3c708ce93c239.
Report an issue: GitHub.
Appendix: source
Thrown at mem/store.go:254
if _, err := tx.Exec(
`INSERT INTO memories
(id, text, created_at, valid_from, supersedes)
VALUES (?, ?, ?, ?, ?)`,
newID, newText, now, now, oldID,
); err != nil {
return Memory{}, fmt.Errorf("insert replacement memory: %w", err)
}
res, err := tx.Exec(
`UPDATE memories
SET valid_until = ?, superseded_by = ?
WHERE id = ? AND valid_until IS NULL`,
now, newID, oldID,
)
if err != nil {
return Memory{}, fmt.Errorf("expire old memory: %w", err)
}
if n, _ := res.RowsAffected(); n != 1 {
return Memory{}, fmt.Errorf("memory %s changed during supersede", oldID)
}
if err := tx.Commit(); err != nil {
return Memory{}, fmt.Errorf("supersede commit: %w", err)
}
return Memory{
ID: newID,
Text: newText,
CreatedAt: now,
ValidFrom: now,
Supersedes: oldID,
}, nil
}
func validateMemorySize(text string) error {
if len(text) > MaxMemoryBytes {
return fmt.Errorf("%w: memory is %d bytes, over the %d-byte cap", ErrMemoryTooLarge, len(text), MaxMemoryBytes)
}
return nilView on GitHub (pinned to 3ee70a1026)