Status: Planned — interviewed August 7, 2026 Surface: Signup (alum-facing) + Signup Admin (staff) + the shared
ChampionSignupMergerHeld 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)
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.
| # | 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).
| # | 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:
SignupHoneypot.spam? with min_seconds: 0. Decision D
requires the gate; the clock inside it is wrong for this caller. Blur fires seconds after
render, and MIN_SECONDS = 3 would eat a fast typist’s first check. Passing 0 keeps both
traps that matter here — the website FIELD, and a forged form_ts signature — and drops
only the timer. This is the same reasoning that produced SHORT_FORM_MIN_SECONDS for the
one-field link form; the throttle is the rate lever, not the clock.{ known: true|false } and nothing else. No name, no masked email, no
signup id. Decision B’s recognized tier grants first name only to someone holding a cookie;
an anonymous POST with a guessed address is not even recognized._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.
POST /sign-up/email-checkThe 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
ChampionSignup.find_active_by_email — the same lookup handle_who_you_are
already uses for 27.2’s nudge. One definition of “this address is known,” not two that drift.known: false and logs through log_honeypot_trip, matching the
silent-fake-success convention every other public form on this surface follows.fail2ban/throttle-abuse escalating repeat offenders. Both already
exist; 27.1 adds no rate-limiting code.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.
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.
merged_into_id — credentials follow the mergeThis 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.
merge_duplicates_for_emailSame 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)
LOWER(email), blank email never matches anything.merge_all_duplicates_by_email! exists. Decision C item 3, and it is the guarantee
that carries the safety property now that the per-field confirmation screen is cut.merge_duplicates_for_buid and merge_all_duplicates! are untouched.
duplicates_by_emailchampion_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.
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.
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.
| 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 |
# 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.
| # | 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.
Built August 7, 2026. All of §2; nothing deferred.
_who_you_are.html.erb.
Email block moved above the name grid. No other field moved; still one page, one POST.POST /sign-up/email-check — Signup::SignupsController#email_check. Path and verb
match signup-email-check/ip exactly. Reuses find_active_by_email, the same lookup
27.2’s nudge uses, so the inline hint and the nudge email cannot disagree.signup_email_check_controller.js — debounced blur check, skips addresses with no
@ and repeats of the last-checked value so it doesn’t spend one of the 10/hr on a
question it just asked. “Email me my link” fetches /profile-link rather than nesting a
form, and shows its confirmation whether or not the send matched.ChampionSignupMerger.merge_duplicates_for_email + .conflicting_buids_for_email,
ConflictingBuidsError. merge_all_duplicates! stays BUID-only.ChampionSignup.active_by_email — one definition of “the rows this email covers,”
read by the predicates, the merger, and the admin’s conflict check.merged_into_id — migration, follow_merge, merged?. All three resolvers
(find_by_access_code, find_by_profile_token, Signup::BaseController#session_signup)
go through it.duplicates_by_email rewritten; find_all_with_duplicates now covers both keys, so
the status=duplicates filter sees email-only pairs.merge_duplicates_by_email action (ensure_portal_admin!), amber banner on
show, amber flags in all.html.erb ×4, sidebar badge counting both keys.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.
| 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.
PROFILE_TOKEN_EXPIRY) and Q2 (remember-me placement) remain open for 27.3.