gastownhall/beads · error · issueops.ErrValidation

%w: expected-version delete names %d issues; one row version

Error message

%w: expected-version delete names %d issues; one row version cannot describe more than one row

What it means

ValidateDeleteRequest refuses an expected-version delete that names more than one distinct issue. A single row version can only guard one row, so a conditional delete across multiple distinct ids is unsatisfiable and rejected. Duplicate mentions like ["a","a"] are legal because ids are normalized (duplicates collapse) before counting.

Source

Thrown at internal/workapi/delete.go:48

// The ExpectedVersion arity rule is here rather than in the bodies for the
// reason the rest of this file exists: it needs no database, and a rule the
// bodies each spelled themselves is a rule that can differ per backend.
func ValidateDeleteRequest(in issueops.DeleteRequest) error {
	if len(in.IDs) == 0 {
		return fmt.Errorf("%w: delete requires at least one issue id", issueops.ErrValidation)
	}
	for i, id := range in.IDs {
		if strings.TrimSpace(id) == "" {
			return fmt.Errorf("%w: delete id at position %d is blank", issueops.ErrValidation, i)
		}
	}
	// DISTINCT ids, not mentions: DeleteRequest.IDs promises duplicates
	// collapse, so an IDs of {"a", "a"} carrying a version names ONE row and is
	// legal. Counting the raw slice here would refuse the request the role's own
	// normalization rule says is fine.
	if in.ExpectedVersion != nil {
		if distinct := len(NormalizeDeleteIDs(in.IDs)); distinct > 1 {
			return fmt.Errorf("%w: expected-version delete names %d issues; one row version cannot describe more than one row",
				issueops.ErrValidation, distinct)
		}
	}
	return nil
}

// NormalizeDeleteIDs collapses duplicates, keeping the caller's FIRST mention
// of each id, and trims surrounding whitespace.
//
// First-mention order rather than sorted, because it is the order the front
// doors echo back in their "issues not found" line and in the confirmation
// hint they print.
//
// It assumes a request already accepted by ValidateDeleteRequest, so no entry
// trims to empty.
func NormalizeDeleteIDs(ids []string) []string {
	seen := make(map[string]bool, len(ids))
	out := make([]string, 0, len(ids))

View on GitHub (pinned to 71377f2769)

Solutions

  1. Issue one delete per issue, each with its own ExpectedVersion
  2. Drop ExpectedVersion for bulk deletes if unconditional removal is acceptable
  3. Deduplicate ids first and confirm count is 1 whenever a version is attached

Example fix

// before
req := issueops.DeleteRequest{IDs: []string{"bd-1","bd-2"}, ExpectedVersion: &ver}
api.Delete(ctx, req)
// after
for _, id := range []string{"bd-1","bd-2"} {
    v := fetchVersion(ctx, id)
    if err := api.Delete(ctx, issueops.DeleteRequest{IDs: []string{id}, ExpectedVersion: &v}); err != nil { return err }
}
Defensive patterns

Strategy: validation

Validate before calling

if req.ExpectedVersion != nil && len(workapi.NormalizeDeleteIDs(req.IDs)) > 1 {
    return fmt.Errorf("cannot attach one expected version to %d distinct ids", len(req.IDs))
}

Try / catch

if err := api.Delete(ctx, req); err != nil {
    if errors.Is(err, issueops.ErrValidation) && strings.Contains(err.Error(), "expected-version delete") {
        return splitIntoPerIssueVersionedDeletes(ctx, req)
    }
    return err
}

Prevention

When it happens

Trigger: Calling delete with ExpectedVersion set and two or more distinct ids in IDs, e.g. DeleteRequest{IDs: ["bd-1","bd-2"], ExpectedVersion: &v}.

Common situations: Batch delete code reusing a struct that also carries an optimistic-concurrency version from a single-issue edit; copy-paste between single-delete and bulk-delete call sites.

Related errors


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