gastownhall/beads · error

%s must be a list of strings, got element %T

Error message

%s must be a list of strings, got element %T

What it means

mergeOpStrings validates that a list-valued merge-operation key (e.g. metadata unset/set list or appended notes) passed to the issue update map contains only strings. When a []interface{} slice carries a non-string element, resolution of the merge op is aborted so a malformed value never reaches the SQL column. This protects the read-merge-write path from type-unsafe payloads that typically arrive over JSON or daemon transports.

Source

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

	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) {
	if !present {
		return nil, nil
	}
	switch v := value.(type) {
	case []string:
		return v, nil
	case []interface{}:
		out := make([]string, 0, len(v))
		for _, item := range v {
			s, ok := item.(string)
			if !ok {
				return nil, fmt.Errorf("%s must be a list of strings, got element %T", op, item)
			}
			out = append(out, s)
		}
		return out, nil
	default:
		return nil, fmt.Errorf("%s must be a list of strings, got %T", op, value)
	}
}

// readIssueAndResolveMergeOps reads the pre-update row in-transaction and folds
// any merge-operation keys (metadata edits, note appends) into concrete column
// values against that row, returning the row and the rewritten update map. It
// keeps the read-merge-write plumbing off updateIssueInTx's already-large body.
func readIssueAndResolveMergeOps(ctx context.Context, tx DBTX, id string, updates map[string]interface{}) (*types.Issue, map[string]interface{}, error) {
	oldIssue, err := GetIssueInTx(ctx, tx, id)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to get issue for update: %w", err)
	}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Inspect the update map value at the merge-op key and ensure every element is a string before calling Update/ResolveMergeOps
  2. If the value comes from JSON, decode into []string instead of []interface{}, or validate elements client-side
  3. Convert numeric/other elements explicitly with strconv / fmt.Sprintf if they are meant to be string keys
  4. Remove null/nil or placeholder entries from the list

Example fix

// before
updates["_unset_metadata"] = []interface{}{"priority", 3}
// after
updates["_unset_metadata"] = []string{"priority", "labels"}
Defensive patterns

Strategy: validation

Validate before calling

func validateStringList(v interface{}) ([]string, bool) {
    switch l := v.(type) {
    case []string:
        return l, true
    case []interface{}:
        out := make([]string, 0, len(l))
        for _, e := range l {
            s, ok := e.(string)
            if !ok { return nil, false }
            out = append(out, s)
        }
        return out, true
    }
    return nil, false
}

Type guard

if list, ok := value.([]string); ok { /* safe */ } else if raw, ok := value.([]interface{}); ok { for _, e := range raw { if _, ok := e.(string); !ok { return error } } }

Prevention

When it happens

Trigger: Passing updates[OpSetMetadata], updates[OpUnsetMetadata], or updates[OpAppendNotes] as []interface{} containing at least one non-string element (e.g. []interface{}{"a", 42}) to ResolveMergeOps/updateIssueInTx, typically after decoding user JSON without enforcing a string array.

Common situations: JSON payloads where a client sent ["key1", 3] or [null] for metadata unset; YAML/JSON decoding into interface{}; dynamic CLI flag values interpolated into a list; template-generated update maps.

Related errors


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