{"record":{"id":"7f5450a28492d1c5","repo":"instructure/canvas-lms","slug":"unsafe-to-decide-automatically-which-cc-to-delete-for-now","errorCode":null,"errorMessage":"Unsafe to decide automatically which CC to delete (for now): ( #{target_cc.id} , #{conflict_cc.id} ) from merge record #{cr.id}","messagePattern":"Unsafe to decide automatically which CC to delete \\(for now\\): \\( #(.+?) , #(.+?) \\) from merge record #(.+?)","errorType":"exception","errorClass":"UnsafeSplitError","httpStatus":null,"severity":"error","filePath":"app/models/split_users.rb","lineNumber":293,"sourceCode":"    # the merge is about to overwrite it)\n    cc_records.where(previous_user_id: restored_user).each do |cr|\n      target_cc = cr.context\n      # if this cc didn't get moved, we don't need to worry\n      # about deconflicting it with the source users.\n      next unless target_cc&.user_id == source_user.id\n\n      conflict_cc = restored_user.communication_channels.detect do |c|\n        c.path.casecmp?(target_cc.path) && c.path_type == target_cc.path_type\n      end\n      if conflict_cc\n        # we need to resolve before we can un-merge\n        if conflict_cc.retired? || conflict_cc.unconfirmed?\n          # when the comm channel from the target record gets moved back, it will\n          # get restored to whatever state it needs.  This one is in a useless state,\n          # so we could just blast this one away safely.\n          conflict_cc.destroy_permanently!\n        else\n          raise UnsafeSplitError, \"Unsafe to decide automatically which CC to delete (for now): ( #{target_cc.id} , #{conflict_cc.id} ) from merge record #{cr.id}\"\n        end\n      end\n    end\n\n    # move moved communication channels back\n    max_position = restored_user.communication_channels.last&.position&.+(1) || 0\n    scope = source_user.communication_channels.where(id: cc_records.where(previous_user_id: restored_user).pluck(:context_id))\n    # passing the array to update_all so we can get postgres to add the position for us.\n    unless scope.empty?\n      scope.update_all([\"user_id=?, position=position+?, root_account_ids='{?}'\",\n                        restored_user.id,\n                        max_position,\n                        restored_user.root_account_ids])\n    end\n\n    cc_records.where.not(previous_workflow_state: \"non existent\").each do |cr|\n      CommunicationChannel.where(id: cr.context_id).update_all(workflow_state: cr.previous_workflow_state)\n    end","sourceCodeStart":275,"sourceCodeEnd":311,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/models/split_users.rb#L275-L311","documentation":"SplitUsers#fix_communication_channels resolves communication channels that conflict between the target user and the restored user after an un-merge. When the conflicting channel on conflict_cc is not in a trivially discardable state (retired or unconfirmed), the code refuses to guess which channel to destroy and raises UnsafeSplitError. This is a deliberate safety guard against data loss during user merges/splits.","triggerScenarios":"Run split_users (un-merge of previously merged users) where both the target user and the restored user own an active, confirmed communication channel with the same path (e.g. same email address), so the code cannot auto-pick a channel to delete.","commonSituations":"Un-merging two user records that each independently confirmed the same email address after the original merge; admin-triggered split of users who re-verified a shared address in both accounts; migration/consolidation scripts replaying merges.","solutions":["Manually resolve the conflict first: retire or permanently destroy one of the duplicate confirmed channels (target_cc or conflict_cc) via Rails console or the UI, then re-run the split.","Decide which user should keep the address and destroy_permanently! the other channel before splitting.","If the conflict is stale, merge the users back together (admin UI) so fix_communication_channels no longer encounters the duplicate.","For bulk operations, pre-scan for duplicate confirmed channels between merge_data records and remediate before calling split_users."],"exampleFix":"// before (console)\nsplit = SplitUsers.split_users(user, merge_data)\n# => UnsafeSplitError on duplicate confirmed email\n\n// after (console)\ncc = CommunicationChannel.where(path: 'user@example.com', workflow_state: 'active').where(user_id: [user.id, restored_user.id]).where.not(user_id: keep_user.id)\ncc.each(&:destroy_permanently!)\nsplit = SplitUsers.split_users(user, merge_data) # succeeds","handlingStrategy":"try-catch","validationCode":"dupes = CommunicationChannel.where(path: shared_path, workflow_state: 'active')\n  .where(user_id: [target_user.id, restored_user.id])\n# remediate dupes (retire/destroy one side) before invoking split_users","typeGuard":"def split_safe?(target_user, restored_user)\n  paths = CommunicationChannel.active.where(user_id: [target_user.id, restored_user.id]).pluck(:path)\n  paths.size == paths.uniq.size\nend","tryCatchPattern":"begin\n  SplitUsers.split_users(user, merge_data)\nrescue UnsafeSplitError => e\n  Rails.logger.error(\"Manual CC resolution needed: #{e.message}\")\n  notify_admin(e.message)\nend","preventionTips":["Before splitting merged users, scan for communication-channel paths confirmed by both records.","Retire or destroy duplicate active channels on one side before running the split.","Document that un-merges require manual resolution when both users re-verified the same address."],"tags":["user-merge","communication-channels","split-users","data-conflict"],"backgroundTag":"invalid-state-transition","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}