gastownhall/beads · error · storage.ErrNotFound

storage.ErrNotFound

storage.ErrNotFound

Error message

%w: issue %s

What it means

ExecuteClaim refuses to claim an issue whose ID refers to an active wisp row. The wisp plane is deliberately not claimable through this path, and the refusal happens before the pre-image read and before any write that would need rollback. It surfaces as storage.ErrNotFound wrapping the issue ID.

Source

Thrown at internal/storage/issueops/public_claim.go:33

// commit. It lives here so every implementation of the claim role spells it
// identically and `bd dolt log` reads the same on all of them. The actor is in
// it because that line IS the audit trail — which is why every surface
// reaching the role validates the actor before calling it.
func ClaimCommitMessage(issueID, actor string) string {
	return fmt.Sprintf("bd: claim %s by %s", issueID, actor)
}

// ExecuteClaim applies a guarded claim in tx and reports durable tables changed.
func ExecuteClaim(ctx context.Context, tx *sql.Tx, request publicops.ClaimRequest) (publicops.ClaimResult, ChangedTables, error) {
	if request.Actor == "" || request.IssueID == "" {
		return publicops.ClaimResult{}, nil, fmt.Errorf("%w: claim requires actor and issue ID", storage.ErrValidation)
	}
	// ClaimIssueInTx routes a wisp id to the wisp tables and claims it there.
	// The role deliberately does not: the wisp plane is not claimable through
	// it, and the refusal lands here — before the pre-image read, and before
	// any write the enclosing transaction would have to roll back.
	if IsActiveWispInTx(ctx, tx, request.IssueID) {
		return publicops.ClaimResult{}, nil, fmt.Errorf("%w: issue %s", storage.ErrNotFound, request.IssueID)
	}
	claimed, err := ClaimIssueInTx(ctx, tx, request.IssueID, request.Actor)
	if err != nil {
		return publicops.ClaimResult{}, nil, classifyClaimRefusalInTx(ctx, tx, request.IssueID, err)
	}
	// The CAS matches no row when the actor already holds the issue in
	// progress, and ClaimIssueInTx reports that as success. The pre-image is
	// what tells the two apart, and staging nothing for the idempotent case is
	// what keeps a polling caller from minting empty version-control commits.
	// Judged under actorMatches, not verbatim (ga-v2k49): a caller re-claiming
	// its own in-progress issue under a different layer's spelling of its own
	// identity (ga-wzl83) is still a no-op — ClaimIssueInTx's own idempotency
	// check already agrees under the same comparison, and this site would
	// otherwise disagree with it and stage a phantom mutation for a request
	// that wrote nothing (caught by TestExecuteClaimIdempotentReclaimAcross-
	// SpellingStagesNothing during this fix, not cited by the original review).
	tables := ChangedTables{}
	changed := claimed.OldIssue.Status != types.StatusInProgress || !actorMatches(claimed.OldIssue.Assignee, request.Actor)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Use the wisp-plane claim path (ClaimIssueInTx routes wisp IDs) instead of ExecuteClaim for wisp IDs
  2. Verify the ID is a regular issue ID before claiming; check it exists in the issues table
  3. Fix upstream ID routing so wisp IDs never reach ExecuteClaim

Example fix

// before
res, err := ExecuteClaim(ctx, tx, publicops.ClaimRequest{Actor: actor, IssueID: someWispID})
// after
if strings.HasPrefix(someWispID, "wisp-") {
    return fmt.Errorf("wisp issues are not claimable via ExecuteClaim")
}
res, err := ExecuteClaim(ctx, tx, publicops.ClaimRequest{Actor: actor, IssueID: someWispID})
Defensive patterns

Strategy: type-guard

Validate before calling

func isWispID(id string) bool { return strings.HasPrefix(id, "wisp-") }

Type guard

func claimableID(id string) (string, bool) {
  if id == "" || strings.HasPrefix(id, "wisp-") {
    return "", false
  }
  return id, true
}

Try / catch

if err != nil {
  if errors.Is(err, storage.ErrNotFound) {
    // may be a wisp ID routed to the wrong plane, or genuinely missing
    return fmt.Errorf("issue not claimable: %w", err)
  }
  return err
}

Prevention

When it happens

Trigger: Calling ExecuteClaim with an IssueID that IsActiveWispInTx identifies as an active wisp — i.e. the ID lives in the wisp tables rather than the regular issues tables.

Common situations: Passing a wisp-prefixed ID (wisp plane) to the standard claim path; a bug upstream that routes wisp IDs into claim requests; confusing transient wisp records with durable issues.

Related errors


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