gastownhall/beads · error · storage.ErrValidation

%w: invalid forced assignee transfer

Error message

%w: invalid forced assignee transfer

What it means

ValidateUpdateRequest returns this when ForceAssigneeTransfer is enabled but its preconditions are violated: the request also sets Claim, the patch does not actually set an assignee, or ExpectedAssignee is present. Forced transfer is meant to be an unambiguous, standalone reassignment, so any of those combinations is rejected before the row is modified. The error wraps storage.ErrValidation.

Source

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

		{patch.DueAt.Set, "due_at", patch.DueAt.Value},
		{patch.DeferUntil.Set, "defer_until", patch.DeferUntil.Value},
	} {
		if field.set {
			updates[field.key] = field.val
		}
	}
	return updates
}

// ValidateUpdateRequest checks mutually exclusive guarded-update options and
// the canonical field values every backend must reject identically. Backends
// call it before touching the row so an invalid patch cannot half-apply.
func ValidateUpdateRequest(request publicops.UpdateRequest) error {
	if request.Claim && (request.ExpectedAssignee != nil || request.ExpectedStatus != nil) {
		return fmt.Errorf("%w: claim cannot use expected assignee or status", storage.ErrValidation)
	}
	if request.ForceAssigneeTransfer && (request.Claim || !request.Patch.Assignee.Set || request.ExpectedAssignee != nil) {
		return fmt.Errorf("%w: invalid forced assignee transfer", storage.ErrValidation)
	}
	patch := request.Patch
	if patch.Title.Set {
		if err := types.ValidateIssueTitle(patch.Title.Value); err != nil {
			return fmt.Errorf("%w: update title: %w", storage.ErrValidation, err)
		}
	}
	if patch.Priority.Set {
		if err := types.ValidateIssuePriority(patch.Priority.Value); err != nil {
			return fmt.Errorf("%w: update priority: %w", storage.ErrValidation, err)
		}
	}
	if patch.EstimatedMinutes.Set {
		if err := types.ValidateIssueEstimatedMinutes(patch.EstimatedMinutes.Value); err != nil {
			return fmt.Errorf("%w: update estimated_minutes: %w", storage.ErrValidation, err)
		}
	}
	if patch.Persistence.Set && !patch.Persistence.Value.IsValid() {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Ensure ForceAssigneeTransfer is combined with Patch.Assignee.Set=true and a target assignee value
  2. Remove Claim and ExpectedAssignee from the request when doing a forced transfer
  3. Send ForceAssigneeTransfer as the only guarded option on the request

Example fix

// before
req := publicops.UpdateRequest{ID: id, ForceAssigneeTransfer: true, Claim: true}
// after
req := publicops.UpdateRequest{
  ID: id,
  ForceAssigneeTransfer: true,
  Patch: publicops.UpdatePatch{Assignee: publicops.SetField[string]{Set: true, Value: newOwner}},
}
Defensive patterns

Strategy: validation

Validate before calling

func validForceTransfer(r publicops.UpdateRequest) bool {
  return r.ForceAssigneeTransfer && !r.Claim && r.ExpectedAssignee == nil && r.Patch.Assignee.Set
}
if !validForceTransfer(req) { return errors.New("invalid forced assignee transfer request") }

Type guard

func isForceTransferReady(r publicops.UpdateRequest) bool {
  return r.ForceAssigneeTransfer && r.Patch.Assignee.Set && !r.Claim && r.ExpectedAssignee == nil
}

Try / catch

if err := issueops.ValidateUpdateRequest(req); errors.Is(err, storage.ErrValidation) {
  // fix request: ensure Assignee set, drop Claim/ExpectedAssignee
}

Prevention

When it happens

Trigger: ExecuteUpdate with request.ForceAssigneeTransfer=true plus any of: request.Claim=true, request.Patch.Assignee.Set=false (no assignee supplied), or request.ExpectedAssignee != nil.

Common situations: Admin tooling that force-reassigns issues but builds the patch conditionally so Assignee ends up unset; copying a claim-style request and flipping on ForceAssigneeTransfer; a UI sending stale ExpectedAssignee fields alongside a force transfer.

Related errors


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