{"record":{"id":"f78d42879457f5a9","repo":"gastownhall/beads","slug":"conditional-unclaim-of-s-expected-assignee-must","errorCode":null,"errorMessage":"conditional unclaim of %s: expected assignee must not be empty (use UnclaimIssueInTx for an unconditional release)","messagePattern":"conditional unclaim of (.+?): expected assignee must not be empty \\(use UnclaimIssueInTx for an unconditional release\\)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/unclaim.go","lineNumber":155,"sourceCode":"// still assigned to expectedAssignee — the compare-and-swap inverse of\n// ClaimIssueInTx: a Go-side actorMatches precheck (ga-5ksp5) plus a conditional\n// UPDATE CASed on row_lock, with RowsAffected as the verdict, so a stale\n// releaser can never clobber a claim that has since moved to (or been\n// re-taken by) someone else. \"Still assigned to expectedAssignee\" is judged\n// under actorMatches, not verbatim equality, so a caller naming the current\n// holder under a different layer's spelling of the same identity is a match,\n// not a mismatch — see canonicalActor. On success it applies the same\n// transition as UnclaimIssueInTx (assignee cleared, status reopened,\n// started_at cleared, lease dropped, row_lock rewritten, \"unclaimed\" event\n// recorded). When the current assignee does not match expectedAssignee —\n// including when the issue is no longer assigned at all — it returns\n// storage.ErrAssigneeMismatch naming the current holder and leaves the row\n// untouched. actor is recorded as the event author.\n//\n//nolint:gosec // G201: table names come from WispTableRouting (hardcoded constants)\nfunc UnclaimIssueIfAssigneeInTx(ctx context.Context, tx DBTX, id string, actor string, expectedAssignee string) error {\n\tif expectedAssignee == \"\" {\n\t\treturn fmt.Errorf(\"conditional unclaim of %s: expected assignee must not be empty (use UnclaimIssueInTx for an unconditional release)\", id)\n\t}\n\n\t// Route to the correct table (issues/wisps) automatically, matching\n\t// UnclaimIssueInTx.\n\tisWisp := IsActiveWispInTx(ctx, tx, id)\n\tissueTable, _, eventTable, _ := WispTableRouting(isWisp)\n\n\toldIssue, err := GetIssueInTx(ctx, tx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get issue for unclaim: %w\", err)\n\t}\n\n\t// Validate: cannot unclaim closed issues.\n\tif oldIssue.Status == types.StatusClosed {\n\t\treturn fmt.Errorf(\"cannot unclaim closed issue %s\", id)\n\t}\n\n\t// Compare-and-swap precheck: a mismatched holder — including an","sourceCodeStart":137,"sourceCodeEnd":173,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/unclaim.go#L137-L173","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Populate expectedAssignee with the holder you expect before calling.","If you want an unconditional release, call UnclaimIssueInTx(ctx, tx, id, actor, force) instead.","Guard the call site: skip the conditional unclaim when the expected assignee is unknown."],"exampleFix":"// before\nerr := issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, expected) // expected == \"\"\n// after\nif expected == \"\" {\n    err = issueops.UnclaimIssueInTx(ctx, tx, id, actor, false)\n} else {\n    err = issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, expected)\n}","handlingStrategy":"validation","validationCode":"if expectedAssignee == \"\" {\n    return issueops.UnclaimIssueInTx(ctx, tx, id, actor, force) // unconditional release\n}","typeGuard":"func validExpectedAssignee(s string) bool { return strings.TrimSpace(s) != \"\" }","tryCatchPattern":null,"preventionTips":["Never call UnclaimIssueIfAssigneeInTx with an empty expectedAssignee.","Use UnclaimIssueInTx when you do not know or care who holds the claim.","Validate the expected holder at the call site before invoking conditional releases.","Fail fast if the expectedAssignee source (config/state) returns an empty value."],"tags":["validation","api-misuse","storage"],"backgroundTag":"empty-required-parameter","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}