gastownhall/beads · error

cannot combine a metadata replacement with incremental metad

Error message

cannot combine a metadata replacement with incremental metadata edits

What it means

The update payload supplied both a direct replacement of the metadata field and incremental metadata operations (merge/set/unset). The library cannot apply both semantics in one update, so it rejects the mutation before touching the database.

Source

Thrown at internal/storage/issueops/update.go:880

		return true
	default:
		return false
	}
}

// resolveMetadataMergeOps folds OpMergeMetadata/OpSetMetadata/OpUnsetMetadata
// into a concrete "metadata" value on resolved, using oldIssue.Metadata (read in
// the same mutation transaction) as the base. It is a no-op when no metadata
// operation keys are present.
func resolveMetadataMergeOps(oldIssue *types.Issue, updates, resolved map[string]interface{}) error {
	_, hasMerge := updates[OpMergeMetadata]
	_, hasSet := updates[OpSetMetadata]
	_, hasUnset := updates[OpUnsetMetadata]
	if !hasMerge && !hasSet && !hasUnset {
		return nil
	}
	if _, direct := resolved["metadata"]; direct {
		return fmt.Errorf("cannot combine a metadata replacement with incremental metadata edits")
	}

	current := oldIssue.Metadata
	if hasMerge {
		normalized, err := storage.NormalizeMetadataValue(updates[OpMergeMetadata])
		if err != nil {
			return fmt.Errorf("invalid %s: %w", OpMergeMetadata, err)
		}
		merged, err := storage.MergeMetadataJSON(current, json.RawMessage(normalized))
		if err != nil {
			return fmt.Errorf("metadata merge failed: %w", err)
		}
		current = merged
	}
	if hasSet || hasUnset {
		unset, err := mergeOpStrings(OpUnsetMetadata, updates[OpUnsetMetadata], hasUnset)
		if err != nil {
			return err

View on GitHub (pinned to 71377f2769)

Solutions

  1. Remove the direct metadata replacement and keep only merge/set/unset ops
  2. Or drop the incremental ops and supply the full metadata object as a replacement
  3. If combining is intended, perform two sequential update calls

Example fix

// before
updates := map[string]any{
  issueops.OpSetMetadata:   map[string]any{"a": 1},
  issueops.OpMergeMetadata: map[string]any{"b": 2},
}

// after
updates := map[string]any{
  issueops.OpMergeMetadata: map[string]any{"b": 2}, // incremental only
}
Defensive patterns

Strategy: validation

Validate before calling

func checkNoConflict(updates map[string]any, resolved map[string]any) error {
    _, direct := resolved["metadata"]
    _, hasOp := updates[issueops.OpSetMetadata]
    hasOp = hasOp || updates[issueops.OpMergeMetadata] != nil || updates[issueops.OpUnsetMetadata] != nil
    if direct && hasOp {
        return errors.New("remove either the metadata replacement or the merge/set/unset ops")
    }
    return nil
}

Try / catch

if err := issueops.ResolveMergeOps(issue, updates, resolved); err != nil {
    if strings.Contains(err.Error(), "cannot combine a metadata replacement") {
        // strip incremental ops and retry with replacement only
    }
}

Prevention

When it happens

Trigger: Calling ResolveMergeOps (via an issue update) with updates containing OpSetMetadata, OpMergeMetadata, or OpUnsetMetadata while the resolved map already contains a direct "metadata" value.

Common situations: Building an update struct where both Metadata and MetadataOps/Merge fields are populated by different code paths (e.g. a CLI flag plus a default merge), or double-applying metadata in a middleware layer.

Related errors


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