Files
gastownhall__beads/issueops/errors.go
Julian Knutsen 3b8e85bae3 Add issueops.Releaser: give a claim back, optionally only a named holder's (#5482)
* Add issueops.Releaser: give a claim back, optionally only a named holder's

Claiming has had its own role since Claimer; releasing has never had one.
`bd unclaim` reaches the raw seam directly, and everything that isn't the
CLI has had to spell 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 still leaves the lease row behind. A
release is one act with a known post-state, and the lease it drops is the
part a patch cannot express at all.

It is its own role rather than a method on Claimer for the reason
Bootstrapper and InitVerifier are two: a caller entitled to give its own
work back 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.

THE SHAPE MIRRORS THE RAW SEAM, and the two places it does not are both
refusals rather than permissions. UnclaimIssueInTx and
UnclaimIssueIfAssigneeInTx refuse an unheld row and a status the release
transition is not defined over with UNTYPED strings — "issue X is not
assigned" and, for the second, "no matching row", which explains nothing
and no caller can classify. The role reads the row first and names them:
ErrNotClaimed and ErrNotReleasable. Which requests are refused is
unchanged; the raw calls still run their own checks underneath.

ErrNotReleasable is wider than "closed" and the leaf says so out loud: the
release UPDATE is pinned to status open or in_progress, so an issue parked
in `blocked` while still holding a claim is refused. That is a limitation
of the transition, not a policy, and it is now visible instead of arriving
as "no matching row".

A RELEASE IS NOT IDEMPOTENT and this keeps it that way. An unheld row is
ErrNotClaimed with or without Force, not a Changed-false no-op — the raw
rule, and the one a reaper can errors.Is in a line it writes knowingly.
ExpectedAssignee is the compare-and-set: a match REPLACES the ownership
fence, exactly as UpdateRequest.ExpectedAssignee does for a transfer, so a
supervisor can free a named agent's claim without Force and without being
able to clobber a claim that has since moved. Force may not accompany it —
the two answer one question and disagree — and a non-nil pointer to "" is
ErrValidation rather than "expected unassigned", because releasing a row
nobody holds describes no release.

ErrNotOwner MOVES to issueops and internal/storage aliases it forward. A
refusal the public role raises has to be classifiable by a caller that
cannot import an internal package; a Go alias preserves identity in both
directions, so every existing errors.Is site keeps matching the same value.

All three legs share one body (internal/storage/issueops.ReleaseIssueInTx),
so this is one reading plus two wrapper checks rather than three backends
agreeing — and the wrapper check earned its keep again, in the same place
MetadataCAS's did. An EPHEMERAL release writes a row and versions nothing;
the unit-of-work leg's commit message is what commits its SQL transaction
as well as what versions it, so composing the message from the durable
table set rolled the write back and the wisp came out still claimed. Green
on both stores, red on the unit of work, exactly as the engdocs predicted.
The domain reach now answers Wrote and the store legs take Tables.

Eleven contract cases on all three legs. The post-state is read RAW, cell
by cell, because the whole reason to state it on the role was that a caller
should not have to reconstruct it — including the lease row, whose survival
is the failure no role-answer assertion can see. The attribution case
forces a release by someone who is NOT the holder, the only arrangement in
which stamping the holder is distinguishable from stamping the actor, and
counts entries per actor because created_at is second-granularity and an
ORDER BY would decide it on a coin toss.

The contract does NOT carry a case for a false ReleaseResult.Changed, and
says so: nothing returns it false, so the case could not fail. What can
fail is the decorator's use of it, which internal/storage/hook_releaser_test
drives with the answer the role reserves the right to give.

FACADE ONLY: no wire, spec or httpapi change, and no bd command re-pointed.
The wire half follows in the conditional-update / release wire slice per
the program's council gating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Delete the two release pre-checks a mutation proved could not fail

Written as defence in depth, measured as dead weight. ErrNotOwner and
ErrAssigneeMismatch already come back from UnclaimIssueInTx and
UnclaimIssueIfAssigneeInTx typed, wrapped and in this same order, so
removing both copies left the whole eleven-case contract green on the
server-backed leg — the mutation-equivalence verdict that says a branch
exists to look reassuring rather than to hold anything.

What stays is the pair the raw seam answers with a bare string: an unheld
row ("issue %s is not assigned") and a status the release transition is not
defined over ("no matching row"). Those are the refusals this body is FOR,
and each one goes red when it is skipped.

The header now says which refusals this function owns and which it leaves
to the calls below, so the absence reads as a decision rather than as a
check somebody forgot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Lint: one spelling of the holder, and drop two parameters nothing varies

golangci-lint's unparam found releaserSeedClaimed's holder and
releaserRowVersion's table taking one value at every call site. Both are now
gone, and the holder is one package constant rather than nine copies of the
same literal — which is worth more than the lint: half these cases are ABOUT
the difference between the holder and the caller, and two spellings of "the
holder" is how such a case comes to assert the wrong one.

Plus two British spellings misspell caught.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review: say why Changed is asymmetric with Claimer's, and three prose fixes

Doc only; no behaviour changes and none intended.

THE RULING ON ReleaseResult.Changed, written where it belongs. Keeping the
unheld-row refusal was defended here as "the rule the raw seam already
has", which is an appeal to precedent rather than a reason. The reason is a
fact about the two POST-STATES. Claimer can afford an idempotent answer
because a claim's post-state IDENTIFIES THE CLAIMANT: a re-claim finds
assignee == Actor and StatusInProgress, the claim's own signature surviving
on the row, so the role can tell "you already did this" from "somebody else
holds it". A release leaves an ANONYMOUS post-state — assignee cleared,
status open, started_at gone — which is the same row whoever emptied it and
whether or not it was ever full. An idempotent release would answer Changed
false identically for "I already released this", "a reaper beat me to it"
and "nothing ever claimed it", with nothing left on the row to tell them
apart. Those three want different things from a caller, so the role refuses
and lets the caller pick. ClaimResult.Changed is now named at the
comparison rather than merely gestured at.

ExpectedAssignee compares BYTE FOR BYTE, now said out loud: " agent-a" is
not agent-a and refuses. The validator trims only far enough to tell a
blank expectation from a real one and never writes the trimmed form back,
and the no-mutation promise makes that permanent — so a padded expectation
loses every time rather than intermittently, which is the failure worth
warning about.

And the deleted-pre-checks note now states the CONSEQUENCE rather than the
shortcut: one leg was sufficient evidence BECAUSE all three legs run this
function and it reaches the same two raw entry points, so there is no
second implementation in which a deleted pre-check could still have been
load-bearing. "A mutation verdict is only true of the body you mutated" is
answered by there being one body. Where the legs DO differ is in what they
version — hence ReleaseWrite's two facts and the wisp case on all three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Review Sim <ci@beads.test>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 07:48:56 -07:00

273 lines
13 KiB
Go

package issueops
import (
"errors"
"fmt"
"github.com/steveyegge/beads/beadserrors"
)
// ErrAlreadyClaimed is returned when attempting to claim an issue that is already
// claimed by another user. The error message contains the current assignee.
var ErrAlreadyClaimed = errors.New("issue already claimed")
// ErrNotClaimable is returned when attempting to claim an issue that is not in a
// claimable state, such as closed, deferred, or already in progress without the
// same actor owning the claim.
var ErrNotClaimable = errors.New("issue not claimable")
// ClaimConflictError reports the state that refused a claim — the current
// assignee and status, read inside the same transaction that lost the
// compare-and-set. It wraps the refusal rather than replacing it, so the
// sentinel still matches, the refusal's carefully-worded prose survives
// byte-for-byte, and a caller can classify the conflict from typed fields
// instead of parsing that prose.
//
// Err is set by every implementation that returns this type; a Claimer whose
// same-transaction re-read fails returns the bare refusal instead.
type ClaimConflictError struct {
// IssueID names the issue that refused the claim.
IssueID string
// Assignee is the holder observed by the losing transaction. It is empty
// when the refusal was about the status rather than a foreign holder.
Assignee string
// Status is the status observed by the losing transaction.
Status Status
// Err is the wrapped refusal. It matches ErrAlreadyClaimed or
// ErrNotClaimable.
Err error
}
func (e *ClaimConflictError) Error() string { return e.Err.Error() }
// Unwrap makes ClaimConflictError match the refusal it carries.
func (e *ClaimConflictError) Unwrap() error { return e.Err }
// ErrUnsupported reports a capability this backend does not serve. It is an
// alias of beadserrors.ErrUnsupported — the same type, so one errors.As arm
// matches it under either name — and it is re-exported here because a caller
// holding an issueops role should not have to discover a second package to
// classify the refusal.
//
// It is declared there rather than here because the capability shell is not an
// issue concept: a memory role can go unimplemented by a backend exactly as a
// Reader can.
type ErrUnsupported = beadserrors.ErrUnsupported
// ErrAssigneeMismatch is returned by a conditional release or update whose
// ExpectedAssignee does not match the issue's current assignee (including when
// the issue is no longer assigned at all). The caller's view of the claim was
// stale; the issue is left untouched. See Releaser.Release and
// UpdateRequest.ExpectedAssignee.
var ErrAssigneeMismatch = errors.New("assignee mismatch")
// ErrNotOwner is returned when an actor tries to release a claim that a
// different actor holds. Releasing another actor's claim requires the force
// escape hatch (ReleaseRequest.Force, `bd unclaim --force`), reserved for
// abandoned claims — or ReleaseRequest.ExpectedAssignee, which authorizes the
// same thing by naming the holder instead of ignoring it.
//
// It is DECLARED here and re-exported by internal/storage rather than the other
// way round, which is the direction the memoryops slice settled: a Go alias
// preserves identity in both directions, so every existing storage.ErrNotOwner
// reference keeps matching this identical value, and a caller holding only the
// public role can now classify the refusal without importing an internal
// package it cannot reach. It is the ownership half of the same vocabulary
// ErrAssigneeMismatch above belongs to, which is why it is here rather than
// beside Releaser: both refusals answer "whose claim is this".
var ErrNotOwner = errors.New("issue claimed by a different actor")
// The namespace-neutral part of this vocabulary is declared by beadserrors and
// re-exported here. These are ALIASES, so they are the same values: every
// existing issueops.ErrX reference and every errors.Is site keeps matching the
// identical error, and a leaf that never imports issueops still matches it too.
//
// They live down there because none of them names an issue: a request can be
// invalid, a row can be missing and a database can be uninitialized on any
// plane. The refusals BELOW that name issue concepts stay here.
var (
// ErrNotFound is returned when a requested entity does not exist in the database.
ErrNotFound = beadserrors.ErrNotFound
// ErrValidation classifies deterministic request-validation failures.
ErrValidation = beadserrors.ErrValidation
// ErrNotInitialized is returned when the database has not been initialized
// (e.g., issue_prefix config is missing).
ErrNotInitialized = beadserrors.ErrNotInitialized
)
// ErrPrefixMismatch is returned when an issue ID does not match the configured prefix.
var ErrPrefixMismatch = errors.New("prefix mismatch")
// ErrCloseBlocked is returned by CloseIssueChecked when an issue cannot be
// closed because it is still blocked (is_blocked=1: an open blocking dependency
// or an open blocking gate). Bypass with CloseIssueOptions.Force.
var ErrCloseBlocked = errors.New("cannot close blocked issue")
// ErrCloseOpenChildren is returned when an unforced close finds open
// parent-child dependents.
var ErrCloseOpenChildren = errors.New("cannot close issue with open children")
// CloseOpenChildrenError reports the issue and open-child count that refused a
// guarded close.
type CloseOpenChildrenError struct {
IssueID string
OpenChildren int
}
func (e *CloseOpenChildrenError) Error() string {
return fmt.Sprintf("cannot close %s: %d open child issue(s); close children first or use --force to override", e.IssueID, e.OpenChildren)
}
// Unwrap makes CloseOpenChildrenError match ErrCloseOpenChildren.
func (e *CloseOpenChildrenError) Unwrap() error {
return ErrCloseOpenChildren
}
// ErrAlreadyExists is returned when a create operation is given an ID that is
// already occupied. The issue and wisp tables share one ID space.
var ErrAlreadyExists = errors.New("issue already exists")
// ErrAlreadyIdentified is returned by Bootstrapper.Bootstrap when the substrate
// already carries a workspace identity. It is its own sentinel rather than
// ErrAlreadyExists because that one is about an occupied ISSUE ID in a shared
// id space, and a caller that classifies the two together would answer a
// re-init with advice about `bd update`.
var ErrAlreadyIdentified = errors.New("workspace already identified")
// AlreadyIdentifiedError reports the identity a substrate was found carrying
// when a bootstrap was refused, read inside the same transaction that would
// have written over it.
//
// It carries the pair rather than formatting it away because the two things a
// caller does next both need the values: adopting the identity needs them, and
// telling a COMPLETE identity apart from a half-written one — the state a
// bootstrap that failed partway leaves on a substrate with no transactions —
// means looking at which of the two is empty.
type AlreadyIdentifiedError struct {
// Prefix is the issue prefix found, or "" when the substrate carried none.
Prefix string
// ProjectID is the project identity found, or "" when the substrate
// carried none.
ProjectID string
}
func (e *AlreadyIdentifiedError) Error() string {
switch {
case e.Prefix != "" && e.ProjectID != "":
return fmt.Sprintf("workspace already identified as prefix %q, project %s", e.Prefix, e.ProjectID)
case e.Prefix != "":
return fmt.Sprintf("workspace already identified as prefix %q with no project id", e.Prefix)
default:
return fmt.Sprintf("workspace already identified as project %s with no issue prefix", e.ProjectID)
}
}
// Unwrap makes AlreadyIdentifiedError match ErrAlreadyIdentified.
func (e *AlreadyIdentifiedError) Unwrap() error { return ErrAlreadyIdentified }
// ErrVersionMismatch is returned by a *Checked op given an ExpectedVersion that
// no longer matches the row's current version (row_lock) — an optimistic
// concurrency failure. Callers errors.Is it to distinguish a lost-update
// precondition from other errors.
var ErrVersionMismatch = errors.New("version mismatch")
// ErrStatusMismatch is returned by UpdateIssueChecked given an ExpectedStatus
// that no longer matches the issue's current status. The caller's view of the
// issue was stale; the issue is left untouched. The assignee analog is
// ErrAssigneeMismatch, shared with UnclaimIssueIfAssignee.
var ErrStatusMismatch = errors.New("status mismatch")
// ErrSelfDependency is returned when a dependency edge would point an issue at
// itself. It is the static prefix of the formatted message, wrapped so callers
// can errors.Is it while the human-readable text is preserved byte-for-byte.
var ErrSelfDependency = errors.New("cannot add self-dependency")
// ErrDependencyCycle is returned when adding a dependency edge would introduce a
// scheduling cycle. It is scoped to the dependency-add family — the single and
// bulk add paths (add/AddDependencies) and the dolt cross-tier check — so
// callers can errors.Is any dependency-add cycle rejection. The whole-graph
// construction paths (ApplyIssueGraph/ApplyWispGraph) are a separate family and
// deliberately do not carry this sentinel yet.
var ErrDependencyCycle = errors.New("adding dependency would create a cycle")
// DependencyTypeConflictError is returned when an edge already exists between
// the same pair with a DIFFERENT type. Its message is byte-identical to the
// embedded issueops path (internal/storage/issueops/dependencies.go) so
// `bd dep add` surfaces the same user-facing retype error on the domain/db seam
// as on the embedded store. It is a typed error so the use-case can pass it
// through unwrapped instead of burying it under an "add dep: insert:" prefix.
type DependencyTypeConflictError struct {
IssueID string
DependsOnID string
ExistingType string
RequestedType string
}
func (e *DependencyTypeConflictError) Error() string {
return fmt.Sprintf("dependency %s -> %s already exists with type %q (requested %q); remove it first with 'bd dep remove' then re-add",
e.IssueID, e.DependsOnID, e.ExistingType, e.RequestedType)
}
// ErrDependencySourceNotFound is returned when an edge's SOURCE names no row
// this database holds. An edge follows its source, so there is no plane for it
// to land in and no event stream to record it on.
var ErrDependencySourceNotFound = errors.New("dependency source not found")
// ErrDependencyTargetNotFound is returned when an edge's TARGET names no row
// this database holds AND is one whose absence this database can SEE: an id in
// its own namespace, carrying no "external:" marker. An "external:" reference
// and an id belonging to another repository are accepted as external targets,
// so neither raises this.
//
// It is a separate sentinel from the source's because the two are separate
// answers. A ghost source is always a bad id; a target is only refused when
// this database is the one that would have held it, which is a narrower claim
// and the one a caller has to reason about before retrying.
var ErrDependencyTargetNotFound = errors.New("dependency target not found")
// DependencyEndpointNotFoundError reports which endpoint of a refused edge was
// absent, read inside the transaction that refused it. It wraps the refusal
// rather than replacing it, so the sentinel still matches, the message stays
// what it was, and a caller classifies the refusal from typed fields instead of
// parsing prose — the ClaimConflictError arrangement, applied to the graph.
//
// It carries the whole edge rather than only the missing id because the request
// is all-or-nothing: the refusal is the REQUEST's, so a caller reporting which
// of its own edges was rejected has to find it by both endpoints.
type DependencyEndpointNotFoundError struct {
// IssueID and DependsOnID name the refused edge.
IssueID string
DependsOnID string
// MissingID is the endpoint that named no row: IssueID for a ghost source,
// DependsOnID for a missing target.
MissingID string
// Err is the wrapped refusal. It matches ErrDependencySourceNotFound or
// ErrDependencyTargetNotFound.
Err error
}
func (e *DependencyEndpointNotFoundError) Error() string {
return fmt.Sprintf("issue %s not found", e.MissingID)
}
// Unwrap makes DependencyEndpointNotFoundError match the refusal it carries.
func (e *DependencyEndpointNotFoundError) Unwrap() error { return e.Err }
// DependencyHierarchyConflictError is returned when a blocking dependency
// would gate an issue on one of its own ancestors or descendants. Either shape
// can never clear under the parent-child close/blocking semantics.
type DependencyHierarchyConflictError struct {
IssueID string
BlockerID string
BlockerIsAncestor bool
}
func (e *DependencyHierarchyConflictError) Error() string {
if e.BlockerIsAncestor {
return fmt.Sprintf("%s cannot be blocked by its ancestor %s: %s cannot close until its descendants finish, so the gate would never clear",
e.IssueID, e.BlockerID, e.BlockerID)
}
return fmt.Sprintf("%s cannot be blocked by its descendant %s: blocked status cascades to descendants, so %s would inherit the block and never close",
e.IssueID, e.BlockerID, e.BlockerID)
}