alumni_lookup

27.7 — One Signed-In State, and a Code to Get There

Status: ✅ Built August 12, 2026 Surface: Signup (alum-facing) + docs/compliance/ Held surfaces touched: None Migration: create_champion_signup_signin_codes Reverses: Phase 27 Decision B. Confirms Decision A (still passwordless).


1. Why This Exists

Three observations from the August 12 review, which turned out to be one observation:

“We don’t need to worry about the devices section. They can just sign out if they need to.”

“I can’t help but feel like we’re making this so much harder by not having a true sign in.”

“With all of these sign-ins, I want to be sure we get them back to where they were.”

The complexity was never passwordlessness

Everything expensive in 27.3–27.5 traces to Decision B’s recognized tier, not to the absence of a password: the masked-email helper, three form states on the opportunity form, three thank-you branches, Signup::Identity existing to arbitrate tiers at all, the assert_no_match PII tests, and the profile-token minting trap 27.4 caught mid-build.

A password removes none of that. It adds a credential people forget on a conversion funnel plus a reset flow — and a password reset flow is a magic link, so the mechanism stays and a second one joins it. Decision A stands, unchanged and for its original reasons.

The tier’s own premise did not survive the device-list cut

Recognized exists so a 6-month cookie is safe on a shared computer. The device list was cut on the reasoning that “they can just sign out” — which is precisely the mitigation the recognized tier was built to avoid depending on.

Accepting that reasoning for devices and rejecting it for the tier is not a position. Decision A’s own logic applies here too: this is name, grad year, contact details and interests, not financial or health data.

So: one signed-in state. Signed in or not.


2. Decision B, Reversed

  Before (27.3) After (27.7)
Tiers anonymous / recognized / verified anonymous / signed in
Device cookie grants first name + masked email, no hub, no edits a full sign-in
Opportunity contact block 3 states 2 — prefilled or empty
Thank-you page 3 branches 2
Email display masked for recognized never masked

Deleted: Signup::Identity#recognized?, #masked_email, .mask_email, #known?, steps/_recognized_banner, fields/_recognized_contact_block, _form_fields’s recognized: local, _response_receipt’s mask_contact:, OpportunityResponse#link_recognized_signup, and Signup::OpportunitiesController::RECOGNIZED_CONTACT_FIELDS.

verified? is renamed signed_in?. Keeping the old name would preserve a distinction that no longer exists — “verified” only ever meant something next to “recognized.”

What does NOT change

The consequence to state plainly

A shared computer where someone ticked “keep me signed in” now exposes a full profile — name, contact details, interests, past responses — to whoever uses it next, for six months. That is the trade being made deliberately, in exchange for a surface that is comprehensible. “Sign out” in the header is the mitigation, and it destroys the device row rather than only the cookie (27.3).


3. The Code (OTP)

One email carrying both a link and a code. Not a replacement for the magic link — an addition to the same message, so nothing new is sent and no new spend is incurred.

Your sign-in link

  [ Sign in to Alumni Champions ]

Or enter this code on the page you were on:

        4 2 9 1 7 4

Expires in 15 minutes.

Why both, rather than either

A magic link structurally cannot return someone to where they were. They are on an opportunity form on a laptop, request a link, and open the email on their phone — the link signs them in on the phone. The laptop is still signed out. Threading a return path through the link does not help: what changed is the device, not the URL.

A code has no navigation to preserve. They never leave the page: type the address, type the six digits, and they are signed in on the laptop they were already using.

So the split is by where the email gets opened, which the sender cannot know in advance — hence both, and let the person pick. This is the cross-device case BACKLOG flagged at 27.2 QA as the one place OTP genuinely wins.

Cost: zero. It rides the existing Mailgun path. (SMS was considered and rejected — Twilio is a paid add-on plus per-message plus A2P 10DLC registration, and phone is optional in the data, so coverage would be partial anyway.)

Schema

champion_signup_signin_codes
  champion_signup_id  bigint   not null, indexed, FK
  code_digest         string   not null, indexed
  expires_at          datetime not null, indexed
  attempts            integer  not null, default 0
  consumed_at         datetime
  created_at / updated_at

Digest, not the code — same reasoning as champion_signup_sessions.token_digest. Six digits is 1M possibilities, so the attempt counter is load-bearing, not decoration: five tries per code, then it is dead. Rate limiting alone is not enough when the search space is that small.

consumed_at rather than deletion, so a second submit of the same code reads as used rather than wrong, and outstanding codes for a signup are superseded on each new request.


4. Return To Where You Were

One mechanism serving both sign-in paths.

  1. Any “Sign in” link may carry ?return_to=/opportunities/admissions-fair.
  2. /sign-in validates and stores it in the session, not in the URL or the email.
  3. Whichever path completes — clicking the link, or entering the code — the redirect goes there, and the stored value is cleared.

Storing it in the session is what makes it safe and simple. It never travels through an email, so it cannot be aimed at someone else; and because the code path never leaves the browser, the session is guaranteed to still be there.

Validation: a path beginning with a single /, never // and never a scheme. This is a redirect an attacker could otherwise aim at a page built to be shared.


5. Test Coverage


6. What Was Built

Piece Where
Two-state tier Signup::Identitysigned_in? / anonymous?, signed_in_by_device_only?
Cookie → session adoption Signup::BaseController#adopt_device_sign_in
Hub guard via Identity signups#authorized_for_profile?
Sign-in code ChampionSignupSigninCode, create_champion_signup_signin_codes
Code in the same email profile_link_email (+ both templates), issued in #request_link
Code entry Signup::SessionsController#create, POST /sign-in, sessions/new
Return path Signup::BaseController#store_return_to! / #consume_return_to! / #safe_return_path
Throttle signin-code/ip, 10 per 15 min
Retention folded into champion_signup_sessions:prune
Tests signin_code_test.rb (25), plus the inversions in identity_test, remembered_device_test, opportunity_identity_test, hub_additions_test, signup_compliance_test

Deleted as planned: steps/_recognized_banner, fields/_recognized_contact_block, Identity#recognized? / #masked_email / .mask_email / #known?, _form_fields’s recognized:, _response_receipt’s mask_contact:, OpportunityResponse#link_recognized_signup, RECOGNIZED_CONTACT_FIELDS.

7. Findings

7.1 The hub guard read the session directly, and would have shipped the reversal broken

authorized_for_profile? was session[:champion_signup_id].to_i == signup.id || find_by_profile_token(...). That runs before signup_identity adopts a remembered device into the session — so with everything else in 27.7 done, a device-signed-in visitor was still bounced to /sign-in. The header would have greeted them by name while the hub disagreed.

Caught by the 27.3 test "a remembered device cannot open the profile hub" continuing to pass after the tier was removed. A test that keeps passing when the behavior it pins is supposed to have inverted is not reassurance — it is the finding. Routed to /debug.

The fix routes it through Signup::Identity, which also removed the third hand-rolled profile-token resolver. 27.1 §5.1 and 27.4 each removed one of the others.

7.2 expire_after is global, so the 6 months could not live there

The obvious way to make a session last six months is config/session_store.rb. It is shared with staff Devise sessions on the Lookup Portal, so that change would have quietly extended staff access to 49K alumni records in order to fix an alum-facing convenience. The six months stays in the champion_device cookie, which is scoped to this surface; someone who does not tick the box keeps a 2-week session.

_link_request_form said the cookie let us “greet you by name next time” — accurate for recognized, false for a full sign-in. A consent label that understates what is being consented to is not consent, so this was a blocker rather than copy polish. Same for both policies, and the version stamp records that rows accepted before 2026-08-12 agreed to something weaker.

7.4 An escaped apostrophe made an enumeration test pass vacuously

assert_match "That code didn't work" failed because ERB escapes the apostrophe. The neutrality test beside it extracted the same string with a regex and compared two nils — so it passed while asserting nothing. Fixed by matching an apostrophe-free fragment and adding assert_not_nil before the equality. Any test that compares two extracted values needs a presence assertion first, or it silently degrades into a tautology.

8. Compliance

27.6’s disclosure said “being recognized is not being signed in.” That is now false, and signup_compliance_test.rb asserts it — so the ratchet fails the build until the policy is corrected, which is exactly what it was built to do.

Both policies need: the cookie now grants a full sign-in, its six months, and the sign-in code’s 15-minute life. PRIVACY_POLICY_VERSION bumps.


9. Follow-on

Walking the shipped flow surfaced four things 27.7 left behind — a duplicated sign-in form, a link/code ordering that lost the reader’s place, a landing page that kept selling to people already signed in, and a leaked ERB comment. They ship as 27.8.