gastownhall/beads · error · storage.ErrAlreadyClaimed

%w: issue %s is assigned to %q

Error message

%w: issue %s is assigned to %q

What it means

This error wraps storage.ErrAlreadyClaimed and is returned by AuthorizeAssigneeTransferWithPools when an issue has an assignee that is not covered by any allowed 'pool' value, blocking an unguarded transfer of an active assignment. The library throws it to protect active assignments from being silently reassigned through an update request without going through the proper transfer/authorization path.

Source

Thrown at internal/storage/issueops/aggregate.go:173

// because the caller named the holder under a different layer's spelling.
//
// Passing nil pools answers every question except pool membership, so a caller
// that wants the config read only when it matters calls with nil first and
// re-evaluates with the loaded aliases on refusal.
func AuthorizeAssigneeTransferWithPools(before *types.Issue, request publicops.UpdateRequest, pools []string) error {
	if !request.Patch.Assignee.Set || actorMatches(request.Patch.Assignee.Value, before.Assignee) || request.ExpectedAssignee != nil || request.ForceAssigneeTransfer || before.Status != types.StatusInProgress || before.Assignee == "" || actorMatches(before.Assignee, request.Actor) {
		return nil
	}
	// Exact-string membership, deliberately not actorMatches (ga-v2k49, same
	// reason as claim.go's identical pool checks): a pool alias is a literal
	// claim.pools config value, not a Gas Town identity that gets respelled
	// per layer, so there is no cross-spelling variant to reconcile.
	for _, pool := range pools {
		if pool == before.Assignee {
			return nil
		}
	}
	return fmt.Errorf("%w: issue %s is assigned to %q", storage.ErrAlreadyClaimed, before.ID, before.Assignee)
}

// AuthorizeAssigneeTransfer protects an active assignment from unguarded transfer.
func AuthorizeAssigneeTransfer(ctx context.Context, tx DBTX, before *types.Issue, request publicops.UpdateRequest) error {
	if err := AuthorizeAssigneeTransferWithPools(before, request, nil); err == nil {
		return nil
	}
	pools, err := ClaimPoolAliasesInTx(ctx, tx)
	if err != nil {
		return err
	}
	return AuthorizeAssigneeTransferWithPools(before, request, pools)
}

// ApplyMetadataPatch returns the canonical metadata value and whether it changes.
func ApplyMetadataPatch(current json.RawMessage, patch publicops.MetadataPatch) (json.RawMessage, bool, error) {
	if !patch.Replace.Set && !patch.Merge.Set && len(patch.Set) == 0 && len(patch.Unset) == 0 {
		return current, false, nil

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check the issue's current assignee before updating; if set, use the proper assignment-release flow (clear the assignee with the correct authorization) instead of a plain update.
  2. Pass the correct pools slice to AuthorizeAssigneeTransferWithPools so the current assignee's pool is included and the transfer is authorized.
  3. If the assignment is stale (agent died), explicitly release/reclaim the issue through the supported claim workflow, then retry the update.

Example fix

// before
err := issueops.AuthorizeAssigneeTransferWithPools(before, req, nil)
// after
pools := []string{"pool-alpha", agentName} // include the pool that owns the current assignee
err := issueops.AuthorizeAssigneeTransferWithPools(before, req, pools)
if errors.Is(err, storage.ErrAlreadyClaimed) {
    // release or coordinate before updating
}
Defensive patterns

Strategy: validation

Validate before calling

func canTransfer(before *types.Issue, pools []string) bool {
    return before == nil || before.Assignee == "" || slices.Contains(pools, before.Assignee)
}

Type guard

func isAlreadyClaimed(err error) bool { return errors.Is(err, storage.ErrAlreadyClaimed) }

Try / catch

if err := issueops.AuthorizeAssigneeTransfer(ctx, tx, before, req); err != nil {
    if errors.Is(err, storage.ErrAlreadyClaimed) {
        return fmt.Errorf("issue %s held by %q; release or include its pool first", before.ID, before.Assignee)
    }
    return err
}

Prevention

When it happens

Trigger: Calling AuthorizeAssigneeTransfer (or AuthorizeAssigneeTransferWithPools) with an UpdateRequest that would change or touch an issue whose before.Assignee is non-empty and does not match any entry in the pools slice (pools is nil or does not contain the current assignee).

Common situations: Agents or scripts run `bd update` on an issue that another agent already claimed; concurrent automation both assigning the same issue; passing the wrong pools list (or nil) when the caller only wants to allow reassignment within known agent pools.

Related errors


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