gastownhall/beads · error

metadata replacement cannot combine with incremental edits

Error message

metadata replacement cannot combine with incremental edits

What it means

validateMetadataPatch enforces that a metadata Replace (full replacement) is mutually exclusive with incremental edits (Merge, Set, Unset). Mixing them is ambiguous, so the patch is rejected as a plain error inside the validation path.

Source

Thrown at internal/storage/uow/issue_operations.go:547

	if err := storageissueops.ValidateUpdateRequest(request); err != nil {
		return validationError(err)
	}
	if err := validateMetadataPatch(request.Patch.Metadata); err != nil {
		return validationError(err)
	}
	return nil
}

func validateCloseRequest(request publicops.CloseRequest) error {
	if request.Actor == "" || request.IssueID == "" {
		return validationError(fmt.Errorf("close: actor and issue ID must not be empty"))
	}
	return nil
}

func validateMetadataPatch(metadata publicops.MetadataPatch) error {
	if metadata.Replace.Set && (metadata.Merge.Set || len(metadata.Set) > 0 || len(metadata.Unset) > 0) {
		return fmt.Errorf("metadata replacement cannot combine with incremental edits")
	}
	if metadata.Replace.Set && len(metadata.Replace.Value) > 0 && !json.Valid(metadata.Replace.Value) {
		return fmt.Errorf("metadata replacement is not valid JSON")
	}
	if metadata.Merge.Set {
		var object map[string]json.RawMessage
		if len(metadata.Merge.Value) == 0 || json.Unmarshal(metadata.Merge.Value, &object) != nil || object == nil {
			return fmt.Errorf("metadata merge must be a JSON object")
		}
	}
	keys := make([]string, 0, len(metadata.Set))
	for key := range metadata.Set {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		if err := storage.ValidateMetadataKey(key); err != nil {
			return err

View on GitHub (pinned to 71377f2769)

Solutions

  1. Choose one strategy: either Replace alone or incremental edits (Merge/Set/Unset) alone
  2. If you need replacement plus tweaks, compute the final object and use Replace only
  3. If you need targeted changes, drop Replace.Set and use Set/Unset/Merge
  4. Add a builder guard so patch constructors cannot set both

Example fix

// before
patch.Metadata.Replace.Set = true
patch.Metadata.Replace.Value = newJSON
patch.Metadata.Set["priority"] = json.RawMessage(`"high"`)
// after
patch.Metadata.Replace.Set = true
patch.Metadata.Replace.Value = fullMetadataJSON // includes priority
// (no Set/Unset/Merge alongside Replace)
Defensive patterns

Strategy: validation

Validate before calling

func validMetadataPatch(p publicops.MetadataPatch) error {
    if p.Replace.Set && (p.Merge.Set || len(p.Set) > 0 || len(p.Unset) > 0) {
        return fmt.Errorf("replace is mutually exclusive with merge/set/unset")
    }
    return nil
}

Try / catch

if err := uc.Update(ctx, req); err != nil {
    if errors.Is(err, publicops.ErrValidation) && strings.Contains(err.Error(), "cannot combine") {
        // rebuild patch with a single metadata strategy
    }
    return err
}

Prevention

When it happens

Trigger: Calling Update with request.Patch.Metadata where Replace.Set is true AND (Merge.Set is true, or len(Set) > 0, or len(Unset) > 0).

Common situations: Composing an update from multiple config sources where one sets Replace and another adds Set/Unset keys; merging user-supplied flags like --metadata-replace together with --set/--unset; copy-paste of patch-building code.

Related errors


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