{"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/27d5a3981a347890211bb1bf2439e5c821a63bc9/mem/store.go#L236-L272","documentation":"Raised by Store.Supersede when the UPDATE that expires the old memory (`SET valid_until/superseded_by WHERE id = ? AND valid_until IS NULL`) affects zero rows. This is an optimistic-concurrency guard: the memory you are replacing is no longer the current version, because a concurrent Supersede, Forget, or expiry already changed it. The transaction had already inserted the replacement row, but the mismatch aborts the commit path.","triggerScenarios":"Two goroutines/processes call Supersede(oldID, ...) on the same current memory; the second one's conditional UPDATE matches no row (valid_until is already set). Also hit when the old memory was Forget-deleted or the row expired between your read and your write, or when the id passed is already a superseded (historical) version.","commonSituations":"Parallel agents editing the same memory through separate Store handles (e.g. CLI and MCP server against the same SQLite file), retry logic that re-runs a supersede whose first attempt actually succeeded, or holding an id from an earlier session after another tool superseded it.","solutions":["Re-read the memory and retry Supersede against the new current version (the winner's replacement), or re-fetch the chain if your edit is still needed","If the error repeats with no other writer, inspect the row: SELECT valid_until, superseded_by FROM memories WHERE id = ? — a non-NULL valid_until means it is already historical; supersede its replacement instead","Serialize supersede edits for the same memory id through a single Store/process or a mutex so only one writer races"],"exampleFix":"// before\nnewMem, err := store.Supersede(oldID, updatedText) // races with another writer\n\n// after\nnewMem, err := store.Supersede(oldID, updatedText)\nif err != nil && strings.Contains(err.Error(), \"changed during supersede\") {\n    // another writer won; re-read the current version and re-apply\n    current, rerr := store.History(oldID) // or fetch latest via memoryByID chain\n    if rerr == nil {\n        latest := current[len(current)-1]\n        newMem, err = store.Supersede(latest.ID, updatedText)\n    }\n}","handlingStrategy":"retry","validationCode":"cur, err := store.History(oldID)\nif err != nil { return err }\nlatest := cur[len(cur)-1]\nif latest.ValidUntil != nil || latest.SupersededBy != \"\" {\n    return fmt.Errorf(\"memory %s already superseded by %s; supersede the current version\", oldID, latest.SupersededBy)\n}","typeGuard":"func isCurrent(m mem.Memory) bool {\n    return m.ValidUntil == nil && m.SupersededBy == \"\"\n}","tryCatchPattern":"newMem, err := store.Supersede(oldID, text)\nif err != nil {\n    if strings.Contains(err.Error(), \"changed during supersede\") {\n        // re-resolve the current version and retry once with fresh state\n    }\n    return err\n}","preventionTips":["Route all supersede edits for a memory through a single writer (one process or a mutex)","Always derive oldID from a fresh read in the same session, never from persisted/stale state","Treat this error as a retry signal with re-read, not a blind retry of the same call"],"tags":["go","sqlite","concurrency","optimistic-locking","memory-store"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}