gastownhall/beads · error · storage.ErrNotFound
%w: issue %s
Error message
%w: issue %s
What it means
CheckExpectedFieldsInTx performs compare-and-swap style validation of expected assignee/status before an update. When the issue row does not exist (sql.ErrNoRows on the SELECT), it returns storage.ErrNotFound wrapped with the issue id so callers can branch on a sentinel via errors.Is.
Source
Thrown at internal/storage/issueops/update_cas.go:47
// fix already shipped for UnclaimIssueInTx's SQL-CAS predicate and
// AuthorizeAssigneeTransferWithPools; this was the third, previously-split
// verbatim-comparison surface (ga-5ksp5, gate review on #5439).
//
//nolint:gosec // G201: table name comes from WispTableRouting (hardcoded constants)
func CheckExpectedFieldsInTx(ctx context.Context, tx DBTX, id string, expectedAssignee, expectedStatus *string) error {
if expectedAssignee == nil && expectedStatus == nil {
return nil
}
isWisp := IsActiveWispInTx(ctx, tx, id)
issueTable, _, _, _ := WispTableRouting(isWisp)
var assignee sql.NullString
var status string
err := tx.QueryRowContext(ctx,
fmt.Sprintf("SELECT assignee, status FROM %s WHERE id = ?", issueTable), id,
).Scan(&assignee, &status)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
}
if err != nil {
return fmt.Errorf("failed to read assignee/status for %s: %w", id, err)
}
if expectedAssignee != nil && !actorMatches(assignee.String, *expectedAssignee) {
return fmt.Errorf("%w: %s is held by %q, expected %q", storage.ErrAssigneeMismatch, id, assignee.String, *expectedAssignee)
}
if expectedStatus != nil && status != *expectedStatus {
return fmt.Errorf("%w: %s has status %q, expected %q", storage.ErrStatusMismatch, id, status, *expectedStatus)
}
return nil
}
View on GitHub (pinned to 71377f2769)
Solutions
- Confirm the issue ID exists before the CAS update
- Handle storage.ErrNotFound explicitly via errors.Is and surface 'issue not found' rather than a generic CAS failure
- Re-sync local state (bd sync / refetch) if the issue was expected to exist
- Fix the ID source — scripts that parse IDs from output may pick up stale values
Example fix
// before
err := ExecuteUpdate(ctx, tx, id, updates, &ExpectedFields{Status: &s}) // generic failure
// after
if err := CheckExpectedFieldsInTx(ctx, tx, id, expStatus, expAssignee); err != nil {
if errors.Is(err, storage.ErrNotFound) { return fmt.Errorf("issue %s was deleted; aborting CAS update", id) }
} Defensive patterns
Strategy: try-catch
Validate before calling
if _, err := store.GetIssue(ctx, id); err != nil {
return fmt.Errorf("cannot CAS-update missing issue %s", id)
} Try / catch
err := ExecuteUpdate(ctx, tx, id, updates, exp)
if errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("issue %s no longer exists; aborting CAS", id)
} Prevention
- Refresh issue IDs from a live query, not stale caches
- Expect deletion races in multi-agent setups and code for ErrNotFound
- Validate ID format/length before issuing CAS calls
When it happens
Trigger: ExecuteUpdate is called with ExpectedFields (expected assignee or status) for an issue id that has no row in the issues table — the issue was deleted, never created, or the ID is wrong.
Common situations: Stale client cache referencing a deleted issue; optimistic-concurrency CAS flows racing with a concurrent delete; typo'd or truncated ID passed to a scripted update.
Related errors
- %w: %s is held by %q, expected %q
- %w: %s has status %q, expected %q
- %w: issue %s
- no store is open for this workspace
- ErrLockHeld
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/5df46d19f0805972.
Report an issue: GitHub.