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

  1. Include status="open" (a non-closed status) in the same update when clearing closed_at, i.e. reopen properly.
  2. Remove closed_at from the update map if you did not intend to clear it.
  3. 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

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


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