You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When a user tries to change their email (see #22) to one that already belongs to another local WP account — or when a user signs in via WorkOS with an email that matches a pre-existing WP account they don't own yet — give them a verified path to claim the other account and merge the two into one canonical user.
This is the follow-on to #22's merge_request conflict policy. Until this ships, that policy degrades to a "block + friendly message" path.
Resolve the "I have two accounts on this site" support case (a common WordPress problem when single sign-on is added years after launch).
Make the merge auditable, idempotent within a request, and extensible so plugins (WooCommerce, BBPress, GravityForms, etc.) can move their per-user data.
Default to disabled because merges are destructive.
Non-goals
Multisite-wide user merges across network sites (single site only for v1).
Automatic background merging (every merge is explicitly initiated by the source-account owner or an admin).
Undoing a merge. We snapshot what changed so an admin can manually reverse, but there's no one-click undo.
Glossary
Source account (S) — the account being absorbed. Its row in wp_users is deleted (or soft-deleted; see Open questions) at the end.
Target account (T) — the account being kept. Its ID survives; its email becomes the merged identity.
Initiator — the human who triggered the merge (could be the source-account holder, the target-account holder, or an admin).
U requests email change to t@x.com. Another WP account T exists with t@x.com.
Conflict policy is merge_request. Instead of rejecting silently, plugin offers: "An account already exists for t@x.com. If it's also yours, you can merge them."
U clicks → enters merge flow. WP sends verification codes to both addresses.
U enters both codes. (If T is WorkOS-linked too, WP can substitute the WorkOS auth flow for T's verification.)
Preview is shown (see Preview).
U confirms → merge runs → S is removed → T's email is updated to t@x.com (already is) → U is logged in as T.
Flow B — user-initiated from profile
"I have another account on this site" link on the profile page → enter the other email → verify both → preview → confirm.
Flow C — admin-initiated
New page under Users → "Merge accounts". Capability: workos_merge_accounts (mapped to manage_options by default, filterable).
Admin picks source + target, optionally selects merge strategy overrides, runs preview, then commits. Admin can skip the dual-verification step (audit-logged as a forced merge).
Merge engine — what gets migrated
Driven by an internal registry. Each entry says "for source user S → target T, do this." Third-party plugins extend via filter.
Item
Default strategy
Notes
Posts (wp_posts.post_author)
reassign S → T
Same as wp_delete_user($s, $t)
Post meta authored by S
leave (belongs to post)
—
Comments (wp_comments.user_id)
reassign S → T
—
wp_usermeta for S
merge into T with conflict policy
Per-key strategy (see below)
Roles + capabilities
union
Filterable
wp_users row for S
delete (or archive; see Open questions)
After all migrations complete
WorkOS link
transfer S's _workos_user_id to T if T has none; otherwise keep T's and deactivate S's WorkOS user
Configurable
Active sessions for S
invalidated
WP_Session_Tokens::destroy_all_for_user( $s )
Application passwords for S
revoked
—
Third-party data (WC orders, BBP topics, GF entries, etc.)
via filter
See Hooks
Usermeta strategy (per-key)
target_wins (default for most keys)
source_wins
keep_both (renames source's key to _merged_from_<source_id>_<key>)
skip (drop source's value)
A default map ships with the plugin (e.g. first_name → target_wins if T has one else source_wins; _workos_* → handled by WorkOS link policy above). Sites override via a filter.
Verification
Both account owners must prove ownership before a non-admin merge:
Source: must currently be logged in as S (or have just authenticated via WorkOS as S).
Target: 6-digit code sent to T's email address, valid for 10 minutes. If T is WorkOS-linked, can substitute a fresh WorkOS auth round-trip via AuthKit.
Both codes must be submitted in the same request to prevent split-brain confirmations.
Admin (workos_merge_accounts cap) may skip target verification; this is logged as merge.forced and surfaces a banner on the merged user's profile until dismissed.
Preview (dry-run)
Before commit, render a preview screen:
Counts: posts (N), comments (N), meta keys with conflicts (N), roles being unioned, third-party items (per-plugin breakdown).
Per-conflict resolution shown with default + a "change strategy" picker.
Warnings: "S has an active subscription that will move to T", "T has a different display_name — which to keep?", etc.
Estimated row count for the transaction (capacity guard — bail out if over a configurable threshold to prevent runaway merges).
Settings / options
Option
Default
Purpose
merge_enabled
false
Master switch — feature off until explicitly enabled
merge_user_initiated_enabled
false
Even if merge_enabled, gate Flow A/B separately
merge_admin_capability
manage_options
Mapped to workos_merge_accounts
merge_verification_code_lifetime
600
Seconds
merge_max_rows_per_run
100000
Capacity guard — preview blocks above this
merge_archive_source_user
false
If true, source is soft-deleted (renamed + role stripped) instead of wp_delete_user'd
Actions (fire in order during a commit, all inside a wrapping transaction where supported):
workos/merge/before_commit — ( int $source_id, int $target_id, array $preview )
workos/merge/migrate — ( int $source_id, int $target_id, AuditLogger $log ) ← the hook for third-party data migration. Plugins log into $log->record_extension( 'plugin-slug', [...] ).
workos/merge/after_commit — ( int $merge_id )
workos/merge/failed — ( int $source_id, int $target_id, \Throwable $e )
Open questions
Hard delete vs soft delete of source user — wp_delete_user is destructive; some sites would prefer to keep the row for audit (rename to merged_into_<target_id>, strip roles, lock login). Tracked by merge_archive_source_user setting; need to validate with users.
Multisite — out of scope for v1, but the engine should not silently corrupt data on multisite. Plan: feature is hard-disabled on multisite for v1.
WooCommerce / membership plugins — these are the highest-value migration cases. v1 ships the hook surface; concrete adapters (WC orders, subs, downloads) are tracked separately once workos/merge/migrate lands.
GDPR / data-export interaction — if a deletion request is in flight for either user, block the merge until it resolves.
Token storage for verification — transients (current plan) vs. a dedicated table. Transients are fine for v1; revisit if we hit cache eviction issues.
Security checklist
Both account ownerships proven (unless admin override, which is audit-logged).
Preview hash prevents commit drift between preview and commit.
Capacity guard prevents runaway transactions.
All sessions/app-passwords for source revoked atomically.
WorkOS-link transfer cannot orphan a WorkOS user without deactivating it.
CSRF nonces on all admin actions; capability gates on REST.
No PII (emails) in URL params; verification codes via body only.
Audit row written before destructive steps so a partial failure is still traceable.
Tests (slic / WPUnit)
Happy path: source has posts + comments + roles + meta + WorkOS link; target has different roles + meta; commit migrates everything correctly per default strategies.
Rollback: simulate failure inside workos/merge/migrate → engine rolls back, source still exists, no orphaned data.
Verification: admin can commit without target code (forced merge, audit-logged).
Preview hash mismatch → commit refused.
Capacity guard: preview blocks when row counts exceed threshold.
Usermeta strategies: target_wins, source_wins, keep_both rename, skip — each verified.
WorkOS link transfer: source had link + target had none → target gets it; source's WorkOS user is not deactivated. Both had links → source's WorkOS user is deactivated.
Audit table: row created in pending_verification, transitions through previewed → committed.
Multisite gating: REST + admin page disabled on multisite.
Phasing suggestion
This is large. Suggest splitting the implementation into PRs:
2.1 — Audit table migration, MergeEngine + Strategy classes, AdminPage (admin-initiated only, no user flow). No conflict detector. Ship behind merge_enabled=false.
Summary
When a user tries to change their email (see #22) to one that already belongs to another local WP account — or when a user signs in via WorkOS with an email that matches a pre-existing WP account they don't own yet — give them a verified path to claim the other account and merge the two into one canonical user.
This is the follow-on to #22's
merge_requestconflict policy. Until this ships, that policy degrades to a "block + friendly message" path.Goals
Non-goals
Glossary
wp_usersis deleted (or soft-deleted; see Open questions) at the end.IDsurvives; its email becomes the merged identity.User flows
Flow A — triggered from #22 conflict
s@x.com, WorkOS-linked).t@x.com. Another WP account T exists witht@x.com.merge_request. Instead of rejecting silently, plugin offers: "An account already exists for t@x.com. If it's also yours, you can merge them."t@x.com(already is) → U is logged in as T.Flow B — user-initiated from profile
Flow C — admin-initiated
workos_merge_accounts(mapped tomanage_optionsby default, filterable).forcedmerge).Merge engine — what gets migrated
Driven by an internal registry. Each entry says "for source user S → target T, do this." Third-party plugins extend via filter.
wp_posts.post_author)wp_delete_user($s, $t)wp_comments.user_id)wp_usermetafor Swp_usersrow for S_workos_user_idto T if T has none; otherwise keep T's and deactivate S's WorkOS userWP_Session_Tokens::destroy_all_for_user( $s )Usermeta strategy (per-key)
target_wins(default for most keys)source_winskeep_both(renames source's key to_merged_from_<source_id>_<key>)skip(drop source's value)A default map ships with the plugin (e.g.
first_name→target_winsif T has one elsesource_wins;_workos_*→ handled by WorkOS link policy above). Sites override via a filter.Verification
Both account owners must prove ownership before a non-admin merge:
Admin (
workos_merge_accountscap) may skip target verification; this is logged asmerge.forcedand surfaces a banner on the merged user's profile until dismissed.Preview (dry-run)
Before commit, render a preview screen:
Settings / options
merge_enabledfalsemerge_user_initiated_enabledfalsemerge_enabled, gate Flow A/B separatelymerge_admin_capabilitymanage_optionsworkos_merge_accountsmerge_verification_code_lifetime600merge_max_rows_per_run100000merge_archive_source_userfalsewp_delete_user'dmerge_default_strategiesREST endpoints
POST /workos/v1/accounts/merge/initiate—{ source_id, target_id }→ returns verification challenge IDs.POST /workos/v1/accounts/merge/verify—{ challenge_id, code }(one per side).POST /workos/v1/accounts/merge/preview—{ source_id, target_id, strategy_overrides? }→ returns preview payload.POST /workos/v1/accounts/merge/commit—{ source_id, target_id, preview_hash, strategy_overrides? }→ runs merge transactionally.GET /workos/v1/accounts/merge/audit/{merge_id}— returns audit record for a past merge.preview_hashensures the data the user previewed hasn't drifted before commit (recompute on commit and compare).Storage
New custom table
{$wpdb->prefix}workos_merge_audit:idsource_user_idtarget_user_idsource_emailtarget_emailinitiator_idinitiator_rolestrategy_snapshotmigrated_countsextension_payloadscommitted_atstatuspending_verification|previewed|committed|failedVerification challenges live in transients (short-lived).
Architecture
Hooks
Filters:
workos/merge/enabled— boolworkos/merge/registry— register custom item handlers (THIS is the extension surface for WC, BBP, etc.)workos/merge/usermeta_strategy— per-key strategy overrideworkos/merge/capacity_threshold— intworkos/merge/can_initiate— bool gateActions (fire in order during a commit, all inside a wrapping transaction where supported):
workos/merge/before_commit—( int $source_id, int $target_id, array $preview )workos/merge/migrate—( int $source_id, int $target_id, AuditLogger $log )← the hook for third-party data migration. Plugins log into$log->record_extension( 'plugin-slug', [...] ).workos/merge/after_commit—( int $merge_id )workos/merge/failed—( int $source_id, int $target_id, \Throwable $e )Open questions
wp_delete_useris destructive; some sites would prefer to keep the row for audit (rename tomerged_into_<target_id>, strip roles, lock login). Tracked bymerge_archive_source_usersetting; need to validate with users.workos/merge/migratelands.Security checklist
Tests (slic / WPUnit)
workos/merge/migrate→ engine rolls back, source still exists, no orphaned data.workos/change_email/conflict_detectedfrom Feature: Change email address (WorkOS-verified, WP conflict-guarded) #22 and surfaces a merge offer.pending_verification, transitions throughpreviewed→committed.Phasing suggestion
This is large. Suggest splitting the implementation into PRs:
merge_enabled=false.workos/merge/migrateextension hook + reference docs for third-party plugin authors.Acceptance criteria (whole feature)
Depends on
workos/change_email/conflict_detecteddefined there. Without Feature: Change email address (WorkOS-verified, WP conflict-guarded) #22 this feature still works for Flow B/C, but Flow A doesn't exist.