Rollback Policy
In the current v3, account unmerge is not offered through any user-facing API or the operator console. For incidents that require forensic recovery, an operator handles it directly at the DB level.
This is a deliberate design choice that protects the consistency and security of user data. Below is the rationale, along with the procedure an operator follows in incidents where recovery is genuinely required.
Why there is no user-facing unmerge button
1. The ownership of data accumulated after a merge becomes ambiguous
After an A → B merge, RPs receive user.merged and combine A's domain data into B's view, presenting it to a single user. Over time:
- B keeps creating new work (orders, posts, payments) while looking at A's data.
- The system has no automatic way to tell whether that new work belongs to "A's context before the merge" or "B's context after the merge."
- If you were to unmerge, you would have to decide which side each piece of new work belongs to — and neither the user nor the operator can give a consistent answer.
As a result, an automatic unmerge can end not in "we preserved the data" but in "the meaning of the data became blurred," which risks causing even greater confusion for the user.
2. Each RP absorbs a merge differently
- Some RPs permanently merge the two users' data into one side (combining them and deleting the other).
- Some RPs do only a soft link (updating only the foreign key and keeping both rows).
- Some RPs perform follow-on work such as payments or orders at merge time (for example, combining the limits of two free trials).
Even if logi offered a blanket unmerge, the actual recovery would still depend on each RP's cooperation. If even one RP misses its part, the data the user sees ends up inconsistent across RPs. To avoid pushing this risk onto the user, we chose to treat recovery as an incident in which the operator explicitly determines the scope of impact first.
3. From a security standpoint, two-way merging widens the attack surface
Leaving unmerge open to users directly enables scenarios like these:
- An attacker who briefly takes over account A absorbs A into B → gains B's permissions → then deletes part of the trail with an unmerge.
- An attacker who passes T3 OTP repeats absorb-and-unmerge to inflate the audit log and bury the real incident.
Keeping the identity_links row and the audit log permanently is the safer choice, because it lets a user verify, even after the fact, what happened to their account.
4. The real cause behind "I want to separate them" is usually something else
In practice, when a "please undo the merge" request comes in, it is most often a different kind of incident that has a more appropriate handling path:
- I entered the OTP on the wrong account — Because T3 requires identity verification on both sides, this is actually very rare; but if it happens, we classify it as a social-engineering attack and handle it as a separate incident.
- I absorbed someone else's account by mistake — This amounts to account takeover, sharing, or impersonation, and incident response under GDPR or privacy law takes precedence.
- I want to use the two accounts separately again — In this case, creating a new account and asking the RP to migrate the data is both safer and clearer in meaning. Rather than having logi imitate an unmerge, a proper data export from the RP is also more transparent to the user.
Forensic recovery procedure
When an incident that genuinely requires an unmerge occurs (for example, a confirmed incident where the wrong user was absorbed), the operator performs the following. This is an operator runbook, not a user API.
- Declare an incident — A separate incident ticket, not
logi_merge_failures_total. Determine the scope of impact (which RPs are affected). - Delete the identity_links row — Direct DB work. In the same transaction, INSERT a
merge_reversedevent into the audit log. - Re-issue the absorbed user's credentials — Because every RP's
oauth_access_grantsare in a revoked state, the user must log in again via SSO. logi does not automatically revive the grants. - Notify the RPs — Emit a
user.merge_reversedevent. (Note: this event type is not currently registered in the webhook outbox. The operator emits it manually, or coordinates with the affected RPs through a separate channel.) - Post-mortem — Write the incident report: how the merge was triggered incorrectly (with T1/T2 this is near-impossible since that is normal behavior — with T3, how the OTP reached the wrong place).
SQL for operators (for reference)
BEGIN;
-- 1. Inspect the target row
SELECT * FROM identity_links
WHERE primary_user_id = :survivor_id AND linked_user_id = :merged_id;
-- 2. Record the audit entry
INSERT INTO authentication_audits (event_type, user_id, event_data, created_at)
VALUES ('merge_reversed', :survivor_id,
jsonb_build_object('linked_user_id', :merged_id, 'reason', :incident_ticket),
NOW());
-- 3. Remove the merge row
DELETE FROM identity_links
WHERE primary_user_id = :survivor_id AND linked_user_id = :merged_id;
-- 4. Restore the absorbed user's active state (if needed)
UPDATE users SET deleted_at = NULL WHERE id = :merged_id AND deleted_at IS NOT NULL;
COMMIT;⚠️ Because of the canonical resolver's 60-second cache, an RP may see the old canonical immediately after the commit. You must call CanonicalResolver.invalidate(:survivor_id) and invalidate(:merged_id), or flush the cache backend.
Future plans
Additional metadata that would let us define the data semantics of an unmerge safely (for example, a per-RP merge cursor, or explicit marking of rows created after the merge) is a candidate for review after v4. The current v3 adopts a simple model that treats a merge as one-directional only, which guarantees that users can track their own account history clearly.