{"record":{"id":"fb81f6e4198e5a99","repo":"gastownhall/beads","slug":"w-expected-version-delete-names-d-issues-one-r","errorCode":null,"errorMessage":"%w: expected-version delete names %d issues; one row version cannot describe more than one row","messagePattern":"%w: expected-version delete names (.+?) issues; one row version cannot describe more than one row","errorType":"validation","errorClass":"issueops.ErrValidation","httpStatus":null,"severity":"error","filePath":"internal/workapi/delete.go","lineNumber":48,"sourceCode":"// The ExpectedVersion arity rule is here rather than in the bodies for the\n// reason the rest of this file exists: it needs no database, and a rule the\n// bodies each spelled themselves is a rule that can differ per backend.\nfunc ValidateDeleteRequest(in issueops.DeleteRequest) error {\n\tif len(in.IDs) == 0 {\n\t\treturn fmt.Errorf(\"%w: delete requires at least one issue id\", issueops.ErrValidation)\n\t}\n\tfor i, id := range in.IDs {\n\t\tif strings.TrimSpace(id) == \"\" {\n\t\t\treturn fmt.Errorf(\"%w: delete id at position %d is blank\", issueops.ErrValidation, i)\n\t\t}\n\t}\n\t// DISTINCT ids, not mentions: DeleteRequest.IDs promises duplicates\n\t// collapse, so an IDs of {\"a\", \"a\"} carrying a version names ONE row and is\n\t// legal. Counting the raw slice here would refuse the request the role's own\n\t// normalization rule says is fine.\n\tif in.ExpectedVersion != nil {\n\t\tif distinct := len(NormalizeDeleteIDs(in.IDs)); distinct > 1 {\n\t\t\treturn fmt.Errorf(\"%w: expected-version delete names %d issues; one row version cannot describe more than one row\",\n\t\t\t\tissueops.ErrValidation, distinct)\n\t\t}\n\t}\n\treturn nil\n}\n\n// NormalizeDeleteIDs collapses duplicates, keeping the caller's FIRST mention\n// of each id, and trims surrounding whitespace.\n//\n// First-mention order rather than sorted, because it is the order the front\n// doors echo back in their \"issues not found\" line and in the confirmation\n// hint they print.\n//\n// It assumes a request already accepted by ValidateDeleteRequest, so no entry\n// trims to empty.\nfunc NormalizeDeleteIDs(ids []string) []string {\n\tseen := make(map[string]bool, len(ids))\n\tout := make([]string, 0, len(ids))","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/workapi/delete.go#L30-L66","documentation":"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.","triggerScenarios":"Calling delete with ExpectedVersion set and two or more distinct ids in IDs, e.g. DeleteRequest{IDs: [\"bd-1\",\"bd-2\"], ExpectedVersion: &v}.","commonSituations":"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.","solutions":["Issue one delete per issue, each with its own ExpectedVersion","Drop ExpectedVersion for bulk deletes if unconditional removal is acceptable","Deduplicate ids first and confirm count is 1 whenever a version is attached"],"exampleFix":"// before\nreq := issueops.DeleteRequest{IDs: []string{\"bd-1\",\"bd-2\"}, ExpectedVersion: &ver}\napi.Delete(ctx, req)\n// after\nfor _, id := range []string{\"bd-1\",\"bd-2\"} {\n    v := fetchVersion(ctx, id)\n    if err := api.Delete(ctx, issueops.DeleteRequest{IDs: []string{id}, ExpectedVersion: &v}); err != nil { return err }\n}","handlingStrategy":"validation","validationCode":"if req.ExpectedVersion != nil && len(workapi.NormalizeDeleteIDs(req.IDs)) > 1 {\n    return fmt.Errorf(\"cannot attach one expected version to %d distinct ids\", len(req.IDs))\n}","typeGuard":null,"tryCatchPattern":"if err := api.Delete(ctx, req); err != nil {\n    if errors.Is(err, issueops.ErrValidation) && strings.Contains(err.Error(), \"expected-version delete\") {\n        return splitIntoPerIssueVersionedDeletes(ctx, req)\n    }\n    return err\n}","preventionTips":["Keep versioned (conditional) deletes single-issue by construction","Never reuse single-issue request structs for bulk deletes","Deduplicate ids with NormalizeDeleteIDs before count checks"],"tags":["validation","delete","optimistic-concurrency","issueops"],"backgroundTag":"versioned-delete-multiple-rows","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}