{"record":{"id":"56a463332a800b66","repo":"gastownhall/beads","slug":"remove-relation-d-w","errorCode":null,"errorMessage":"remove relation %d: %w","messagePattern":"remove relation (.+?): %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/ado/links.go","lineNumber":313,"sourceCode":"\tvar removeIndices []int\n\tfor _, cl := range current {\n\t\tif _, ok := desired[cl.key]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tif !removableRelTypes[cl.key.Rel] {\n\t\t\tcontinue\n\t\t}\n\t\tif !managedTargets[cl.key.TargetID] {\n\t\t\tcontinue\n\t\t}\n\t\tremoveIndices = append(removeIndices, cl.index)\n\t}\n\t// Sort descending so higher indices are removed first.\n\tsort.Sort(sort.Reverse(sort.IntSlice(removeIndices)))\n\n\tfor _, idx := range removeIndices {\n\t\tif err := r.Client.RemoveWorkItemLink(ctx, workItemID, idx); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"remove relation %d: %w\", idx, err))\n\t\t}\n\t}\n\n\t// Find relations to add (in desired but not current).\n\tfor key, dep := range desired {\n\t\tif currentSet[key] {\n\t\t\tcontinue\n\t\t}\n\t\ttargetURL := r.buildWorkItemURL(key.TargetID)\n\t\trel := beadsDepToADORel(dep.Type)\n\t\tcomment := \"\"\n\t\tif dep.Type == \"discovered-from\" {\n\t\t\tcomment = discoveredFromComment\n\t\t}\n\t\tif err := r.Client.AddWorkItemLink(ctx, workItemID, targetURL, rel, comment); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"add link %s to %d: %w\", rel, key.TargetID, err))\n\t\t}\n\t}","sourceCodeStart":295,"sourceCodeEnd":331,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/ado/links.go#L295-L331","documentation":"During PushLinks, relations that exist in Azure DevOps but not in the desired bead dependency set are removed one by one via RemoveWorkItemLink (by relation index). Each failure is collected (not returned immediately) and wrapped as 'remove relation <idx>'. The sync reports all accumulated errors at the end.","triggerScenarios":"Calling pushADOLinks/PushLinks when RemoveWorkItemLink fails for a given relation index — e.g. 401/403 from insufficient PAT permissions, 404 because the relation was deleted concurrently, 409/412 from a stale index after concurrent edits, or network errors.","commonSituations":"PAT lacking 'Write' work item scope; two sync jobs racing and shifting relation indices between list and remove; the relation already removed by a human in the ADO UI; transient network failures during bulk removal.","solutions":["Read the wrapped inner error to identify the HTTP status; fix permissions (PAT needs Work Items Read+Write) if 401/403.","Re-run the sync: indices are recomputed from a fresh GET, which resolves stale-index (404/409) failures.","Run a single sync process at a time to avoid concurrent link edits shifting relation indices.","Retry transient network failures; RemoveWorkItemLink is idempotent per relation key on the next pass."],"exampleFix":"// before: immediate bulk remove with stale indices\n// after: re-fetch relations and remove by identity on failure\nif err := r.Client.RemoveWorkItemLink(ctx, workItemID, idx); err != nil {\n    var apiErr *APIError\n    if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {\n        continue // already removed concurrently\n    }\n    errs = append(errs, fmt.Errorf(\"remove relation %d: %w\", idx, err))\n}","handlingStrategy":"try-catch","validationCode":"// Pre-check: confirm PAT has write scope and relation index is current\nrel, resp, err := client.GetWorkItemRelations(ctx, workItemID)\nif err != nil || resp == nil || idx >= len(rel) {\n    return fmt.Errorf(\"relation index %d out of range; refresh before removing\", idx)\n}","typeGuard":"func isRemovableRelationErr(err error) bool {\n    var apiErr *APIError\n    if !errors.As(err, &apiErr) { return true } // transient/network: retry\n    return apiErr.StatusCode != http.StatusForbidden\n}","tryCatchPattern":"if err := r.Client.RemoveWorkItemLink(ctx, workItemID, idx); err != nil {\n    var apiErr *APIError\n    switch {\n    case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound:\n        continue // already gone; next sync reconciles\n    case errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden:\n        return fmt.Errorf(\"insufficient PAT permissions to remove relations: %w\", err)\n    default:\n        errs = append(errs, fmt.Errorf(\"remove relation %d: %w\", idx, err))\n    }\n}","preventionTips":["Ensure the PAT has Work Items Read+Write scope.","Run only one link-sync process at a time to avoid shifting relation indices.","Re-fetch relations immediately before removal so indices are fresh.","Treat 404 on removal as success (relation already deleted)."],"tags":["azure-devops","link-sync","http-error","concurrency"],"backgroundTag":"http-request-failed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}