gastownhall/beads · error · issueops.ErrValidation

%w: apply batch item %d must carry exactly one payload, got

Error message

%w: apply batch item %d must carry exactly one payload, got %d

What it means

Each ApplyBatchRequest item must carry exactly one payload (exactly one of Create, Update, Close, DepAdd non-nil). planApplyBatchKeys counts the non-nil payloads and fails with the item index and actual count, wrapped with issueops.ErrValidation. Zero payloads and multiple payloads are both rejected.

Source

Thrown at internal/storage/batch_apply.go:123

	return plan, nil
}

// planApplyBatchKeys checks every item's kind/payload agreement and collects
// the create keys. It runs as its own pass because a ref may name a key
// declared by a LATER item, which is a different diagnosis from a key nothing
// declares — and telling them apart means knowing every key before checking any
// ref.
func planApplyBatchKeys(items []issueops.ApplyItem) (map[string]int, error) {
	keyIndex := make(map[string]int, len(items))
	for i, item := range items {
		payloads := 0
		for _, present := range []bool{item.Create != nil, item.Update != nil, item.Close != nil, item.DepAdd != nil} {
			if present {
				payloads++
			}
		}
		if payloads != 1 {
			return nil, fmt.Errorf("%w: apply batch item %d must carry exactly one payload, got %d",
				issueops.ErrValidation, i, payloads)
		}
		var matches bool
		switch item.Kind {
		case issueops.ItemCreate:
			matches = item.Create != nil
		case issueops.ItemUpdate:
			matches = item.Update != nil
		case issueops.ItemClose:
			matches = item.Close != nil
		case issueops.ItemDepAdd:
			matches = item.DepAdd != nil
		default:
			return nil, fmt.Errorf("%w: apply batch item %d has unknown kind %q", issueops.ErrValidation, i, item.Kind)
		}
		if !matches {
			return nil, fmt.Errorf("%w: apply batch item %d is kind %q but carries another kind's payload",
				issueops.ErrValidation, i, item.Kind)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Ensure each item sets exactly one of Create/Update/Close/DepAdd and leaves the others nil.
  2. If reusing a struct, construct a fresh ApplyBatchItem per operation instead of mutating a shared one.
  3. Match the payload to item.Kind — also see the kind/payload mismatch error that follows this check.

Example fix

// before
item := issueops.ApplyBatchItem{Kind: issueops.ItemCreate, Create: cr, Close: cl} // 2 payloads
// after
item := issueops.ApplyBatchItem{Kind: issueops.ItemCreate, Create: cr}
Defensive patterns

Strategy: validation

Validate before calling

func payloadCount(it issueops.ApplyBatchItem) int {
    n := 0
    for _, p := range []*issueops.CreatePayload{it.Create} { if p != nil { n++ } }
    if it.Update != nil { n++ }
    if it.Close != nil { n++ }
    if it.DepAdd != nil { n++ }
    return n
}

Try / catch

_, err := storage.PlanApplyBatch(req)
if errors.Is(err, issueops.ErrValidation) && strings.Contains(err.Error(), "exactly one payload") {
    return fmt.Errorf("malformed batch item: %w", err)
}

Prevention

When it happens

Trigger: Building an item with no payload set, or with two payloads set (e.g. both Create and Close non-nil), or reusing a single item struct across kinds without clearing other fields.

Common situations: Generic builder code that fills multiple payload pointers; copy-pasted item literals; deserialization bugs where JSON fills unexpected payload fields.

Related errors


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