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

  1. Confirm the issue ID exists before the CAS update
  2. Handle storage.ErrNotFound explicitly via errors.Is and surface 'issue not found' rather than a generic CAS failure
  3. Re-sync local state (bd sync / refetch) if the issue was expected to exist
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/5df46d19f0805972. Report an issue: GitHub.