Repository navigation
Conversation
ed7ce8f to
0569092
Compare
|
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
} |
|
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
} |
{
"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
} |
…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.
…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.
…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.
|
Applied to production today (2026-10-08), run from a local {
"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 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 Remaining low-confidence matches, left unmerged and still listed by
Three notes for this PR before it merges (verified against current master):
Also worth adding to this branch: the |
… duplicate members
… detection output
…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.
142d292 to
31cbac1
Compare
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 matchemail— exact case-insensitive matchfirst-name+uid-surname— first name matches; duplicate has no surname, but the codebar auth UID contains the original's surnamedomain+local-part— non-generic domain with overlapping local partsPlus hard-coded manual overrides for edge cases the heuristics cannot detect.
Safety
APPLY=1to change data.duplicate.<id>.merged-into.<id>@codebar.io, auth services and roles are removed, and aMemberNotepreserves audit history.log/merge_duplicate_members/run_YYYYMMDDTHHMMSSZ.json.Usage
Production:
Files
lib/tasks/merge_duplicate_members.rake— detection and merge logicMakefile— convenience targets for local and Heroku operationdocs/merge_duplicate_members.md— operator documentationTesting
make dump_production).This is a temporary tool. Once the existing duplicates are resolved, it can be removed.