gastownhall/beads · error · storage.ErrValidation

%w: cannot combine a notes replacement with %s

Error message

%w: cannot combine a notes replacement with %s

What it means

An update attempted to replace the notes field directly while also appending to notes via OpAppendNotes. These operations conflict (the replacement would discard the appended text), so the library rejects the update with a validation error (wrapping storage.ErrValidation).

Source

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

		delete(data, key)
	}
	result, err := json.Marshal(data)
	if err != nil {
		return nil, fmt.Errorf("failed to marshal metadata: %w", err)
	}
	return json.RawMessage(result), nil
}

// resolveNotesAppendOp folds OpAppendNotes into a concrete "notes" value on
// resolved, appending to oldIssue.Notes (read in the same mutation transaction).
// It is a no-op when the append op is absent.
func resolveNotesAppendOp(oldIssue *types.Issue, updates, resolved map[string]interface{}) error {
	raw, ok := updates[OpAppendNotes]
	if !ok {
		return nil
	}
	if _, direct := resolved["notes"]; direct {
		return fmt.Errorf("%w: cannot combine a notes replacement with %s", storage.ErrValidation, OpAppendNotes)
	}
	text, ok := raw.(string)
	if !ok {
		return fmt.Errorf("%s must be a string, got %T", OpAppendNotes, raw)
	}
	combined := oldIssue.Notes
	if combined != "" {
		combined += "\n"
	}
	combined += text
	resolved["notes"] = combined
	return nil
}

// mergeOpStrings coerces a merge-operation value to []string. Accepts
// []interface{} of strings as well, so operation maps survive a JSON
// round-trip (e.g. daemon transports).
func mergeOpStrings(op string, value interface{}, present bool) ([]string, error) {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Remove the direct notes replacement and keep only the append op
  2. Or drop OpAppendNotes and include the appended text directly in the replacement string
  3. Split into two sequential update calls if both semantics are required

Example fix

// before
resolved["notes"] = "reset"
updates[issueops.OpAppendNotes] = "new entry"

// after
updates[issueops.OpAppendNotes] = "new entry" // append only, no direct notes set
Defensive patterns

Strategy: validation

Validate before calling

if _, hasAppend := updates[issueops.OpAppendNotes]; hasAppend {
    if _, direct := resolved["notes"]; direct {
        return errors.New("drop either the notes replacement or the append op")
    }
}

Try / catch

err := issueops.ResolveMergeOps(issue, updates, resolved)
if err != nil {
    var vErr error = storage.ErrValidation
    if errors.Is(err, vErr) && strings.Contains(err.Error(), "notes replacement") {
        delete(resolved, "notes") // retry with append only
        err = issueops.ResolveMergeOps(issue, updates, resolved)
    }
}

Prevention

When it happens

Trigger: Calling ResolveMergeOps with OpAppendNotes present in updates while the resolved map already contains a direct "notes" value.

Common situations: A code path that sets Notes explicitly while another layer (CLI command, webhook handler) adds an append op in the same update payload.

Related errors


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