gastownhall/beads · error · issueops.ErrValidation

%w: apply batch item %d guards on a row version, but an earl

Error message

%w: apply batch item %d guards on a row version, but an earlier item in this request already wrote %s; the version token is minted by the write and cannot be known mid-request

What it means

An ExpectedVersion guard on an Update/Close item asserts the row is at a version the caller observed earlier. If an earlier item in the SAME batch already wrote that row, the new version token doesn't exist yet (it's minted by that write), so the guard is unsatisfiable and rejected. ExpectedStatus/ExpectedAssignee guards are allowed mid-batch because the caller can know what its own items set.

Source

Thrown at internal/storage/batch_apply.go:296

}

// checkApplyExpectedVersion refuses a version guard on a row an earlier item of
// this request already mutated.
//
// IT IS A REQUEST-SHAPE RULE, not a race. The token is server-minted and
// rewritten by the write, so mid-request there is no value a caller COULD send:
// the pre-request token is stale by construction, and a row this request just
// created never had one the caller could read. Refusing statically says so;
// letting it through would answer every such request with ErrVersionMismatch
// and leave the caller looking for a concurrent writer that does not exist.
//
// ExpectedStatus and ExpectedAssignee carry no such rule, and the difference is
// that a caller CAN know what its own earlier item set them to.
func checkApplyExpectedVersion(expected *int64, target issueops.Ref, index int, touched map[string]bool) error {
	if expected == nil || !touched[applyTouchKeyRef(target)] {
		return nil
	}
	return fmt.Errorf("%w: apply batch item %d guards on a row version, but an earlier item in this request already wrote %s; "+
		"the version token is minted by the write and cannot be known mid-request",
		issueops.ErrValidation, index, applyRefLabel(target))
}

// validateApplyTargetRef checks a ref used to ADDRESS a row: exactly one member
// set, and a key that reaches BACKWARD.
func validateApplyTargetRef(ref issueops.Ref, index int, member string, keyIndex map[string]int) error {
	if err := validateApplyRef(ref, index, member); err != nil {
		return err
	}
	if ref.Key == "" {
		return nil
	}
	declaredAt, ok := keyIndex[ref.Key]
	if !ok {
		return &issueops.RefError{Index: index, Member: member, Key: ref.Key}
	}
	if declaredAt >= index {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Drop ExpectedVersion on the later item(s) touching the same row within one batch (rely on ordering), or split them into separate batch requests
  2. Use ExpectedStatus/ExpectedAssignee guards instead, which are permitted mid-request
  3. Read a fresh version and issue a new batch after the first one is applied

Example fix

// before
{Kind: issueops.ItemClose, Close: &issueops.CloseItem{Ref: ref, ExpectedVersion: &v}} // after an update to same row in this batch
// after
{Kind: issueops.ItemClose, Close: &issueops.CloseItem{Ref: ref}} // ordering within the batch provides the guard
Defensive patterns

Strategy: validation

Validate before calling

touched := map[string]bool{}
for i, it := range items {
  ref := itemTargetRef(it)
  if ref == nil { continue }
  if expectedVersionOf(it) != nil && touched[refKey(ref)] {
    return fmt.Errorf("item %d re-guards a row written earlier in this batch", i)
  }
  touched[refKey(ref)] = true
}

Type guard

func unsatisfiableVersionGuard(expected *int64, wroteEarlier bool) bool {
  return expected != nil && wroteEarlier
}

Try / catch

if err := store.PlanApplyBatch(plan); err != nil {
  if errors.Is(err, issueops.ErrValidation) && strings.Contains(err.Error(), "cannot be known mid-request") {
    // clear ExpectedVersion on later items touching the same row, or split batches
  }
  return err
}

Prevention

When it happens

Trigger: PlanApplyBatch with item 0 updating issue X, then item 3 closing X with ExpectedVersion set to the pre-batch version — the planner sees X in the touched set and rejects.

Common situations: Programmatically generating chains like update-then-close on the same issue and copying ExpectedVersion from the original read into every item; retrying a failed batch by rebuilding it with stale version tokens while keeping multiple writes to one row.

Related errors


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