instructure/canvas-lms · error · UnsafeSplitError
Unsafe to decide automatically which CC to delete (for now)
Error message
Unsafe to decide automatically which CC to delete (for now): ( #{target_cc.id} , #{conflict_cc.id} ) from merge record #{cr.id} What it means
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.
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.
Example fix
// before (console) split = SplitUsers.split_users(user, merge_data) # => UnsafeSplitError on duplicate confirmed email // after (console) cc = CommunicationChannel.where(path: 'user@example.com', workflow_state: 'active').where(user_id: [user.id, restored_user.id]).where.not(user_id: keep_user.id) cc.each(&:destroy_permanently!) split = SplitUsers.split_users(user, merge_data) # succeeds
Defensive patterns
Strategy: try-catch
Validate before calling
dupes = CommunicationChannel.where(path: shared_path, workflow_state: 'active') .where(user_id: [target_user.id, restored_user.id]) # remediate dupes (retire/destroy one side) before invoking split_users
Type guard
def split_safe?(target_user, restored_user) paths = CommunicationChannel.active.where(user_id: [target_user.id, restored_user.id]).pluck(:path) paths.size == paths.uniq.size end
Try / catch
begin
SplitUsers.split_users(user, merge_data)
rescue UnsafeSplitError => e
Rails.logger.error("Manual CC resolution needed: #{e.message}")
notify_admin(e.message)
end Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15).
Data as JSON: /api/errors/7f5450a28492d1c5.
Report an issue: GitHub.
Appendix: source
Thrown at app/models/split_users.rb:293
# the merge is about to overwrite it)
cc_records.where(previous_user_id: restored_user).each do |cr|
target_cc = cr.context
# if this cc didn't get moved, we don't need to worry
# about deconflicting it with the source users.
next unless target_cc&.user_id == source_user.id
conflict_cc = restored_user.communication_channels.detect do |c|
c.path.casecmp?(target_cc.path) && c.path_type == target_cc.path_type
end
if conflict_cc
# we need to resolve before we can un-merge
if conflict_cc.retired? || conflict_cc.unconfirmed?
# when the comm channel from the target record gets moved back, it will
# get restored to whatever state it needs. This one is in a useless state,
# so we could just blast this one away safely.
conflict_cc.destroy_permanently!
else
raise UnsafeSplitError, "Unsafe to decide automatically which CC to delete (for now): ( #{target_cc.id} , #{conflict_cc.id} ) from merge record #{cr.id}"
end
end
end
# move moved communication channels back
max_position = restored_user.communication_channels.last&.position&.+(1) || 0
scope = source_user.communication_channels.where(id: cc_records.where(previous_user_id: restored_user).pluck(:context_id))
# passing the array to update_all so we can get postgres to add the position for us.
unless scope.empty?
scope.update_all(["user_id=?, position=position+?, root_account_ids='{?}'",
restored_user.id,
max_position,
restored_user.root_account_ids])
end
cc_records.where.not(previous_workflow_state: "non existent").each do |cr|
CommunicationChannel.where(id: cr.context_id).update_all(workflow_state: cr.previous_workflow_state)
endView on GitHub (pinned to 1c9f0bb801)