gastownhall/beads · error · storage.ErrValidation
%w: refusing to set closed_at on %s: its status stays %q, an
Error message
%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
What it means
ValidateClosedAtCoherence rejects an update that sets closed_at while the issue's resulting status is not closed. Only a closed issue may carry closed_at; the caller must set status=closed in the same update to close the issue. The error wraps storage.ErrValidation.
Source
Thrown at internal/storage/issueops/update.go:141
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:
return false
}
}View on GitHub (pinned to 71377f2769)
Solutions
- Add status=closed to the same updates map as closed_at.
- Remove closed_at from the update map if the issue is not being closed.
- Close the issue through the high-level close API instead of setting closed_at manually.
Example fix
// before
updates := map[string]interface{}{"closed_at": time.Now().UTC()}
// after
updates := map[string]interface{}{"closed_at": time.Now().UTC(), "status": types.StatusClosed} Defensive patterns
Strategy: validation
Validate before calling
if _, setting := updates["closed_at"]; setting {
s, _ := updates["status"].(string)
if types.Status(s) != types.StatusClosed {
return errors.New("setting closed_at requires status=closed in the same update")
}
} Type guard
func setsClosedAtWithoutClosing(updates map[string]interface{}) bool {
if _, ok := updates["closed_at"]; !ok { return false }
s, _ := updates["status"].(types.Status)
return s != types.StatusClosed
} Try / catch
if err := storage.UpdateIssue(ctx, id, updates, actor); err != nil {
if errors.Is(err, storage.ErrValidation) && strings.Contains(err.Error(), "set closed_at") {
updates["status"] = types.StatusClosed // close properly and retry
} else { return err }
} Prevention
- Always pair closed_at with status=closed in the same updates map.
- Prefer the high-level close API over manually stamping closed_at.
- Never pre-stamp closed_at on issues still open.
- Validate updates maps in helpers before passing to UpdateIssue.
When it happens
Trigger: Calling updateIssueInTx (or ValidateClosedAtCoherence) with a closed_at value while the landed status (unchanged or newly set) is anything other than closed — e.g. {"closed_at": now} without {"status": "closed"}.
Common situations: Scripts backfilling closed timestamps on open issues; automation that stamps closed_at 'in advance' of a status flip; forgetting to include the status change in the same update call.
Related errors
- %w: refusing to clear closed_at on %s: its status stays %q,
- %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/70f1f649625a8e13.
Report an issue: GitHub.