alumni_lookup

Phase 28 — Google Sign-In and One Row Per Person

Status: ✅ Complete — all three sub-phases shipped August 14, 2026 Surfaces: Signup (alum-facing) + Signup Admin. No held-surface writes. Prerequisites: Phase 27 — all eight sub-phases shipped August 12, 2026.


1. The Question This Phase Answers

Phase 27 gave a returning alum a way back in. It costs them a round trip through their email every time. Can we make the common case two clicks — and can we stop the same person from becoming two rows while we do it?

Two answers, in that order, because the second is a prerequisite for the first.

Google sign-in resolves an identity to a record. On a table where one email can name several records, “resolve” is a guess. find_active_by_email currently makes that guess with .order(:created_at).last — newest row wins (champion_signup.rb:405). That heuristic is defensible under a mailed link, which only ever reaches one inbox. It is not defensible under a verified credential that signs somebody straight in.

So uniqueness lands first, and the guess is retired rather than hardened.


2. Decisions

Decision A — ChampionSignup becomes account-like. It does not become an account.

Enforced uniqueness on email, one row per person, silent auto-linking of a Google identity. What it does not get: a password, Devise, confirmable, or any dependency on Cp::Champion.

Merging the two models was proposed and rejected on August 14, 2026. The reasoning is worth keeping because the instinct behind it was right:

   
Dependency direction The live intake product would depend on the frozen one. Today it runs the safe way — signup knows how to become a Champion via to_champion_attributes, and nothing on alumnichampions.com breaks if Cp:: never ships again.
30% can’t be accounts 61 of 200 active rows are mid-funnel (35 started, 26 selected_role). A lead can be half-finished; a Devise account can’t. The portal already papers over this with random_password = "#{Devise.friendly_token[0,16]}1a" (cp/champion.rb:1763) — a smell at 46 records, the default path at intake volume.
Passwordless is Decision A of Phase 27 Reaffirmed in 27.7 after direct re-litigation. Cp::Champion is the password model; adopting the class re-imports the scheme that was ruled out twice.
Anonymous responses break has_many :opportunity_responses, dependent: :nullify exists because a response is a lead in its own right and anonymous submitters are the common case.
BUID is already the shared key 189 of 200 signups (95%) carry one; cp_champions.buid is uniquely indexed (schema.rb:519). Reconciliation between the apps is a join today. Merging the tables buys what already exists and spends a 56-table held surface to get it.

What was kept from the proposal: every step in this phase is a step the merge would have needed anyway. google_uid uses the same column name and semantics as users and cp_champions; to_champion_attributes is extended to carry it, per the convergence rule that the map gets extended whenever a signup field gains a Champion equivalent.

Revisit the table question at portal unfreeze, when the dependency direction is a live choice rather than a bet on a frozen surface. Cross-app reconciliation remains Phase 26’s question.

Decision B — Google never creates a ChampionSignup.

It matches an existing row or it hands the person to the signup form with their verified details prefilled. User.from_google_oauth (user.rb:93) already works this way and is the model to follow. Cp::Champion.from_omniauth is the one not to follow — it creates on miss, which on an intake funnel is a duplicate generator aimed at the one maintenance task staff already do by hand.

Decision C — one callback, two labels.

“Continue with Google” appears on both /sign-up and /sign-in, and both hit the same endpoint running the same resolver. The destination is decided by what is found, never by which button was pressed. Someone who clicks it on the signup page and already has a record gets signed in; someone who clicks it on the sign-in page and doesn’t gets the signup form.

This is 27.8 §7 applied ahead of time rather than after the fact. Two labelled entrances sharing one resolver is one decision with two doors. Two callbacks that each guess at intent is a second copy of a decision — the defect that drifted four times in three sub-phases of Phase 27.

Decision D — a Google identity may resolve through an address the person didn’t present.

The BUID fallback is what rescues the alum who signed up with @belmont.edu and authenticates with a personal Gmail. Without it, Decision B’s “hand them to the signup form” path manufactures exactly the duplicate this phase exists to prevent.

It is also a genuine change in privacy posture and carries a PRIVACY_POLICY_VERSION bump. See 28.2 §6.


3. Sub-Phases

# Title Ships Why this order
28.1 Email uniqueness Partial unique index on LOWER(email), collision cleanup, retirement of the newest-row-wins heuristic Auto-linking assumes one row per email. Everything downstream is unsound without it.
28.2 Google sign-in google_uid, one callback, the four-way resolver, Connected Accounts, policy bump The phase’s payload. Depends on 28.1.
28.3 Step one and prefill Step 1 shortened to identity-only, locked prefill on the Google-miss path, status-ladder repair Depends on 28.2 for the prefill contract. Independently valuable.

Each deploys on its own. 28.1 is reversible by dropping an index.


4. Data Snapshot (August 14, 2026)

Measured against the development database, which carries staff test data — not production. Corrected after the fact; the numbers were originally written up as production figures and they are not. Roughly 3–4 active rows are staff test signups under one address, which is small enough that the domain split below holds (133 of 199 Gmail, ~66% excluding them) but large enough to matter for anything counting duplicates.

Re-measure against production before quoting any of this externally, and treat the duplicate figures in particular as unreliable: the one active collision and the six rows it had already absorbed were all staff testing, not a pattern in real signups.

   
Active signups 200
Duplicate email groups among active rows 1 (chiphayner@gmail.com ×2 — staff test data)
All rows including soft-deleted / merged 244
Duplicate email groups across all rows 15
Null emails 0
Blank graduation years 0
Signups carrying a BUID 189 (95%)
Merges performed to date 6
cp_champions 46

Email domain split, which decides whether Google is worth building at all:

Domain Count Google sign-in works?
gmail.com 134 (67%) Yes, day one
belmont.edu 18 No — Belmont runs Microsoft, confirmed August 14, 2026
bruins.belmont.edu 2 No, same reason
yahoo / icloud / outlook / me / hotmail 19 Never
custom domains (1 each) ~25 Some — several look like Workspace tenants

Two-thirds coverage on day one with nobody having linked anything, because auto-link-on-verified-email-match needs no prior linking. That is what makes the button worth shipping before the Connected Accounts screen rather than after.

Coverage stays at ~67%; it does not reach ~77%. An earlier draft hoped the university domains would come along if Belmont ran Workspace. It doesn’t. Those 20 rows reach their record only through Decision D’s BUID fallback, which is why that step is required rather than optional — see 28.2 §3.2.

Caveat: 200 active rows is a small sample. Gmail dominance among alumni is unlikely to invert, but the exact split will move.


5. Explicitly Out of Scope

Item Why Where it goes
Merging ChampionSignup into Cp::Champion Decision A Revisit at portal unfreeze; Phase 26 owns cross-app reconciliation
Apple Sign In, Facebook OAuth Backlogged since Phase 1.3. The resolver built here makes both cheap later — a second provider is a config entry and a uid column BACKLOG → Authentication & Signup
A password on the signup surface Phase 27 Decision A, permanent
Storing the Google address when it differs from the record’s Decided during planning: discard after authentication. The stored address may be what Advancement Services has, and CRM changes are not recallable once exported Revisit if a real need appears
Reconciling duplicates across alumni / champion_signups / cp_champions Blocked on portal unfreeze BACKLOG 5.1, still open

6. Backlog Rows This Phase Touches

Row Effect
SSO account linking (Phase 1.3, deferred for “complexity — edge case handling”) Resolved for the signup surface. The edge cases are enumerated in 28.2 §3. Row stays open for the Lookup/staff surface.
5.1 Account Merge Tool (closed 27.1) ChampionSignupMerger.merge_duplicates_for_email becomes load-bearing rather than convenient — see 28.1 §4.
Duplicate-signup warning on alumni#show (Phase 25, deferred) Premise removed. Active email duplicates become impossible, so the warning has nothing to warn about. Recommend closing rather than shipping.
Signup Admin status=duplicates filter The email half goes permanently empty by construction. The BUID half stays live — two different addresses on one BUID is still possible and still common at 95% BUID coverage. See 28.1 §5.

7. Activity Events

Event New? Notes
signin_completed with method: "google" No — reuses the existing type The type already carries method: ("code" since 27.7). A Google sign-in is a sign-in; a second event type for it would be a second copy of a decision.
google_linked Yes A new fact — account linkage, not authentication. Metadata: auto: true \| false.
google_unlinked Yes Written by the Connected Accounts disconnect action.

Both new types go in ChampionSignupEvent::EVENT_TYPES (champion_signup_event.rb:34). Cp::ActivityRecorder is not touched — wrong surface, and it is held.