gastownhall/beads · error · storage.ErrValidation

%w: metadata merge must be a JSON object

Error message

%w: metadata merge must be a JSON object

What it means

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.

Source

Thrown at internal/storage/issueops/aggregate.go:223

			return nil, false, fmt.Errorf("%w: %w", storage.ErrValidation, err)
		}
	}
	var next json.RawMessage
	if patch.Replace.Set {
		next = append(json.RawMessage(nil), patch.Replace.Value...)
		if len(next) == 0 {
			next = json.RawMessage(`{}`)
		}
		if !json.Valid(next) {
			return nil, false, fmt.Errorf("%w: metadata replacement is not valid JSON", storage.ErrValidation)
		}
	} else {
		next = append(json.RawMessage(nil), current...)
		if patch.Merge.Set {
			// A JSON null unmarshals into a nil overlay map, so the merge
			// below would silently accept it as "change nothing".
			if strings.TrimSpace(string(patch.Merge.Value)) == "null" {
				return nil, false, fmt.Errorf("%w: metadata merge must be a JSON object", storage.ErrValidation)
			}
			merged, err := storage.MergeMetadataJSON(next, patch.Merge.Value)
			if err != nil {
				return nil, false, fmt.Errorf("%w: metadata merge: %v", storage.ErrValidation, err)
			}
			next = merged
		}
		if len(patch.Set) > 0 || len(patch.Unset) > 0 {
			values := make(map[string]json.RawMessage)
			if len(next) > 0 && string(next) != "null" {
				if err := json.Unmarshal(next, &values); err != nil {
					return nil, false, fmt.Errorf("%w: metadata edits require an object: %v", storage.ErrValidation, err)
				}
			}
			for _, key := range setKeys {
				value := patch.Set[key]
				if !json.Valid(value) {
					return nil, false, fmt.Errorf("%w: metadata value for key %q is not valid JSON", storage.ErrValidation, key)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check the merge value for "null" (after trimming) in the caller and treat it as absent: leave patch.Merge.Set false.
  2. Marshal only non-nil maps/structs for the merge overlay.
  3. Fix the upstream data source so a real JSON object is supplied when a merge is intended.

Example fix

// before
patch.Merge.Set = true
patch.Merge.Value = json.RawMessage(overlayJSON) // overlayJSON may be "null"
// after
trimmed := strings.TrimSpace(string(overlayJSON))
if trimmed != "" && trimmed != "null" {
    patch.Merge.Set = true
    patch.Merge.Value = json.RawMessage(overlayJSON)
}
Defensive patterns

Strategy: validation

Validate before calling

func validMergeOverlay(v json.RawMessage) bool {
    t := strings.TrimSpace(string(v))
    return t != "" && t != "null" && strings.HasPrefix(t, "{")
}

Try / catch

if errors.Is(err, storage.ErrValidation) && strings.Contains(err.Error(), "must be a JSON object") { /* set Merge.Set=false or supply a real object */ }

Prevention

When it happens

Trigger: 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.

Common situations: 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".

Related errors


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