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 known

View on GitHub (pinned to 71377f2769)

Solutions

  1. 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
  2. Handle the sentinel with errors.Is(err, issueops.ErrNotReleasable) and surface a clear message instead of retrying
  3. 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

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


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