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
- Remove the direct notes replacement and keep only the append op
- Or drop OpAppendNotes and include the appended text directly in the replacement string
- 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
- Never populate both resolved["notes"] and updates[OpAppendNotes]
- Route all notes mutations through one helper
- Use append ops for additive logging; use replacement only for full rewrites
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
- proxy.ForceStopUnverified: at most one options value is allo
- proxy.ForceStopUnverified: timeout must be positive, got %s
- issue must not be nil
- add dep: dep must not be nil
- add dep: IssueID and DependsOnID must be non-empty
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/d061556b0bb74ee0.
Report an issue: GitHub.