gastownhall/beads · error · storage.ErrValidation
%w: refusing to clear closed_at on %s: its status stays %q,
Error message
%w: refusing to clear closed_at on %s: its status stays %q, and a closed issue must keep a closed_at; reopen it with a status update instead
What it means
ValidateClosedAtCoherence rejects an update that clears closed_at while the issue's resulting status is closed. A closed issue must always carry its closed_at timestamp; to reopen, change status first. The error wraps storage.ErrValidation.
Source
Thrown at internal/storage/issueops/update.go:138
landedStatus := oldIssue.Status
if rawStatus, hasStatus := updates["status"]; hasStatus {
switch value := rawStatus.(type) {
case string:
landedStatus = types.Status(value)
case types.Status:
landedStatus = value
default:
// A status Go type nobody can read is already CrossesIntoDoneCategoryInTx's
// refusal in both funnels; leave that the single message for it.
return nil
}
}
clearing := clearsClosedAt(rawClosedAt)
switch {
case landedStatus == types.StatusClosed && clearing:
return fmt.Errorf("%w: refusing to clear closed_at on %s: its status stays %q, and a closed issue must keep a closed_at; reopen it with a status update instead",
storage.ErrValidation, oldIssue.ID, landedStatus)
case landedStatus != types.StatusClosed && !clearing:
return fmt.Errorf("%w: refusing to set closed_at on %s: its status stays %q, and only a closed issue may carry a closed_at; set status=closed in the same update to close it",
storage.ErrValidation, oldIssue.ID, landedStatus)
}
return nil
}
// clearsClosedAt reports whether an allowlisted closed_at value blanks the
// column. It accepts the same nil shapes matchesTimePointer treats as empty, so
// the guard and the no-op filter agree on what "no closed_at" means.
func clearsClosedAt(value interface{}) bool {
switch typed := value.(type) {
case nil:
return true
case *time.Time:
return typed == nil
default:View on GitHub (pinned to 71377f2769)
Solutions
- Include status="open" (a non-closed status) in the same update when clearing closed_at, i.e. reopen properly.
- Remove closed_at from the update map if you did not intend to clear it.
- Use the dedicated reopen path instead of hand-editing closed_at.
Example fix
// before
updates := map[string]interface{}{"closed_at": ""}
// after
updates := map[string]interface{}{"closed_at": "", "status": "open"} Defensive patterns
Strategy: validation
Validate before calling
if strings.EqualFold(issue.Status, string(types.StatusClosed)) && clearsClosedAt(updates["closed_at"]) {
return errors.New("cannot clear closed_at on a closed issue; reopen it first")
} Type guard
func clearsClosedAt(v interface{}) bool { return v == "" || v == nil } Try / catch
if err := storage.UpdateIssue(ctx, id, updates, actor); err != nil {
if errors.Is(err, storage.ErrValidation) && strings.Contains(err.Error(), "clear closed_at") {
// add status=open to the same update and retry once
}
return err
} Prevention
- Never blank closed_at without also changing status in the same update.
- To reopen an issue, set status=open and let closed_at clearing be part of that same update.
- Treat closed_at as derived state managed by close/reopen flows.
- Review bulk-update scripts for closed_at mutations.
When it happens
Trigger: An update that sets closed_at to an empty/zero/allowlisted clearing value while the update (or the issue's current landed status) leaves status=closed. Raised from updateIssueInTx and direct ValidateClosedAtCoherence calls.
Common situations: Bulk edit scripts that blank timestamps; migrating data that strips closed_at; calling update with {"closed_at": ""} intending to reset it without changing status.
Related errors
- %w: refusing to set closed_at on %s: its status stays %q, an
- %w: status value of type %T is neither a string nor a types.
- no store is open for this workspace
- not found
- db: ChildCounterSQLRepository.NextChildID: parentID must not
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/7a26a4b08016030c.
Report an issue: GitHub.