{"record":{"id":"cb27e7046cabb853","repo":"gastownhall/beads","slug":"errnotreleasable","errorCode":"ErrNotReleasable","errorMessage":"issue status does not accept a release","messagePattern":"issue status does not accept a release","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"issueops/releaser.go","lineNumber":183,"sourceCode":"// most often because a reaper got there first — and the two facts are worth\n// telling apart. Callers for which \"already released\" is the ordinary path\n// errors.Is this and carry on; that is one line, and it is a line the caller\n// writes knowingly rather than one the role writes on its behalf.\nvar ErrNotClaimed = errors.New(\"issue holds no claim\")\n\n// ErrNotReleasable classifies the refusal of a release over a row whose STATUS\n// will not accept one: a closed issue, or an issue in any status other than the\n// two the release transition is defined over.\n//\n// IT IS WIDER THAN \"CLOSED\", and that is worth knowing before it surprises\n// someone. The release is an UPDATE pinned to status open or in_progress, so an\n// issue parked in a workspace's own configured status — an active or wip status\n// a `bd update` put it in — is refused here even though it plainly holds a\n// claim. That is a limitation of the transition rather than a policy, and it is\n// named rather than papered over: before this role, the same request failed\n// with an untyped \"no matching row\" that no caller could classify and no\n// message explained.\nvar ErrNotReleasable = errors.New(\"issue status does not accept a release\")\n\n// Releaser describes the release of a claim — the capability behind\n// `bd unclaim` — and, like every other capability here, a role with its own\n// accessor. A new capability gets a new role interface and its own accessor;\n// never append a method here.\n//\n// IT IS A DIFFERENT QUESTION FROM ITS NEIGHBORS.\n//\n//   - Claimer takes ownership. This gives it back, and the two are not one\n//     role with a flag: a caller entitled to release its own work is very\n//     often not entitled to take new work, and a surface carrying both hands\n//     it a capability it should not be able to reach. That is the same test\n//     Bootstrapper and InitVerifier were split by.\n//   - Lifecycle.Update can clear an assignee as one field among many, and\n//     that is exactly why this is separate. An update spells the release as a\n//     patch — assignee to empty, status to open, started_at to null — which\n//     puts the transition's definition in the CALLER, three fields at a time,\n//     and leaves the lease row behind. A release is one act with a known","sourceCodeStart":165,"sourceCodeEnd":201,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/issueops/releaser.go#L165-L201","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before: opaque failure\nif err := tx.ReleaseIssue(id); err != nil { return err }\n// after\ngo import \"errors\"\nif err := tx.ReleaseIssue(id); err != nil {\n    if errors.Is(err, issueops.ErrNotReleasable) {\n        return fmt.Errorf(\"issue %s is in a status that does not accept release; reset its status first\", id)\n    }\n    return err\n}","handlingStrategy":"type-guard","validationCode":"// before releasing, check the status accepts release\nst, _ := issue.GetStatus(id)\nif !releasableStatuses[st] {\n    return fmt.Errorf(\"skip release: issue %s in non-releasable status %s\", id, st)\n}","typeGuard":"func isNotReleasable(err error) bool { return errors.Is(err, issueops.ErrNotReleasable) }","tryCatchPattern":"if err := releaser.ReleaseIssue(tx, id); err != nil {\n    if isNotReleasable(err) {\n        // reset status or skip; do not retry blindly\n        return resetStatusThenRelease(id)\n    }\n    return err\n}","preventionTips":["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"],"tags":["go","sentinel-error","status-transition","claim-release"],"backgroundTag":"invalid-state-transition","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}