{"record":{"id":"8ee3c708ce93c239","repo":"JuliusBrussee/caveman","slug":"memory-s-changed-during-supersede","errorCode":null,"errorMessage":"memory %s changed during supersede","messagePattern":"memory (.+?) changed during supersede","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"mem/store.go","lineNumber":254,"sourceCode":"\tif _, err := tx.Exec(\n\t\t`INSERT INTO memories\n\t\t   (id, text, created_at, valid_from, supersedes)\n\t\t VALUES (?, ?, ?, ?, ?)`,\n\t\tnewID, newText, now, now, oldID,\n\t); err != nil {\n\t\treturn Memory{}, fmt.Errorf(\"insert replacement memory: %w\", err)\n\t}\n\tres, err := tx.Exec(\n\t\t`UPDATE memories\n\t\t    SET valid_until = ?, superseded_by = ?\n\t\t  WHERE id = ? AND valid_until IS NULL`,\n\t\tnow, newID, oldID,\n\t)\n\tif err != nil {\n\t\treturn Memory{}, fmt.Errorf(\"expire old memory: %w\", err)\n\t}\n\tif n, _ := res.RowsAffected(); n != 1 {\n\t\treturn Memory{}, fmt.Errorf(\"memory %s changed during supersede\", oldID)\n\t}\n\tif err := tx.Commit(); err != nil {\n\t\treturn Memory{}, fmt.Errorf(\"supersede commit: %w\", err)\n\t}\n\treturn Memory{\n\t\tID:         newID,\n\t\tText:       newText,\n\t\tCreatedAt:  now,\n\t\tValidFrom:  now,\n\t\tSupersedes: oldID,\n\t}, nil\n}\n\nfunc validateMemorySize(text string) error {\n\tif len(text) > MaxMemoryBytes {\n\t\treturn fmt.Errorf(\"%w: memory is %d bytes, over the %d-byte cap\", ErrMemoryTooLarge, len(text), MaxMemoryBytes)\n\t}\n\treturn nil","sourceCodeStart":236,"sourceCodeEnd":272,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/mem/store.go#L236-L272","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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)."],"exampleFix":"// before\nmem, err := store.Supersede(oldID, text)\n// after\nmem, err := store.Supersede(oldID, text)\nif err != nil && strings.Contains(err.Error(), \"changed during supersede\") {\n\ttime.Sleep(50 * time.Millisecond)\n\tmem, err = store.Supersede(oldID, text) // retry after concurrent writer settles\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"mem, err := store.Supersede(oldID, text)\nif err != nil && strings.Contains(err.Error(), \"changed during supersede\") {\n\ttime.Sleep(50 * time.Millisecond)\n\treturn store.Supersede(oldID, text)\n}","preventionTips":["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"],"tags":["concurrency","race-condition","sqlite"],"backgroundTag":"invalid-state-transition","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}