gastownhall/beads · error · issueops.ErrValidation

%w: apply batch requires at least one item

Error message

%w: apply batch requires at least one item

What it means

PlanApplyBatch rejects batches with zero items: an empty batch is meaningless work and is refused as a request-shape error wrapped with issueops.ErrValidation. This is the second check, after the Actor check, per the documented contract order.

Source

Thrown at internal/storage/batch_apply.go:74

	// from it, so the two cannot disagree about what a key means.
	KeyIndex map[string]int
}

// PlanApplyBatch validates an apply-batch request and normalizes its waits-for
// gate metadata. It is the whole of the role's request validation: every
// implementation calls it before touching a substrate, so a refused request
// costs no database work anywhere.
//
// THE ORDER OF THE CHECKS IS PART OF THE CONTRACT, because a request can be
// wrong in several ways at once and a caller fixing them one at a time needs
// the same answer every time. Request-level shape first, then per-item shape,
// then the ref graph, then the guards.
func PlanApplyBatch(in issueops.ApplyBatchRequest) (ApplyBatchPlan, error) {
	if in.Actor == "" {
		return ApplyBatchPlan{}, fmt.Errorf("%w: apply batch requires an actor", issueops.ErrValidation)
	}
	if len(in.Items) == 0 {
		return ApplyBatchPlan{}, fmt.Errorf("%w: apply batch requires at least one item", issueops.ErrValidation)
	}
	if len(in.Items) > issueops.MaxApplyBatchItems {
		return ApplyBatchPlan{}, fmt.Errorf("%w: apply batch accepts at most %d items, got %d",
			issueops.ErrValidation, issueops.MaxApplyBatchItems, len(in.Items))
	}

	keyIndex, err := planApplyBatchKeys(in.Items)
	if err != nil {
		return ApplyBatchPlan{}, err
	}

	plan := ApplyBatchPlan{
		Actor:                 in.Actor,
		Provenance:            in.Provenance,
		ForceIDPrefix:         in.ForceIDPrefix,
		SkipPerEdgeCycleCheck: in.SkipPerEdgeCycleCheck,
		Items:                 make([]issueops.ApplyItem, len(in.Items)),
		KeyIndex:              keyIndex,

View on GitHub (pinned to 71377f2769)

Solutions

  1. Guard the call site: skip PlanApplyBatch (and any follow-up apply) when len(items) == 0.
  2. If empty batches should be legal no-ops, handle that in the caller before constructing the request.
  3. Check upstream filters/queries that unexpectedly produced zero items if an empty batch is a surprise.

Example fix

// before
plan, err := PlanApplyBatch(issueops.ApplyBatchRequest{Actor: actor, Items: items})
// after
if len(items) == 0 {
    return nil // nothing to apply
}
plan, err := PlanApplyBatch(issueops.ApplyBatchRequest{Actor: actor, Items: items})
Defensive patterns

Strategy: validation

Validate before calling

if len(req.Items) == 0 {
    return nil // nothing to do
}

Try / catch

_, err := storage.PlanApplyBatch(req)
if errors.Is(err, issueops.ErrValidation) && strings.Contains(err.Error(), "at least one item") {
    return nil // treat as no-op
}

Prevention

When it happens

Trigger: Calling PlanApplyBatch with Items: nil or an empty slice — typically when an upstream filter produced no operations but the code still invoked the batch API.

Common situations: Automation that collects changes and finds none (e.g. no issues matched a query) but unconditionally calls ApplyBatch; a loop that filters out all invalid items leaving an empty list.

Related errors


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