{"record":{"id":"4f45d7ec3dc3521c","repo":"gastownhall/beads","slug":"w-issue-s-4f45d7","errorCode":null,"errorMessage":"%w: issue %s","messagePattern":"%w: issue (.+?)","errorType":"error_code","errorClass":"storage.ErrNotFound","httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/version.go","lineNumber":39,"sourceCode":"// retry-wrapped permanent close path — a writer that commits DURING the close's\n// transaction collides on this same row_lock cell at commit time, which\n// withRetryTx replays; the replayed attempt then re-reads the new version here\n// and refuses. Together they close the read-then-write window that a bare\n// read-then-write would leave open.\n//\n//nolint:gosec // G201: table name comes from WispTableRouting (hardcoded constants)\nfunc CheckVersionInTx(ctx context.Context, tx DBTX, id string, expected int64) error {\n\tisWisp := IsActiveWispInTx(ctx, tx, id)\n\tissueTable, _, _, _ := WispTableRouting(isWisp)\n\n\t// row_lock is NOT NULL DEFAULT 0, but scan defensively so a NULL maps to 0\n\t// rather than erroring (mirrors scan.go's RowVersion handling).\n\tvar current sql.NullInt64\n\terr := tx.QueryRowContext(ctx,\n\t\tfmt.Sprintf(\"SELECT row_lock FROM %s WHERE id = ?\", issueTable), id,\n\t).Scan(&current)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn fmt.Errorf(\"%w: issue %s\", storage.ErrNotFound, id)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read row version for %s: %w\", id, err)\n\t}\n\tif current.Int64 != expected {\n\t\treturn fmt.Errorf(\"%w: expected %d, got %d\", storage.ErrVersionMismatch, expected, current.Int64)\n\t}\n\treturn nil\n}\n","sourceCodeStart":21,"sourceCodeEnd":49,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/version.go#L21-L49","documentation":"CheckVersionInTx implements row-version (row_lock) optimistic concurrency. If the issue id has no row, it returns storage.ErrNotFound wrapped with the id — same sentinel contract as the CAS check — so callers can distinguish 'gone' from 'changed'.","triggerScenarios":"CloseIssueCheckedInTx, DeleteInTx, ExecuteUpdate, or ExecuteReopen called with an ExpectedVersion/row-lock value for an issue id that does not exist in the issues table.","commonSituations":"Deleting or closing an issue another session already removed; stale IDs cached by a long-running agent; exporting/importing issues where the target DB lacks the row.","solutions":["Verify the issue exists before the versioned mutation","Branch on errors.Is(err, storage.ErrNotFound) and surface 'already deleted' instead of a generic version failure","Refresh local issue list (bd sync) if the row was expected to exist","Guard scripts against reusing IDs from stale snapshots"],"exampleFix":"// before\ndeleteIssue(id) // opaque error when already gone\n// after\nif err := CheckVersionInTx(ctx, tx, id, ver); errors.Is(err, storage.ErrNotFound) {\n    return nil // already deleted; treat as success\n}","handlingStrategy":"try-catch","validationCode":"if _, err := store.GetIssue(ctx, id); err != nil { return fmt.Errorf(\"missing issue %s before versioned op\", id) }","typeGuard":null,"tryCatchPattern":"if err := CheckVersionInTx(ctx, tx, id, expected); errors.Is(err, storage.ErrNotFound) {\n    return nil // already deleted; treat delete as idempotent success\n}","preventionTips":["Existence-check before versioned close/delete","Treat ErrNotFound in versioned deletes as success (idempotent delete)","Sync frequently so local ID references stay live"],"tags":["go","not-found","optimistic-lock","concurrency"],"backgroundTag":"issue-not-found","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}