{"record":{"id":"7a26a4b08016030c","repo":"gastownhall/beads","slug":"w-refusing-to-clear-closed-at-on-s-its-status","errorCode":null,"errorMessage":"%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","messagePattern":"%w: refusing to clear closed_at on (.+?): its status stays %q, and a closed issue must keep a closed_at; reopen it with a status update instead","errorType":"validation","errorClass":"storage.ErrValidation","httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/update.go","lineNumber":138,"sourceCode":"\n\tlandedStatus := oldIssue.Status\n\tif rawStatus, hasStatus := updates[\"status\"]; hasStatus {\n\t\tswitch value := rawStatus.(type) {\n\t\tcase string:\n\t\t\tlandedStatus = types.Status(value)\n\t\tcase types.Status:\n\t\t\tlandedStatus = value\n\t\tdefault:\n\t\t\t// A status Go type nobody can read is already CrossesIntoDoneCategoryInTx's\n\t\t\t// refusal in both funnels; leave that the single message for it.\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tclearing := clearsClosedAt(rawClosedAt)\n\tswitch {\n\tcase landedStatus == types.StatusClosed && clearing:\n\t\treturn 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\",\n\t\t\tstorage.ErrValidation, oldIssue.ID, landedStatus)\n\tcase landedStatus != types.StatusClosed && !clearing:\n\t\treturn 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\",\n\t\t\tstorage.ErrValidation, oldIssue.ID, landedStatus)\n\t}\n\treturn nil\n}\n\n// clearsClosedAt reports whether an allowlisted closed_at value blanks the\n// column. It accepts the same nil shapes matchesTimePointer treats as empty, so\n// the guard and the no-op filter agree on what \"no closed_at\" means.\nfunc clearsClosedAt(value interface{}) bool {\n\tswitch typed := value.(type) {\n\tcase nil:\n\t\treturn true\n\tcase *time.Time:\n\t\treturn typed == nil\n\tdefault:","sourceCodeStart":120,"sourceCodeEnd":156,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/update.go#L120-L156","documentation":"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.","triggerScenarios":"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.","commonSituations":"Bulk edit scripts that blank timestamps; migrating data that strips closed_at; calling update with {\"closed_at\": \"\"} intending to reset it without changing status.","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."],"exampleFix":"// before\nupdates := map[string]interface{}{\"closed_at\": \"\"}\n// after\nupdates := map[string]interface{}{\"closed_at\": \"\", \"status\": \"open\"}","handlingStrategy":"validation","validationCode":"if strings.EqualFold(issue.Status, string(types.StatusClosed)) && clearsClosedAt(updates[\"closed_at\"]) {\n    return errors.New(\"cannot clear closed_at on a closed issue; reopen it first\")\n}","typeGuard":"func clearsClosedAt(v interface{}) bool { return v == \"\" || v == nil }","tryCatchPattern":"if err := storage.UpdateIssue(ctx, id, updates, actor); err != nil {\n    if errors.Is(err, storage.ErrValidation) && strings.Contains(err.Error(), \"clear closed_at\") {\n        // add status=open to the same update and retry once\n    }\n    return err\n}","preventionTips":["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."],"tags":["storage","validation","closed-at","status"],"backgroundTag":"closed-at-incoherent","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}