diff --git a/internal/memory/store/node.go b/internal/memory/store/node.go index 69e67539..a7b9b0cb 100644 --- a/internal/memory/store/node.go +++ b/internal/memory/store/node.go @@ -477,6 +477,12 @@ func (db *DB) autoPrune(maxInsights int, excludeIDs []string) (int, error) { if err := db.DeleteEdgesByNode(id); err != nil { return pruned, fmt.Errorf("delete edges for pruned %s: %w", id, err) } + // Auto-prune is the only destructive path that leaves no trace: + // every other write the CLI makes -- remember, forget, link, + // import, embed -- records an oplog entry. Without this, a store + // can silently lose thousands of insights with no way to find out + // which, when, or why. + db.LogOp("prune", id, fmt.Sprintf("auto-prune: over capacity (active=%d, max=%d)", total, maxInsights)) pruned++ } } diff --git a/internal/memory/store/store_test.go b/internal/memory/store/store_test.go index 21067abf..f2b19761 100644 --- a/internal/memory/store/store_test.go +++ b/internal/memory/store/store_test.go @@ -478,6 +478,58 @@ func TestAutoPrune_PrunesLowestEI(t *testing.T) { } } +// Auto-prune is destructive and, before this test, was the only mutation that +// left no oplog entry — a store could silently shed thousands of insights with +// no record of which ones. Every pruned id must be recoverable from the oplog. +func TestAutoPrune_RecordsOplogEntryPerPrunedInsight(t *testing.T) { + db := testDB(t) + + for i := range 5 { + db.InsertInsight(makeInsight("audit-"+string(rune('a'+i)), "content", 2)) + } + + pruned, err := db.AutoPrune(3, nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 2 { + t.Fatalf("want 2 pruned, got %d", pruned) + } + + entries, err := db.GetOplog(50) + if err != nil { + t.Fatalf("get oplog: %v", err) + } + logged := map[string]bool{} + for _, e := range entries { + if e.Operation == "prune" { + logged[e.InsightID] = true + if e.Detail == "" { + t.Errorf("prune entry for %s has empty detail", e.InsightID) + } + } + } + if len(logged) != pruned { + t.Errorf("want %d prune oplog entries, got %d", pruned, len(logged)) + } + + // Every id recorded as pruned must name a row that still exists and is + // soft-deleted, so the oplog can be trusted as the recovery index. Asking + // only whether the id is inactive is not the same claim: a lookup that + // excludes deleted rows also returns nothing for an id that was never in + // the store, so a fabricated entry would satisfy it. + for id := range logged { + ins, err := db.GetInsightByIDIncludeDeleted(id) + if err != nil { + t.Errorf("oplog names %s, but no such insight exists: %v", id, err) + continue + } + if ins.DeletedAt == nil { + t.Errorf("oplog claims %s pruned but it is still active", id) + } + } +} + func TestAutoPrune_RespectsImmune(t *testing.T) { db := testDB(t)