Status: ✅ Complete — drafted August 5, 2026, finished August 12, 2026. Eight sub-phases shipped; all four open questions in §6 closed. 27.7 reverses Decision B — read it alongside §4 rather than reading §4 alone. 27.8 was written against the shipped flow rather than the spec, and is where the sub-phase boundaries themselves get audited — §7 there is the finding worth carrying forward Effort Class: Medium (one new table; most of the mechanism already exists unnamed) Surfaces: Signup (alum-facing) + Signup Admin (duplicate resolution) — plus the shared
rack_attackinitializer,ChampionSignupMerger, anddocs/compliance/Prerequisites: None. Phase 26 is a peer, not a dependency — see §8 Held surfaces touched: None
An alum who signed up six months ago comes back. How do we know it’s them, how much do we trust that, and what do we let them do about it?
Today there are three partial answers and no front door. This phase names the mechanism that already exists, makes it durable, and stops the same person from becoming three rows.
The app already does passwordless authentication. It just isn’t called that, isn’t durable, and has no entry point.
| Mechanism | Where | Lifetime |
|---|---|---|
session[:champion_signup_id] |
cookie → active_record_store |
2 weeks |
profile_token — signed, purpose-scoped |
emailed link | 72 hours (PROFILE_TOKEN_EXPIRY) |
access_code — has_secure_token |
mail-merge re-entry link | never expires |
request_link |
“email me a link” form | on demand, enumeration-safe by design |
app/models/champion_signup.rb:132-184, app/controllers/signup/signups_controller.rb:184-199.
app/views/layouts/signup.html.erb renders a header and footer and no navigation. “Sign in” is reachable only from a page you cannot reach unless you are already in.
Measured August 5, 2026.
app/controllers/signup/signups_controller.rb:219-234 decides create-vs-edit on the presence of a profile token alone:
creating = !editing_who_you_are?
Anyone on a new device who fills step 1 gets a new row regardless of history. People land on /sign-up and start typing; no amount of sign-in changes that.
ChampionSignupMerger is a working service: newest row becomes the target, blanks fill from older rows, open text combines with date tags, sources soft-delete. Staff UI backs it — BUID assignment with find_potential_alumni_matches suggestions, per-BUID merge, and bulk merge_all_duplicates.
The constraint is the key it uses. Both merge_duplicates_for_buid and merge_all_duplicates! require a non-blank buid, and BUID assignment is a manual staff action. Three consequences:
Alumni row has no assignable BUID and can never be merged.duplicates_by_email exists as a scope and is called from nowhere. Every duplicate affordance in the admin is has_duplicates_by_buid? — _sidebar.html.erb:47, signups/show.html.erb:72, and four places in signups/all.html.erb.find_by_access_code scopes to active (app/models/champion_signup.rb:154-158); ChampionSignupMerger#soft_delete_sources! stamps deleted_at on every source row.
So a permanent access-code link already sent in mail-merge outreach stops resolving the moment staff merge that row — no redirect, no notice. This is a live defect today, independent of this phase, and it gets worse once sign-in is a promoted feature and sessions point at rows too.
config/initializers/rack_attack.rb:96-132 defines champion-logins/ip, champion-signups/ip, and champion-signups/email against /login and /signup, reading a cp_champion param key. Those are Devise routes for Cp::Champion (config/routes.rb:16-33) — the held belmontalum.com portal.
The live alum-facing endpoints are /sign-up (hyphen) and /profile-link. Neither is throttled beyond the global req/ip 300/5min. SignupHoneypot is the only guard.
/profile-link is therefore an unmetered email-bomb vector — unlimited magic-link sends to any address. This is a live gap today and a prerequisite to promoting that endpoint into the nav.
opportunity-responses/ip is correctly aimed at /opportunities/:slug. Only the three champion-* throttles are aimed away from this phase’s surface.
Corrected August 6, 2026. This finding originally called those three throttles “stranded,” and the launch guide inherited the word as “removed or repointed.” Both were wrong. /login and /signup are live, reachable Devise routes — those throttles guard a real internet-facing login form. They are pointed at a surface this phase does not care about; they are not dead. Repointing them would strip rate limiting off a production login. They are renamed in place (portal-*) and the live endpoints get new throttles. See 27.2 §3.1.
POST /sign-up is the whole funnel, not a create endpointFound August 6, 2026 while planning 27.2, and recorded here because it invalidates the obvious reading of “meter /sign-up” in §5.
STEPS (signups_controller.rb:32) routes who_you_are, affinities, where_you_are, question1–question7, quiz_results, and role through the same POST /sign-up. One alum completing signup legitimately POSTs there more than ten times. A flat per-IP throttle mirroring the portal’s 5/30min would 429 the first real completion — and would bite hardest on the alumni who answer every question.
The throttle must be scoped to the tokenless who_you_are submit. See 27.2 §3.2.
docs/planning/BACKLOG.md:836 — *“Cookie consent banner |
Phase 16 | No analytics/tracking cookies; not required unless third-party tracking added | Backlog (If tracking added).”* |
A remembered-device cookie is not tracking, so it does not trip that condition — but it is exactly the non-strictly-necessary cookie the deferral was implicitly guarding against. Left as-is, the entry reads as pre-approval for a case it never considered.
app/views/layouts/signup.html.erb:25-27 preconnects to fonts.googleapis.com / fonts.gstatic.com and loads Montserrat. No cookie is set, so docs/compliance/drafts/COOKIE_POLICY.md’s “Third-party cookies: No” is literally true — but the request transmits visitor IP addresses to Google on every page load, which is the fact pattern behind the January 2022 Munich ruling against a site operator.
The cookie policy also states “That’s it — one cookie.” This phase adds a second.
app/controllers/signup/opportunities_controller.rb:140-152 — current_signup returns nil for anonymous submitters and the form accepts it. Opportunity responses are the largest source of contact data that never reaches a ChampionSignup.
No password field on the signup surface, now or later. The data is name, grad year, contact info, and interests — not financial or health. A password would add reset flows, breach surface, and support load in exchange for a credential people forget, and would depress completion on a conversion funnel.
It also keeps eventual reconciliation with Cp::Champion cheap: no hashes to migrate, no credential expectations to honor.
⚠️ REVERSED August 12, 2026 by 27.7. The recognized tier no longer exists; a remembered device is a full sign-in. The text below is kept as the record of what was decided and built in 27.3–27.5, not as current behavior.
Short version of why: the tier was the largest source of complexity on the surface, and its premise did not survive the device-list cut on the same day — that feature was dropped because “they can just sign out”, which is precisely the mitigation this tier existed to avoid depending on. Decision A (passwordless) was reviewed at the same time and confirmed; it was never the cost.
| Tier | Proof | Grants |
|---|---|---|
| Recognized | long-lived signed cookie → champion_signup_sessions row |
First name only. Prefilled/collapsed opportunity contact block. “Welcome back” on landing. Always accompanied by a visible “Not you?” |
| Verified | valid profile_token, access_code, or active signup session |
Full profile hub. All edits. |
Recognized never renders PII beyond first name and a masked email, and never permits an edit. This is what makes a 6-month cookie safe on a shared device.
Verified is the existing authorized_for_profile? (signups_controller.rb:614) — unchanged and no less strict.
A duplicate submission creates a row exactly as it does today. The prevention happens before the submit (Decision D); the resolution happens after, in Signup Admin, using tooling that already exists.
Revised August 5, 2026. The original Decision C merged at create time, filled blanks, queued divergent values to crm_data_changes, and refused to adopt the session. It was rejected as a reinvention of ChampionSignupMerger (§3.2), which already merges newest-wins-fill-blanks and is wired to a staff UI.
Worth recording why the discarded version needed its no-session-adoption rule: create-time merging is what created the leak it was guarding. Without it, the session points at the submitter’s own new row and there is nothing to guard. The simpler design does not trade the security property away — it removes the need for one.
What this phase adds instead:
ChampionSignupMerger.merge_duplicates_for_email — the same service, keyed on email. Closes §3.2’s three consequences, including signups that have no assignable BUID and can therefore never be merged today.duplicates_by_email in Signup Admin alongside the existing duplicates_by_buid affordances — sidebar count, row flags, show banner.merge_all_duplicates_by_email!. Email is a weaker identity claim than BUID: two rows sharing an address may be a shared family inbox or a typo, not one person.Divergent non-blank values keep the merger’s existing newest-wins behavior. Queueing them for review was over-engineering against a problem the current service has not produced.
Step 1 is reordered to collect email early, with the remaining fields after. An async check on the email field surfaces “This email is already registered — sign in instead?” before the person has invested effort.
This is an account-enumeration oracle and there is no wording that isn’t. Conditional display is the confirmation. Accepted deliberately: the leaked fact is program affiliation, not a credential, with no takeover path behind it, and the UX win goes directly at §3.1. The lever is cost per query, not whether it leaks:
fail2ban/throttle-abuse (rack_attack.rb:152) escalates repeat offenderslog_honeypot_trip pathDecision C is not made optional by this. The nudge is the fast path; the merge is the backstop for everyone who ignores it.
Session, CSRF, and magic-link cookies are strictly necessary and need disclosure only. The 6-month remembered-device cookie is not automatically covered by that exemption — persistent login surviving browser close generally requires an affirmative opt-in.
“Keep me signed in on this device for 6 months,” unchecked by default. That checkbox is the consent: specific, informed, affirmative, tied to a service the user requested.
No cookie banner. There is no banner-worthy category — no analytics, no pixels, no ad tech, no sale or sharing. A banner on a conversion funnel is a tax, and a badly-built one is its own liability. Consent at the point of use is better compliance and better UX.
For GPC/CPRA the correct posture is documentation, not a handler that does nothing: state in /privacy that no personal information is sold or shared. Do not ship a GPC endpoint that is a no-op.
Build order revised August 6, 2026: 27.2 ships before 27.1. The numbering is unchanged; only the sequence moved. Reason: Decision D commits the inline email check to shipping throttled, but every throttle it depends on was assigned to 27.2 — the sub-phase after the one introducing the oracle. In README order the enumeration endpoint would go live behind only the honeypot and the global 300/5min. Reordering also closes §3.4’s live /profile-link email-bomb vector one stage sooner, and makes open question 3 answerable rather than deferred.
| Part | What | Surface | Depends on |
|---|---|---|---|
| 27.2 | Ships first. Sign-in front door: header entry in layouts/signup, dedicated /sign-in page wrapping request_link; rename §3.4’s portal throttles and add live-endpoint throttles; meter the tokenless who_you_are create (§3.4a), /profile-link, and the 27.1 check endpoint; nudge email (open question 3) → 27.2 spec |
Signup | — |
| 27.1 | Prevention: step-1 reorder, inline email check (Decision D), async on blur. Resolution: merge_duplicates_for_email, duplicates_by_email scope fix, full admin surfacing, credentials follow the merge via merged_into_id (§3.3) → 27.1 spec |
Signup + Signup Admin | 27.2 (for the check endpoint’s throttle) |
| 27.3 | champion_signup_sessions + rotating token cookie; recognized/verified split (Decision B); remember-me checkbox (Decision E); device revocation |
Signup | 27.2 |
| 27.4 | Opportunity flow: collapsed contact block for recognized alumni; post-submit account offer for anonymous responders (§3.7) → 27.4 spec | Signup | 27.3 |
| 27.5 | Hub additions: past opportunity responses, sign out, device list → 27.5 spec | Signup | 27.3 |
| 27.6 | Compliance: cookie policy revision, privacy policy version bump, self-host Montserrat (§3.6), session retention rule, deletion kills sessions | Signup + docs | must ship with or before 27.3 |
| 27.7 | Added August 12, 2026, after 27.4/27.5 shipped. Reverses Decision B: the recognized tier is removed and a remembered device signs you in. Adds a six-digit code alongside every magic link, and a session-held return path → 27.7 spec | Signup + docs | 27.5; ships with its own policy rewrite |
| 27.8 | Added August 12, 2026, after walking the shipped 27.7 flow. Consolidates sign-in onto one page (the landing and thank-you copies become links), carries the typed address into the code box, inverts the email so the code leads, gives the landing page an education mode for signed-in alumni, and fixes an ERB comment leaking onto a public page → 27.8 spec | Signup | 27.7 |
27.2 and 27.1 each deploy independently and carry most of the value. 27.3–27.5 deploy as one unit. 27.6 cannot lag 27.3 — the cookie ships with its disclosure or not at all.
27.1’s credential-invalidation step must land before 27.3, or a device session can survive its own row being merged away.
Basic sign-in persistence moved into 27.2 on August 7, 2026. QA of the first 27.2 build found that promoting a “Sign in” link into the header while session establishment sat in 27.3 shipped a front door that didn’t sign anyone in — show was the only entry point that never adopted the session, so a magic-link visitor went anonymous the moment they left the hub. Fixed in 27.2 using the existing 2-week session: token adoption, first-name-plus-sign-out header, and a real sign-out landing. See 27.2 §10.
So 27.3 is now an upgrade, not the foundation: the 6-month champion_signup_sessions cookie, the recognized/verified split that makes a long-lived cookie safe on a shared device, the Decision E consent checkbox, and device revocation. Its §5 sequencing constraint with 27.6 is unchanged — the 6-month cookie still ships with its disclosure or not at all, and the 2-week session already in production is a strictly-necessary one that the existing cookie policy covers.
Cut: the staff-reviewed side-by-side per-field confirmation screen. merge_duplicates merges immediately today with no preview, staff can already compare both rows before clicking, and sources are soft-deleted so a bad merge is recoverable by clearing deleted_at. A plain confirmation dialog naming the two rows replaces it. Decision C item 3’s substantive guarantee — no bulk email merge exists — is unchanged and is the part that carries the safety property.
Added: the duplicates_by_email scope must be fixed, not merely surfaced. champion_signup.rb:217 groups on raw email — case-sensitive, and blanks not excluded, so every blank-email row would collapse into one enormous false duplicate set. Harmless while the scope is called from nowhere; wrong the moment it drives a UI. The spec’s own §10 verification command already uses LOWER(email).
Confirmed in scope: full admin surfacing — sidebar count, all.html.erb row flags, and the show banner alongside the BUID equivalents.
Moved to 27.2: the check endpoint’s throttle, and the nudge email.
Measured against the development database on August 6, 2026 (203 active signups; production may differ): 15 rows have no BUID, of which 1 has an email matching an Alumni record; 1 email-duplicate set has no BUID on any row and is therefore unmergeable by current tooling; 0 BUID-duplicate sets are outstanding. That last figure reflects staff assigning BUIDs proactively — which is currently the only thing making duplicates visible at all — not an absence of need.
champion_signup_sessions
champion_signup_id bigint not null, indexed, FK
token string not null, unique index # rotated on each verified sign-in
last_seen_at datetime indexed
user_agent string
ip string
expires_at datetime not null, indexed
created_at / updated_at
last_seen_at is a better return-activity signal than the denormalized last_return_visit_at stamp. Do not remove the existing stamp in this phase; reconcile them in the wrap.
ChampionSignupEvent)New types: signin_link_sent, signin_completed, device_remembered, device_revoked, signup_merged.
Existing link_requested, profile_viewed, and the section-saved events are unchanged.
All four are closed as of August 12, 2026.
PROFILE_TOKEN_EXPIRY change?ChampionSignup::PROFILE_TOKEN_EXPIRY is unchanged at 72.hours._link_request_form gated on signin_entry_point?, so the landing page’s copy of the same partial does not show it. The cleaner consent story won: Decision E’s checkbox is the consent, and a checkbox appearing in two places with two contexts is how an affirmative opt-in stops being informed.signup-creates/email, 3 per 15 min) now ships in 27.2 alongside it. The duplicate row is still created and the session still adopts it — Decision C is untouched. The send goes only to the address the submitter typed, so it cannot be aimed at a third party, and it leaks nothing the inline check has not already shown that same person. See 27.2 §6.show redirects anyone failing authorized_for_profile? to a working /sign-in page, and has since 19.7. Past responses render inside a view only verified visitors reach, so the answer is structural rather than a new guard. Adding a recognized-tier branch would have been the mistake — dead code guarding a route recognized visitors cannot reach. Asserted in hub_additions_test.rb so it stays true.A note on the shape of question 4. It was written expecting a policy answer and got a structural one. That happened twice in this phase: 27.5’s “sign out” was already built by 27.2, and 27.4’s “post-submit account offer” was already built by 21.6. The phase’s own §2 said most of the mechanism already existed unnamed — that turned out to apply to its later sub-phases as much as its earlier ones, and the check worth repeating in a future phase is “has an earlier sub-phase already done this?” before “how should we build it?”
| Risk | Mitigation |
|---|---|
| The inline check becomes a bulk enumeration channel | Form-token gate + 10/hr/IP throttle + fail2ban escalation + logging. Reviewed at wrap against actual probe volume, not assumed |
| A 6-month cookie on a shared/kiosk device exposes a stranger’s data | Recognized tier renders first name only and permits no edits. Persistent “Not you?”. The existing reset action (signups_controller.rb:205) already handles the deliberate case |
| Email-keyed merge folds two different people into one record | Email is a weaker claim than BUID — shared family inboxes and typos both produce it. Staff-reviewed side-by-side confirmation only; no bulk email merge. Credentials on merged-away rows are invalidated, so a cookie cannot ride a merge into someone else’s record |
| A merged-away row’s mailed access link dies silently (§3.3) | Fixed in 27.1: invalidate deliberately and redirect to sign-in with an explanatory message, rather than resolving to nil |
| 27.3 ships before 27.6 and the cookie is undisclosed | Sequencing constraint stated in §5 and enforced in the launch guide |
champion_signup_sessions becomes an undated PII store |
Retention rule written in 27.6, not retrofitted. Deletion request must destroy sessions, not just stamp deleted_at |
Phase 26 §3.1 records champion_signups as non-unique on BUID with 0-or-many rows per person — the same defect 27.1 attacks. Neither phase supersedes the other.
| Phase 26 | Phase 27 | |
|---|---|---|
| Question | Which of three tables is true when they disagree, and who may write to whom | How do we recognize a returning alum on this surface |
| Keyed on | BUID, cross-table | Email, within champion_signups |
| Blocked on | Portal unfreezing (26.3, 26.4) | Nothing |
27.1 narrows the population 26 must reconcile; it does not answer 26’s question. Write the matcher so it can delegate to Alumni::Identity once 26.1 lands, rather than duplicating match logic that phase will own.
| Item | Disposition |
|---|---|
| Cookie consent banner (BACKLOG:836) | Resolved by Decision E — checkbox consent at point of use, no banner. Update the entry; do not leave the stale “if tracking added” trigger |
| GDPR rights management (BACKLOG:835) | Partially touched — deletion must destroy sessions (27.6). Remains backlog otherwise |
| Staff merge tool for duplicate signups (BACKLOG:589) | Already built and better than the entry implies — ChampionSignupMerger plus per-BUID and bulk staff actions. The entry is stale; correct it. What is actually missing is the email key, which 27.1 adds |
Duplicate-signup warning on alumni#show (BACKLOG:701) |
Stays deferred |
| CSP enforcement mode (BACKLOG:834) | Self-hosting Montserrat (§3.4) removes two external origins and makes this cheaper. Note the link; do not pull it in |
# Duplicate signup sets by email, active only — the population 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
# Confirm the stranded throttles match no live route
bin/rails routes | grep -E '^\s+\S+\s+(GET|POST)\s+/(signup|login)\b'
# Third-party origins in the signup layout
grep -n 'https://' app/views/layouts/signup.html.erb