gastownhall/beads · error

conditional unclaim of %s: expected assignee must not be emp

Error message

conditional unclaim of %s: expected assignee must not be empty (use UnclaimIssueInTx for an unconditional release)

What it means

UnclaimIssueIfAssigneeInTx performs a conditional (compare-and-swap) release, so it requires a non-empty expectedAssignee to compare against. An empty expectedAssignee would make the condition meaningless (and would 'match' the already-unclaimed state), so the library refuses it and points to UnclaimIssueInTx for an unconditional release.

Source

Thrown at internal/storage/issueops/unclaim.go:155

// still assigned to expectedAssignee — the compare-and-swap inverse of
// ClaimIssueInTx: a Go-side actorMatches precheck (ga-5ksp5) plus a conditional
// UPDATE CASed on row_lock, with RowsAffected as the verdict, so a stale
// releaser can never clobber a claim that has since moved to (or been
// re-taken by) someone else. "Still assigned to expectedAssignee" is judged
// under actorMatches, not verbatim equality, so a caller naming the current
// holder under a different layer's spelling of the same identity is a match,
// not a mismatch — see canonicalActor. On success it applies the same
// transition as UnclaimIssueInTx (assignee cleared, status reopened,
// started_at cleared, lease dropped, row_lock rewritten, "unclaimed" event
// recorded). When the current assignee does not match expectedAssignee —
// including when the issue is no longer assigned at all — it returns
// storage.ErrAssigneeMismatch naming the current holder and leaves the row
// untouched. actor is recorded as the event author.
//
//nolint:gosec // G201: table names come from WispTableRouting (hardcoded constants)
func UnclaimIssueIfAssigneeInTx(ctx context.Context, tx DBTX, id string, actor string, expectedAssignee string) error {
	if expectedAssignee == "" {
		return fmt.Errorf("conditional unclaim of %s: expected assignee must not be empty (use UnclaimIssueInTx for an unconditional release)", id)
	}

	// Route to the correct table (issues/wisps) automatically, matching
	// UnclaimIssueInTx.
	isWisp := IsActiveWispInTx(ctx, tx, id)
	issueTable, _, eventTable, _ := WispTableRouting(isWisp)

	oldIssue, err := GetIssueInTx(ctx, tx, id)
	if err != nil {
		return fmt.Errorf("failed to get issue for unclaim: %w", err)
	}

	// Validate: cannot unclaim closed issues.
	if oldIssue.Status == types.StatusClosed {
		return fmt.Errorf("cannot unclaim closed issue %s", id)
	}

	// Compare-and-swap precheck: a mismatched holder — including an

View on GitHub (pinned to 71377f2769)

Solutions

  1. Populate expectedAssignee with the holder you expect before calling.
  2. If you want an unconditional release, call UnclaimIssueInTx(ctx, tx, id, actor, force) instead.
  3. Guard the call site: skip the conditional unclaim when the expected assignee is unknown.

Example fix

// before
err := issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, expected) // expected == ""
// after
if expected == "" {
    err = issueops.UnclaimIssueInTx(ctx, tx, id, actor, false)
} else {
    err = issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, expected)
}
Defensive patterns

Strategy: validation

Validate before calling

if expectedAssignee == "" {
    return issueops.UnclaimIssueInTx(ctx, tx, id, actor, force) // unconditional release
}

Type guard

func validExpectedAssignee(s string) bool { return strings.TrimSpace(s) != "" }

Prevention

When it happens

Trigger: Calling UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, "") — e.g. the expectedAssignee variable was never populated, or the caller actually wanted an unconditional release and used the wrong function.

Common situations: A caller copies the expected assignee from a field that is empty (already-released issue); a refactor swaps UnclaimIssueIfAssigneeInTx in where UnclaimIssueInTx was intended; config/env supplying the expected holder is missing.

Related errors


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