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

  1. Add status=closed to the same updates map as closed_at.
  2. Remove closed_at from the update map if the issue is not being closed.
  3. 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

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


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