{"record":{"id":"6cb0f108c9530af0","repo":"multica-ai/multica","slug":"move-anchors-are-stale-or-out-of-order","errorCode":null,"errorMessage":"move anchors are stale or out of order","messagePattern":"move anchors are stale or out of order","errorType":"http","errorClass":null,"httpStatus":409,"severity":"warning","filePath":"server/internal/handler/issue_move.go","lineNumber":194,"sourceCode":"\t\tFROM issue\n\t\tWHERE workspace_id = $1 AND id = $2\n\t`, workspaceID, *id).Scan(&position)\n\tif err != nil {\n\t\tif errors.Is(err, pgx.ErrNoRows) {\n\t\t\twriteError(w, http.StatusBadRequest, \"move anchor not found in this workspace\")\n\t\t} else {\n\t\t\twriteIssueTableQueryFailure(w, r, \"failed to resolve move anchor\")\n\t\t}\n\t\treturn nil, false\n\t}\n\treturn &position, true\n}\n\nfunc issueMovePosition(current float64, before, after *float64) (float64, error) {\n\tswitch {\n\tcase before != nil && after != nil:\n\t\tif !(*before < *after) {\n\t\t\treturn 0, errors.New(\"move anchors are stale or out of order\")\n\t\t}\n\t\tposition := *before + (*after-*before)/2\n\t\tif !(position > *before && position < *after) ||\n\t\t\tmath.IsInf(position, 0) || math.IsNaN(position) {\n\t\t\treturn 0, errors.New(\"move anchors are too close; refresh and retry\")\n\t\t}\n\t\treturn position, nil\n\tcase before != nil:\n\t\tposition := *before + 1\n\t\tif math.IsInf(position, 0) || math.IsNaN(position) {\n\t\t\treturn 0, errors.New(\"move position is out of range\")\n\t\t}\n\t\treturn position, nil\n\tcase after != nil:\n\t\tposition := *after - 1\n\t\tif math.IsInf(position, 0) || math.IsNaN(position) {\n\t\t\treturn 0, errors.New(\"move position is out of range\")\n\t\t}","sourceCodeStart":176,"sourceCodeEnd":212,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/handler/issue_move.go#L176-L212","documentation":"issueMovePosition computes a fractional-order position for reordering issues. When both `before` and `after` anchor positions are supplied, they must satisfy before < after strictly; otherwise the anchors are stale (the list changed since the client read it) or inverted, and the move is rejected with 'stale or out of order'. This is optimistic concurrency control for drag-and-drop ordering: the server refuses to place an issue between anchors that do not actually bracket a gap.","triggerScenarios":"POST/PATCH move with before_id and after_id whose current positions satisfy pos(before) >= pos(after) — e.g. another user reordered between the client's read and its move, the client sends anchors in the wrong order (after above before), or the client sends the same issue as both anchors.","commonSituations":"Two users dragging issues in the same view concurrently; stale client state after a reconnect (positions from an old list snapshot); frontend computing anchor ids from an unsorted array; rapid successive drags where the second request's anchors reflect the pre-first-drag order.","solutions":["Refresh the issue list and re-derive the anchors from current positions, then retry the move once.","Send anchors in strict list order: before = the issue above the drop target, after = the issue below.","Apply server-pushed reorder events to local state before issuing the next move.","If the 409/400 persists, fall back to moving to top/bottom (single-anchor form) which has no ordering precondition."],"exampleFix":"// before\nmoveIssue(id, {beforeId: belowIssue.id, afterId: aboveIssue.id}); // swapped\n\n// after\nmoveIssue(id, {beforeId: aboveIssue.id, afterId: belowIssue.id}); // before.position < after.position\n// on failure: const list = await refetchIssues(); retry with fresh neighbors once","handlingStrategy":"retry","validationCode":"function deriveAnchors(sortedList, targetIndex) {\n  // sortedList must be sorted by current position ascending\n  const before = targetIndex > 0 ? sortedList[targetIndex - 1] : null;\n  const after = targetIndex < sortedList.length - 1 ? sortedList[targetIndex + 1] : null;\n  if (before && after && !(before.position < after.position)) {\n    throw new Error('local list is stale: anchor positions out of order — refetch before moving');\n  }\n  return { beforeId: before?.id, afterId: after?.id };\n}","typeGuard":"const anchorsAreOrdered = (before, after) => before == null || after == null || before.position < after.position;","tryCatchPattern":"try {\n  await api.moveIssue(issueId, { beforeId, afterId });\n} catch (e) {\n  if (e.status === 400 && /stale or out of order/.test(e.message)) {\n    const list = await refetchIssues();          // refresh positions\n    const anchors = deriveAnchors(sortedByPosition(list), newIndex);\n    await api.moveIssue(issueId, anchors);        // exactly one retry\n  } else {\n    throw e;\n  }\n}","preventionTips":["Keep local order synced from server reorder events before issuing the next drag.","Always sort the local list by position before deriving anchors — never by array index alone.","Retry at most once after a refresh; a second stale failure means a real conflict to surface to the user."],"tags":["optimistic-concurrency","ordering","drag-and-drop","stale-state"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}