Skip to content

fix(tasks): temporary tool to detect and merge codebar auth duplicate members - #2809

Draft
mroderick wants to merge 11 commits into
masterfrom
fix/codebar-auth-duplicate-members
Draft

mroderick wants to merge 11 commits into
masterfrom
fix/codebar-auth-duplicate-members

Conversation

@mroderick

Copy link
Copy Markdown
Collaborator

Problem

When the codebar auth flow was merged on 2026-08-06, members with an existing GitHub account whose email differed from their stored email could not be matched automatically. The codebar auth flow created new member accounts instead of linking to existing ones (#2805).

Solution

Add a temporary rake task and Makefile targets to detect and safely merge duplicate members.

Detection strategies

  • name+surname — exact case-insensitive match
  • email — exact case-insensitive match
  • first-name+uid-surname — first name matches; duplicate has no surname, but the codebar auth UID contains the original's surname
  • domain+local-part — non-generic domain with overlapping local parts

Plus hard-coded manual overrides for edge cases the heuristics cannot detect.

Safety

  • Dry run by default — must pass APPLY=1 to change data.
  • Idempotent — re-running after a merge reports no duplicates.
  • Deactivates, not deletes — duplicate emails are renamed to duplicate.<id>.merged-into.<id>@codebar.io, auth services and roles are removed, and a MemberNote preserves audit history.
  • Logged — every real run writes to a new timestamped JSON log at log/merge_duplicate_members/run_YYYYMMDDTHHMMSSZ.json.

Usage

make detect_duplicate_members   # list duplicates
make fix_duplicate_members      # dry run
make fix_duplicate_members APPLY=1  # execute
make verify_duplicate_members   # confirm none remain

Production:

heroku run rake member:duplicates:fix APPLY=1 --app codebar-production

Files

  • lib/tasks/merge_duplicate_members.rake — detection and merge logic
  • Makefile — convenience targets for local and Heroku operation
  • docs/merge_duplicate_members.md — operator documentation

Testing

  • Run against a fresh production dump (make dump_production).
  • Dry run shows all actions without modifying data.
  • Verify task exits 0 when no duplicates remain.

This is a temporary tool. Once the existing duplicates are resolved, it can be removed.

@mroderick
mroderick force-pushed the fix/codebar-auth-duplicate-members branch from ed7ce8f to 0569092 Compare August 21, 2026 08:36
@mroderick

mroderick commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Applied to production. 12 duplicate members merged successfully. All verified clean afterwards — no duplicates remain.

Log:

{
  "timestamp": "2026-08-21T10:44:25+02:00",
  "dry_run": false,
  "environment": "development",
  "merges": [
    { "dup_id": 31262, "orig_id": 553, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31263, "orig_id": 655, "strategies": "first-name+uid-surname", "status": "success" },
    { "dup_id": 31230, "orig_id": 5583, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31224, "orig_id": 17284, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31243, "orig_id": 22921, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31233, "orig_id": 24686, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31232, "orig_id": 25640, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31258, "orig_id": 27714, "strategies": "domain+local-part", "status": "success" },
    { "dup_id": 31257, "orig_id": 27714, "strategies": "manual", "status": "success" },
    { "dup_id": 31261, "orig_id": 29893, "strategies": "email", "status": "success" },
    { "dup_id": 31229, "orig_id": 30581, "strategies": "name+surname", "status": "success" },
    { "dup_id": 31218, "orig_id": 31192, "strategies": "email", "status": "success" }
  ],
  "errors": [],
  "total_merges": 12
}

@mroderick

Copy link
Copy Markdown
Collaborator Author

Applied to production again tonight:

{
  "timestamp": "2026-08-31T23:56:30+02:00",
  "dry_run": false,
  "environment": "development",
  "merges": [
    {
      "dup_id": 31284,
      "orig_id": 21098,
      "strategies": "name+surname",
      "status": "success"
    },
    {
      "dup_id": 31295,
      "orig_id": 31133,
      "strategies": "name+surname",
      "status": "success"
    }
  ],
  "errors": [],
  "total_merges": 2
}

@mroderick

Copy link
Copy Markdown
Collaborator Author
{
  "timestamp": "2026-09-09T11:49:49+02:00",
  "dry_run": false,
  "environment": "development",
  "merges": [
    {
      "dup_id": 31292,
      "orig_id": 13771,
      "strategies": "manual",
      "status": "success"
    }
  ],
  "errors": [],
  "total_merges": 1
}

mroderick added a commit that referenced this pull request Oct 7, 2026
…lback

When a planner id_token arrived without an `email` claim, the OmniAuth
strategy substituted the better-auth user id (`payload['sub']`) as the
member email, creating garbage members with no subscriptions or roles —
the duplicate-member mechanism behind the 2026-10-02 incident (members
31450 and 31452). The existing duplicate heuristics cannot see these
members: they share no name, email, or activity evidence with the real
account.

Adds `member:subkeyed` rake tasks (`detect` / `deactivate` / `verify`)
backed by `SubkeyedMemberCleanup`. Detection is deterministic and
planner-side: the member's email equals its own codebar auth service uid,
that shared value contains no `@`, and the member was created after the
2026-08-06 auth-flow cutoff.

Cleanup is deactivate-only: no merge target is knowable from the planner
database, and guessing merges is the harm the duplicate tooling guards
against. A detected member owning subscriptions, invitations, roles,
bans, feedbacks, or notes is skipped and reported, never touched; skipped
members count as handled, so `verify` reaches a clean PASS even while
skips remain. Runs default to a dry read; `EXECUTE=1` writes one
transaction per member (auth services removed, email renamed
`subkeyed.<id>.deactivated@codebar.io`, an audit note recording the
original email/uid). Re-runs detect nothing — repeatable and idempotent.

Pattern and deactivate flow adapted from the temporary duplicate-merge
tooling on branch fix/codebar-auth-duplicate-members (draft PR #2809),
which stays temporary and unmerged. Sequencing dependency: run production
EXECUTE=1 only after #2986 (the fail-closed strategy guard) deploys.
mroderick added a commit that referenced this pull request Oct 7, 2026
…lback

When a planner id_token arrived without an `email` claim, the OmniAuth
strategy substituted the better-auth user id (`payload['sub']`) as the
member email, creating garbage members with no subscriptions or roles —
the duplicate-member mechanism behind the 2026-10-02 incident (members
31450 and 31452). The existing duplicate heuristics cannot see these
members: they share no name, email, or activity evidence with the real
account.

Adds `member:subkeyed` rake tasks (`detect` / `deactivate` / `verify`)
backed by `SubkeyedMemberCleanup`. Detection is deterministic and
planner-side: the member's email equals its own codebar auth service uid,
that shared value contains no `@`, and the member was created after the
2026-08-06 auth-flow cutoff.

Cleanup is deactivate-only: no merge target is knowable from the planner
database, and guessing merges is the harm the duplicate tooling guards
against. A detected member owning subscriptions, invitations, roles,
bans, feedbacks, or notes is skipped and reported, never touched; skipped
members count as handled, so `verify` reaches a clean PASS even while
skips remain. Runs default to a dry read; `EXECUTE=1` writes one
transaction per member (auth services removed, email renamed
`subkeyed.<id>.deactivated@codebar.io`, an audit note recording the
original email/uid). Re-runs detect nothing — repeatable and idempotent.

Pattern and deactivate flow adapted from the temporary duplicate-merge
tooling on branch fix/codebar-auth-duplicate-members (draft PR #2809),
which stays temporary and unmerged. Sequencing dependency: run production
EXECUTE=1 only after #2986 (the fail-closed strategy guard) deploys.
mroderick added a commit that referenced this pull request Oct 7, 2026
…lback

When a planner id_token arrived without an `email` claim, the OmniAuth
strategy substituted the better-auth user id (`payload['sub']`) as the
member email, creating garbage members with no subscriptions or roles —
the duplicate-member mechanism behind the 2026-10-02 incident (members
31450 and 31452). The existing duplicate heuristics cannot see these
members: they share no name, email, or activity evidence with the real
account.

Adds `member:subkeyed` rake tasks (`detect` / `deactivate` / `verify`)
backed by `SubkeyedMemberCleanup`. Detection is deterministic and
planner-side: the member's email equals its own codebar auth service uid,
that shared value contains no `@`, and the member was created after the
2026-08-06 auth-flow cutoff.

Cleanup is deactivate-only: no merge target is knowable from the planner
database, and guessing merges is the harm the duplicate tooling guards
against. A detected member owning subscriptions, invitations, roles,
bans, feedbacks, or notes is skipped and reported, never touched; skipped
members count as handled, so `verify` reaches a clean PASS even while
skips remain. Runs default to a dry read; `EXECUTE=1` writes one
transaction per member (auth services removed, email renamed
`subkeyed.<id>.deactivated@codebar.io`, an audit note recording the
original email/uid). Re-runs detect nothing — repeatable and idempotent.

Pattern and deactivate flow adapted from the temporary duplicate-merge
tooling on branch fix/codebar-auth-duplicate-members (draft PR #2809),
which stays temporary and unmerged. Sequencing dependency: run production
EXECUTE=1 only after #2986 (the fail-closed strategy guard) deploys.
@mroderick

Copy link
Copy Markdown
Collaborator Author

Applied to production today (2026-10-08), run from a local DB_URL with the task file at this PR's head (142d292). Production backup captured first (heroku pg:backups:capture → b032). Working tree untouched; the run log is reproduced below.

{
  "timestamp": "2026-10-08T15:35:30+02:00",
  "dry_run": false,
  "environment": "development",
  "merges": [
    { "dup_id": 31476, "orig_id": 6573,  "strategies": "manual", "status": "success" },
    { "dup_id": 31440, "orig_id": 17359, "strategies": "manual", "status": "success" },
    { "dup_id": 31390, "orig_id": 18002, "strategies": "manual", "status": "success" },
    { "dup_id": 31419, "orig_id": 30879, "strategies": "manual", "status": "success" }
  ],
  "errors": [],
  "total_merges": 4
}

All four pairs were reviewed by hand before running; the merged members carry the duplicate.<id>.merged-into.<id>@codebar.io rename and audit MemberNotes, auth services moved to the originals, zero auth services left on the duplicates. Cumulative merges via this tool: 19.

One manual companion fix followed the merge for original 6573: the duplicate's auth uid was a sub-keyed value, and the auth-app user now carries a corrected mail address, so after moving services the original got a codebar auth_service with the corrected-address uid and the dormant sub-keyed row was deleted. Without it, the next sign-in for that person would create a third member.

Remaining low-confidence matches, left unmerged and still listed by member:duplicates:verify (PASS: no high-confidence duplicates remain):

  • 31436 → 24631 (name+surname)
  • 31336 → 25796 (name+surname) — documented false positive, cited in the task source
  • 31343 → 28148 (first-name+uid-surname) — degenerate case: original's surname field is empty, so the uid-surname predicate matches anything; reduces to first-name-only
  • 31421 → 30330 (name+surname)

Three notes for this PR before it merges (verified against current master):

  1. member_email_deliveries now has a UNIQUE (member_id, email_type) index — the blind update_all(member_id:) in merge_member_email_deliveries can raise mid-run on a type collision between both members. Worth a per-row conflict check (today's pairs had none, so the run passed).
  2. subscriptions gained discarded_at + a partial unique index — orig.subscriptions.exists?(group_id:) should filter to kept rows, else a discarded original subscription can cause a duplicate's active subscription to be destroyed instead of moved.
  3. merge_invitations dedupes by event_id only; with the (member_id, event_id, role) unique index a role-aware check would move rows it currently destroys.

Also worth adding to this branch: the .gitignore line for /log/merge_duplicate_members/ is present here but absent on master — today's run left the log in the repo working tree.

…erging

A shared name is not proof of the same person (see members 31336/25796),
so name-based strategies are now low-confidence: fix skips them unless
INCLUDE_WEAK=1, and verify passes when only low-confidence matches
remain. Email and manual overrides stay high-confidence.
Adds a concatenated-name strategy: compares LOWER(TRIM(name) ||
TRIM(surname)) between dup and original, in both orders, so
"LenaKrasnova" (surname blank) matches "Lena Krasnova". Requires at
least one side to have a surname so it cannot degenerate into a
first-name-only match. Name-derived evidence stays low-confidence.
Verified on the production dump: fires for members 31292 and 31336 only.
Three latent traps from schema drift since the tool was written, plus a
detector guard:

- member_email_deliveries gained a UNIQUE (member_id, email_type) index
  (PR #2832). The blind update_all(member_id:) raised mid-run on a type
  collision; a colliding delivery now stays on the renamed duplicate
  instead of destroying log history.
- subscriptions gained discarded_at and a partial unique index. The
  existence check now uses kept rows only, so a discarded original
  subscription lets the duplicate's active subscription move instead of
  being destroyed.
- merge_invitations deduped by event only; the (member_id, event_id,
  role) unique index permits moves for differing roles, so the check is
  role-aware now.
- firstname_uid_surname_matches degenerated to a first-name-only match
  when the original's surname was an empty string; the predicate now
  requires a non-blank surname.

Task-level and unit specs cover each behaviour, written red first.
The doc said duplicates have "all auth services and roles removed", but
the tool re-points the duplicate's codebar auth service onto the original
so the original keeps signing in; only the remaining services and all
roles are removed.
Three findings from the review run on the rebased branch:

- The run log is now flushed before a mid-run failure re-raises, so the
  per-run JSON file accounts for pairs already merged and the failure
  itself; the documented "written even on failure" property holds. While
  adding the spec, record_error also hardened against a backtrace-less
  error.
- merge_feedback_requests moves rows per pairing instead of a blind
  update_all: a colliding (member_id, workshop_id) row stays on the
  renamed duplicate instead of violating its unique index and rolling
  back the pair.
- The fix task skips and loudly warns on any pair whose original is
  itself a merge target this run, instead of merging live data onto a
  deactivated tombstone.
@mroderick
mroderick force-pushed the fix/codebar-auth-duplicate-members branch from 142d292 to 31cbac1 Compare October 8, 2026 15:49

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant