gastownhall/beads · error
ErrNotReleasable
ErrNotReleasable
Error message
issue status does not accept a release
What it means
ErrNotReleasable is a typed sentinel returned when `bd unclaim` (issue release) is attempted on an issue whose current status does not accept a release transition — e.g. the issue sits in a workspace's own configured active/wip status. It replaces a previous untyped 'no matching row' failure so callers can classify the refusal via errors.Is. It is a limitation of the status-transition table, not a claim-policy judgment: the issue plainly holds a claim, but the transition from its current status to released has no matching row.
Source
Thrown at issueops/releaser.go:183
// most often because a reaper got there first — and the two facts are worth
// telling apart. Callers for which "already released" is the ordinary path
// errors.Is this and carry on; that is one line, and it is a line the caller
// writes knowingly rather than one the role writes on its behalf.
var ErrNotClaimed = errors.New("issue holds no claim")
// ErrNotReleasable classifies the refusal of a release over a row whose STATUS
// will not accept one: a closed issue, or an issue in any status other than the
// two the release transition is defined over.
//
// IT IS WIDER THAN "CLOSED", and that is worth knowing before it surprises
// someone. The release is an UPDATE pinned to status open or in_progress, so an
// issue parked in a workspace's own configured status — an active or wip status
// a `bd update` put it in — is refused here even though it plainly holds a
// claim. That is a limitation of the transition rather than a policy, and it is
// named rather than papered over: before this role, the same request failed
// with an untyped "no matching row" that no caller could classify and no
// message explained.
var ErrNotReleasable = errors.New("issue status does not accept a release")
// Releaser describes the release of a claim — the capability behind
// `bd unclaim` — and, like every other capability here, a role with its own
// accessor. A new capability gets a new role interface and its own accessor;
// never append a method here.
//
// IT IS A DIFFERENT QUESTION FROM ITS NEIGHBORS.
//
// - Claimer takes ownership. This gives it back, and the two are not one
// role with a flag: a caller entitled to release its own work is very
// often not entitled to take new work, and a surface carrying both hands
// it a capability it should not be able to reach. That is the same test
// Bootstrapper and InitVerifier were split by.
// - Lifecycle.Update can clear an assignee as one field among many, and
// that is exactly why this is separate. An update spells the release as a
// patch — assignee to empty, status to open, started_at to null — which
// puts the transition's definition in the CALLER, three fields at a time,
// and leaves the lease row behind. A release is one act with a knownView on GitHub (pinned to 71377f2769)
Solutions
- Check the issue's current status (bd show <id>) and move it back to a status that accepts release, e.g. bd update <id> --status open, then retry unclaim
- Handle the sentinel with errors.Is(err, issueops.ErrNotReleasable) and surface a clear message instead of retrying
- If the workspace status should legally allow releases, extend the status-transition table so the release transition from that status exists
Example fix
// before: opaque failure
if err := tx.ReleaseIssue(id); err != nil { return err }
// after
go import "errors"
if err := tx.ReleaseIssue(id); err != nil {
if errors.Is(err, issueops.ErrNotReleasable) {
return fmt.Errorf("issue %s is in a status that does not accept release; reset its status first", id)
}
return err
} Defensive patterns
Strategy: type-guard
Validate before calling
// before releasing, check the status accepts release
st, _ := issue.GetStatus(id)
if !releasableStatuses[st] {
return fmt.Errorf("skip release: issue %s in non-releasable status %s", id, st)
} Type guard
func isNotReleasable(err error) bool { return errors.Is(err, issueops.ErrNotReleasable) } Try / catch
if err := releaser.ReleaseIssue(tx, id); err != nil {
if isNotReleasable(err) {
// reset status or skip; do not retry blindly
return resetStatusThenRelease(id)
}
return err
} Prevention
- Check issue status before calling bd unclaim
- Treat ErrNotReleasable as a classified refusal, not a transient failure
- Extend the transition table when adding workspace statuses that must allow release
When it happens
Trigger: Calling ReleaseIssueInTx (the Releaser role behind `bd unclaim`) on an issue whose status column holds a workspace-configured active or wip status that was set via `bd update` and has no release transition defined.
Common situations: A user moves an issue into a custom or workspace-specific in-progress status with `bd update`, then tries `bd unclaim`; automated scripts that release claims after status changes hit the refusal; users migrating from versions where this failed with an opaque 'no matching row' error.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- ErrTransaction
- ErrQuery
- ErrScan
- ErrReadOnly
- validateExplicitPath sentinel (ErrNotFound/ErrNotExecutable)
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/cb27e7046cabb853.
Report an issue: GitHub.