Status: ✅ Built August 12, 2026 Surface: Signup (alum-facing) +
docs/compliance/Held surfaces touched: None Migration:create_champion_signup_signin_codesReverses: Phase 27 Decision B. Confirms Decision A (still passwordless).
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.”
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.
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.
| 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.”
/sign-in. It
is now a stronger credential, which makes the affirmative consent more warranted, not
less. No banner.config/session_store.rb is not touched. Its expire_after: 2.weeks is global —
it governs staff Devise sessions on the Lookup Portal, and raising it to six months would
quietly extend staff access to 49K alumni records to fix an alum-facing convenience.
The six months lives in the champion_device cookie, which is scoped to this surface;
someone who does not tick the box keeps a 2-week session, which is correct.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).
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.
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.)
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.
One mechanism serving both sign-in paths.
?return_to=/opportunities/admissions-fair./sign-in validates and stores it in the session, not in the URL or the email.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.
return_to survives both paths and is cleared afterward.return_to refuses //evil.example.com, a full URL, and a scheme.return_to still lands on the hub.| Piece | Where |
|---|---|
| Two-state tier | Signup::Identity — signed_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.
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.
expire_after is global, so the 6 months could not live thereThe 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.
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.
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.
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.