alumni_lookup

27.1 — Duplicate Prevention and Email-Keyed Resolution

Status: Planned — interviewed August 7, 2026 Surface: Signup (alum-facing) + Signup Admin (staff) + the shared ChampionSignupMerger Held surfaces touched: None Ships second in Phase 27. 27.2 shipped first so the enumeration oracle below lands on an already-metered surface — see 27.2 §1 Migration: One — champion_signups.merged_into_id. See §5.1 (the README predicted none; this is a Spec Deviation, recorded in §9)


1. What This Sub-Phase Answers

Phase 27 §3.1: nothing stops a returning alum on a new device from becoming a second row. §3.2: the tooling that would clean that up is gated on a BUID a human has to assign by hand. §3.3: cleaning it up silently kills the mailed link that got them there.

27.1 attacks all three, in that order — prevent, resolve, don’t break what already works.

The two halves are independent and could ship separately; they are one sub-phase because the prevention half deliberately does not prevent everything (Decision C: no create-time merge), and the resolution half is what catches the rest.


2. Scope

# Item Half Notes
2.1 Email field moves to the top of step 1 Prevent §4.1
2.2 POST /sign-up/email-check — the endpoint 27.2’s inert throttle is waiting for Prevent §4.2
2.3 Inline hint + one-click “email me my link” on the step-1 form Prevent §4.3
2.4 ChampionSignupMerger.merge_duplicates_for_email Resolve §5.2
2.5 Fix duplicates_by_email — case-fold, exclude blanks, exclude BUID conflicts Resolve §5.3
2.6 Surface email duplicates in Signup Admin: sidebar, list flags, show banner Resolve §5.4
2.7 merged_into_id — credentials follow the merge instead of dying (§3.3) Resolve §5.1

Out of scope, deliberately: the side-by-side per-field confirmation screen (cut in the README’s “27.1 scope, settled August 6, 2026” — a plain confirm dialog naming both rows replaces it), and any bulk email merge (Decision C item 3, unchanged).


3. Decisions Made at Interview (August 7, 2026)

# Question Decision
A How far does the step-1 reorder go? Email to the top of the existing single form. Not a two-stage email gate
B What happens to a mailed access code whose row was merged away? Follow the merge target via merged_into_id. Supersedes the README’s “invalidate”
C Email merge when rows carry conflicting non-blank BUIDs? Refuse, and say why. The set still shows in the queue, flagged as unmergeable
D What does the alum get when the inline check matches? Inline hint with a one-click send, not a link away to /sign-in

Three smaller calls made without a question, stated here so they are reviewable rather than buried in a diff:


4. Prevention

4.1 Step-1 field order

_who_you_are.html.erb today: first/last name → maiden name → graduation year → email → ZIP/phone → consent.

Email moves above the name grid. Nothing else reorders, no field is added or removed, and the form stays one page and one POST.

Before                          After
┌──────────────────────────┐    ┌──────────────────────────┐
│ First name   Last name   │    │ Email address            │  ← blur fires check
│ Maiden name              │    │ ⓘ already registered…    │
│ Graduation year          │    │                          │
│ Email address            │    │ First name   Last name   │
│ ZIP          Phone       │    │ Maiden name              │
│ [ ] consent              │    │ Graduation year          │
└──────────────────────────┘    │ ZIP          Phone       │
                                │ [ ] consent              │
                                └──────────────────────────┘

The point is when the nudge can fire, not the aesthetics: the alum learns they already have a signup before they have invested effort. The two-stage email gate was rejected at interview — it adds a page to a converting funnel and creates a bounce-between-screens state that holds no record.

4.2 POST /sign-up/email-check

The endpoint rack_attack.rb:223 has been metered for since 27.2 and that has had nothing to point at. Path and verb are already fixed by that rule and must match exactly: /sign-up/email-check, POST.

POST /sign-up/email-check
  email, website, form_ts     →  200 { "known": true }
                              →  200 { "known": false }
                              →  200 { "known": false }   # honeypot trip, logged

This is an account-enumeration oracle and that is accepted, not overlooked. Phase 27 Decision D makes the argument and commits to disclosing the residual in /privacy (27.6). The lever is cost per query. §7 requires it be reviewed at wrap against real probe volume rather than assumed benign.

4.3 The inline hint

A Stimulus controller on the email field, debounced, firing on blur.

Email address
[jane@example.com________________]
┌────────────────────────────────────┐
│ ⓘ This email is already registered.│
│   [ Email me my link ]  or keep    │
│   going to start a fresh signup.   │
└────────────────────────────────────┘

“Email me my link” POSTs to the existing /profile-link — the endpoint 27.2 built, metered, and made enumeration-neutral. It is not a new send path, so there is nothing new to rate-limit and no second copy of the generic-response contract to keep honest. The person stays on the half-filled form.

Nothing blocks. Submitting anyway creates the row exactly as it does today (Decision C), the session adopts the submitter’s own new record, and 27.2’s notify_existing_signup nudge email still fires as the backstop. The inline check is the fast path; it is not a gate.


5. Resolution

5.1 merged_into_id — credentials follow the merge

This supersedes the README. Decision C item 4 and §7 both say invalidate: send the alum to /sign-in with “that link has been replaced.” Decided otherwise at interview, because a merge is precisely the assertion that these rows are one person — so pointing their link at the surviving row is both correct and strictly kinder than making them fetch a new email they may no longer have inbox access to.

Alum clicks a 6-month-old mail-merge link
        ↓
access_code → row #41   (deleted_at set, merged_into_id: 88)
        ↓
row #88 (active)  →  profile hub  ✅
add_column :champion_signups, :merged_into_id, :bigint
add_index  :champion_signups, :merged_into_id
add_foreign_key :champion_signups, :champion_signups, column: :merged_into_id

Three resolvers change, and they change together — a merge that fixed the mailed link but stranded the cookie would be a worse bug than the one being fixed:

Resolver Today After
find_by_access_code active.find_by(access_code:) → nil falls through merged_into_id to the live target
find_by_profile_token active.find_by(id:) → nil same
load_signup (session) active.find_by(id:) → drops the session same, and re-points the session at the target

Chains are re-pointed, never walked. When A merges into B and B later merges into C, the merge sets merged_into_id on its new sources and updates every row already pointing at those sources. Resolution is always exactly one hop, so there is no cycle to guard and no depth cap to pick.

Free consequence worth having: merged_into_id is a merge audit trail. Staff can see where a soft-deleted row went instead of inferring it from timestamps.

5.2 merge_duplicates_for_email

Same service, same newest-wins-fill-blanks behavior, same soft_delete_sources! — the class method is the only new surface:

ChampionSignupMerger.merge_duplicates_for_email(email)

merge_duplicates_for_buid and merge_all_duplicates! are untouched.

5.3 Fixing duplicates_by_email

champion_signup.rb:217 today:

scope :duplicates_by_email, -> { active.group(:email).having('COUNT(*) > 1') }

Case-sensitive, and blanks are not excluded — so every blank-email row collapses into one enormous false duplicate set. Harmless while nothing calls it; wrong the instant it drives a sidebar badge. The README’s own §10 verification command already uses LOWER(email).

Fixed: case-fold, exclude blank emails, and exclude BUID-conflict sets from the mergeable count. Conflict sets are still visible — surfaced flagged as unmergeable, not hidden. Hiding them would give staff a clean queue by concealing a data-quality problem they are the only ones who can fix.

find_all_with_duplicates and the status: "duplicates" list filter (signups_controller.rb:496) extend to cover both keys.

5.4 Admin surfacing

Every existing affordance is BUID-only. Each gains an email sibling:

Location Today Adds
_sidebar.html.erb:47 BUID duplicate count badge email duplicates in the same badge total
signups/all.html.erb ×4 has_duplicates_by_buid? row flags has_duplicates_by_email?, visually distinguished
signups/show.html.erb:72 BUID banner + merge button email banner + merge button, or the unmergeable-conflict explanation

The email merge button carries a turbo_confirm naming both rows and their creation dates. That is the whole of the “staff review” now that the per-field screen is cut — staff can already open both records side by side, and soft-delete means a bad merge is recoverable by clearing deleted_at (and now merged_into_id).

Authorization is unchanged: ensure_portal_admin! gates every merge action.


6. Activity Events

signup_merged joins ChampionSignupEvent::EVENT_TYPES (champion_signup_event.rb:34), recorded against the surviving row with the merged-away ids, the key used (buid or email), and the acting user.

The phase README also lists device_remembered and device_revoked — those belong to 27.3 and are not added here.


7. Risks

Risk Mitigation
The check endpoint becomes a bulk enumeration channel Honeypot gate + 10/hr/IP + fail2ban + logging, all pre-existing. Reviewed at wrap against measured probe volume, per README §7
Email merge folds two people into one record BUID-conflict refusal (§5.2) makes it structurally unreachable, not merely discouraged. No bulk email merge
A credential rides a merge into someone else’s record Only reachable through a merge, and §5.2 refuses the conflicting case. Same guarantee, one mechanism
Moving the email field depresses step-1 conversion Single-form reorder, no added page or POST. Watch completion rate at wrap
merged_into_id chains produce a cycle Re-point on merge, never walk (§5.1) — resolution is always one hop
Deletion leaves a dangling merged_into_id FK with the soft-delete model already in use; 27.6’s deletion work must clear it

8. Verification

# Email-duplicate sets the BUID-keyed merger cannot see today
echo 'ChampionSignup.active.group("LOWER(email)").having("COUNT(*) > 1").count.size' | bin/rails console

# Of those, how many have NO buid on any row (unmergeable by current tooling)
echo 'ChampionSignup.active.where(buid: [nil, ""]).group("LOWER(email)").having("COUNT(*) > 1").count.size' | bin/rails console

# The throttle that has been inert since 27.2 now has a route
bin/rails routes | grep email-check

# Blank emails must NOT collapse into one false set
echo 'ChampionSignup.active.where(email: [nil, ""]).count' | bin/rails console

Development database, August 6, 2026 (203 active signups; production differs): 15 rows have no BUID, 1 of which matches an Alumni by email; 1 email-duplicate set has no BUID on any row and is unmergeable today; 0 BUID-duplicate sets outstanding. That last figure measures staff assigning BUIDs proactively — the only thing that makes duplicates visible at all right now — not an absence of need.


9. Spec Deviations

# README says 27.1 does Why
1 “Migration: none” implied — the phase’s only new table is 27.3’s Adds champion_signups.merged_into_id Required by Decision B at interview (§5.1)
2 Decision C item 4 / §7: merged-away credentials are invalidated, alum is redirected to /sign-in Credentials follow the merge to the surviving row A merge asserts one person. Invalidating costs the alum an email round trip and assumes they still hold that inbox

Both need reflecting back into the phase README at wrap, along with the BACKLOG:589 correction the README §9 already calls for.


10. What Was Implemented

Built August 7, 2026. All of §2; nothing deferred.

Prevention

Resolution

Spec deviations beyond §9

The merger never filled buid. Found by a test asserting the survivor keeps a BUID that only the older row had. Under the BUID key this was invisible — every row in the set carries the same BUID by construction. Under the email key the newest row can be the unlinked one, and merging would have soft-deleted the only row connected to an Alumni record and kept the one that wasn’t. merge_basic_info! now fills it, which is safe precisely because §5.2 refuses conflicting sets first.

Two preloaded sets in the admin all action, @duplicate_emails and @unmergeable_emails. The BUID predicate beside them is already a per-row query; adding a second — plus a conflict check — to an intentionally unpaginated view would have been one query per flagged row with no ceiling.

Signup::SignupsController#load_signup no longer resolves the session itself. It delegates to session_signup, which 27.2 had already promoted to the base controller for the header. Two resolvers would have meant the header and the flow disagreeing about who the device is the moment one followed a merge and the other didn’t.

Tests added

File Runs Covers
test/controllers/signup/email_check_test.rb 16 §4.2 response shape, the honeypot gate, the dropped clock, “changes nothing”, §4.1 field order
test/controllers/signup_admin/email_merge_test.rb 13 §5.2 action + refusal, authorization, all three §5.4 surfacing points
test/services/champion_signup_merger_test.rb +18 Email key, BUID-conflict refusal, merged_into_id incl. the re-pointing case, merge events
test/models/champion_signup_test.rb +13 §5.1 resolvers, §5.3 scope (blank exclusion, case-folding, soft deletes)
test/controllers/signup/signups_controller_test.rb +2 The session resolver following a merge, and being cleared by a plain delete

bin/test: 5652 runs, 16098 assertions, 0 failures, 0 errors.

Carried forward