{"record":{"id":"b1a933ab4af20fd4","repo":"gastownhall/beads","slug":"w-metadata-merge-must-be-a-json-object","errorCode":null,"errorMessage":"%w: metadata merge must be a JSON object","messagePattern":"%w: metadata merge must be a JSON object","errorType":"validation","errorClass":"storage.ErrValidation","httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/aggregate.go","lineNumber":223,"sourceCode":"\t\t\treturn nil, false, fmt.Errorf(\"%w: %w\", storage.ErrValidation, err)\n\t\t}\n\t}\n\tvar next json.RawMessage\n\tif patch.Replace.Set {\n\t\tnext = append(json.RawMessage(nil), patch.Replace.Value...)\n\t\tif len(next) == 0 {\n\t\t\tnext = json.RawMessage(`{}`)\n\t\t}\n\t\tif !json.Valid(next) {\n\t\t\treturn nil, false, fmt.Errorf(\"%w: metadata replacement is not valid JSON\", storage.ErrValidation)\n\t\t}\n\t} else {\n\t\tnext = append(json.RawMessage(nil), current...)\n\t\tif patch.Merge.Set {\n\t\t\t// A JSON null unmarshals into a nil overlay map, so the merge\n\t\t\t// below would silently accept it as \"change nothing\".\n\t\t\tif strings.TrimSpace(string(patch.Merge.Value)) == \"null\" {\n\t\t\t\treturn nil, false, fmt.Errorf(\"%w: metadata merge must be a JSON object\", storage.ErrValidation)\n\t\t\t}\n\t\t\tmerged, err := storage.MergeMetadataJSON(next, patch.Merge.Value)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, false, fmt.Errorf(\"%w: metadata merge: %v\", storage.ErrValidation, err)\n\t\t\t}\n\t\t\tnext = merged\n\t\t}\n\t\tif len(patch.Set) > 0 || len(patch.Unset) > 0 {\n\t\t\tvalues := make(map[string]json.RawMessage)\n\t\t\tif len(next) > 0 && string(next) != \"null\" {\n\t\t\t\tif err := json.Unmarshal(next, &values); err != nil {\n\t\t\t\t\treturn nil, false, fmt.Errorf(\"%w: metadata edits require an object: %v\", storage.ErrValidation, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor _, key := range setKeys {\n\t\t\t\tvalue := patch.Set[key]\n\t\t\t\tif !json.Valid(value) {\n\t\t\t\t\treturn nil, false, fmt.Errorf(\"%w: metadata value for key %q is not valid JSON\", storage.ErrValidation, key)","sourceCodeStart":205,"sourceCodeEnd":241,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/aggregate.go#L205-L241","documentation":"ApplyMetadataPatch wraps storage.ErrValidation with this message when patch.Merge.Set is true and the merge overlay is the literal JSON null. A JSON null would unmarshal into a nil map and silently do nothing, so the library explicitly rejects it to prevent a no-op being mistaken for a successful merge.","triggerScenarios":"Calling ApplyMetadataPatch with patch.Merge.Set=true and patch.Merge.Value trimming to \"null\" — e.g. a variable holding JSON that was null, or an unset flag serialized as null.","commonSituations":"Passing optional merge input from JSON config where the field is null; Go nil pointer/slice marshaled to null and fed straight into the merge patch; CLI flags defaulting to the string \"null\".","solutions":["Check the merge value for \"null\" (after trimming) in the caller and treat it as absent: leave patch.Merge.Set false.","Marshal only non-nil maps/structs for the merge overlay.","Fix the upstream data source so a real JSON object is supplied when a merge is intended."],"exampleFix":"// before\npatch.Merge.Set = true\npatch.Merge.Value = json.RawMessage(overlayJSON) // overlayJSON may be \"null\"\n// after\ntrimmed := strings.TrimSpace(string(overlayJSON))\nif trimmed != \"\" && trimmed != \"null\" {\n    patch.Merge.Set = true\n    patch.Merge.Value = json.RawMessage(overlayJSON)\n}","handlingStrategy":"validation","validationCode":"func validMergeOverlay(v json.RawMessage) bool {\n    t := strings.TrimSpace(string(v))\n    return t != \"\" && t != \"null\" && strings.HasPrefix(t, \"{\")\n}","typeGuard":null,"tryCatchPattern":"if errors.Is(err, storage.ErrValidation) && strings.Contains(err.Error(), \"must be a JSON object\") { /* set Merge.Set=false or supply a real object */ }","preventionTips":["Never set patch.Merge.Set=true for null overlays; treat null as absent.","Check for the literal \"null\" after trimming before enabling Merge.","Marshal only non-nil maps/structs as merge overlays.","Reject null-typed optional fields at the config-parsing layer."],"tags":["go","validation","metadata","json"],"backgroundTag":"metadata-merge-null","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}