{"record":{"id":"70f1f649625a8e13","repo":"gastownhall/beads","slug":"w-refusing-to-set-closed-at-on-s-its-status-st","errorCode":null,"errorMessage":"%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","messagePattern":"%w: refusing to set closed_at on (.+?): its status stays %q, and only a closed issue may carry a closed_at; set status=closed in the same update to close it","errorType":"validation","errorClass":"storage.ErrValidation","httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/update.go","lineNumber":141,"sourceCode":"\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:\n\t\treturn false\n\t}\n}","sourceCodeStart":123,"sourceCodeEnd":159,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/update.go#L123-L159","documentation":"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.","triggerScenarios":"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\"}.","commonSituations":"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.","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."],"exampleFix":"// before\nupdates := map[string]interface{}{\"closed_at\": time.Now().UTC()}\n// after\nupdates := map[string]interface{}{\"closed_at\": time.Now().UTC(), \"status\": types.StatusClosed}","handlingStrategy":"validation","validationCode":"if _, setting := updates[\"closed_at\"]; setting {\n    s, _ := updates[\"status\"].(string)\n    if types.Status(s) != types.StatusClosed {\n        return errors.New(\"setting closed_at requires status=closed in the same update\")\n    }\n}","typeGuard":"func setsClosedAtWithoutClosing(updates map[string]interface{}) bool {\n    if _, ok := updates[\"closed_at\"]; !ok { return false }\n    s, _ := updates[\"status\"].(types.Status)\n    return s != types.StatusClosed\n}","tryCatchPattern":"if err := storage.UpdateIssue(ctx, id, updates, actor); err != nil {\n    if errors.Is(err, storage.ErrValidation) && strings.Contains(err.Error(), \"set closed_at\") {\n        updates[\"status\"] = types.StatusClosed // close properly and retry\n    } else { return err }\n}","preventionTips":["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."],"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"}