Status: Planned — interviewed August 6, 2026 Surface: Signup (alum-facing) + the shared
config/initializers/rack_attack.rbHeld surfaces touched: None. The portal throttles are renamed in place, which touches noCp::file — see §3 Ships first in Phase 27. See §7 for why this moved ahead of 27.1 Migration: None
The phase README ordered 27.1 before 27.2. That inverts a dependency the README itself
states: Decision D commits the inline email check to shipping “throttled at ~10/hour/IP,
fail2ban/throttle-abuse escalates repeat offenders” — but §5 assigns all of that throttle
work to 27.2, the sub-phase after the one that introduces the oracle.
Shipping in README order means the account-enumeration endpoint goes live behind nothing
but the honeypot gate and the global req/ip 300/5min. Decided August 6, 2026: 27.2
ships first. 27.1 then lands the oracle onto an already-metered surface, and open
question 3 (§6) becomes answerable rather than deferred.
Ordering benefit beyond the oracle: §3.4’s /profile-link email-bomb vector is live in
production today. It closes in stage 1 instead of stage 2.
| # | Item | Notes |
|---|---|---|
| 2.1 | GET /sign-in — dedicated page wrapping the existing request_link form |
Shares one partial with the landing form |
| 2.2 | Header sign-in link in layouts/signup |
Grouped with the Belmont logo — see §4 |
| 2.3 | Rename the three portal throttles; add live-endpoint throttles | See §3 |
| 2.4 | Per-email nudge email when someone ignores the 27.1 check | Resolves open question 3 — see §6 |
The landing page’s inline “email me a link” form stays exactly as it is. It converts today, and people who never look at a header would lose the affordance. Both entry points render the same partial.
The README’s §3.4 calls champion-logins/ip, champion-signups/ip, and
champion-signups/email “stranded,” which reads as delete. That framing is wrong and
the launch guide’s “removed or repointed” line is corrected with it.
/login and /signup are live, reachable Cp:: Devise routes (config/routes.rb:16-33).
Those throttles guard a real, internet-facing login form. They are aimed at a surface this
phase does not care about; they are not dead code. Repointing them at /sign-up and
/profile-link would strip rate limiting off a production login.
Decided: keep all three, rename them to name their surface honestly, and add new throttles for the live endpoints.
| Current name | New name |
|---|---|
champion-logins/ip |
portal-logins/ip |
champion-signups/ip |
portal-signups/ip |
champion-signups/email |
portal-signups/email |
Each gets a comment naming the surface and its held status, so the next person reading
rack_attack.rb does not have to re-derive which champion this is. This is CLAUDE.md
rule 3 applied to a config file: since 23.4, “champion” in a name carries no routing
signal, and these three names actively mislead — they sit in the same file as
opportunity-responses/ip, which is the live signup ecosystem.
Rename side effects, verified:
fail2ban/throttle-abuse (rack_attack.rb:152) filters on
req.env["rack.attack.matched"] being truthy — any throttle, not a name list. So the
rename does not break escalation, and every throttle added below inherits fail2ban
escalation for free. Decision D assumes exactly this and is correct to.POST /sign-up cannot take a flat per-IP throttleThis is the finding that most changes the work. POST /sign-up is not a create
endpoint — it is the whole multi-step form. STEPS (signups_controller.rb:32) is
landing, who_you_are, affinities, where_you_are, question1…question7, quiz_results, role
plus the later interest/zip saves. A single alum completing the funnel legitimately POSTs
to /sign-up well over ten times.
Mirroring the portal’s 5 per 30 minutes per IP onto this path would 429 the first real
person to complete a signup, and would look like a broken form rather than a rate limit.
It would also be worst for the most engaged alumni — the ones who answer every question.
So the per-IP throttle is scoped to the creating submit, not the path:
# Only the step that creates a row. Later steps POST to the same path a dozen
# times during one legitimate completion — see STEPS in signups_controller.
throttle("signup-creates/ip", limit: 5, period: 30.minutes) do |req|
if req.post? && req.path == "/sign-up" &&
req.params["step"].to_s == "who_you_are" &&
req.params["signup_token"].blank? # tokenless == create, not a hub edit
req.ip
end
end
The signup_token check mirrors editing_who_you_are? (signups_controller.rb:608): an
alum editing their name from the hub carries a token and must not be counted as a create.
The per-email throttle needs no such scoping — later steps do not submit
champion_signup[email], so the discriminator returns nil and the throttle skips itself
naturally. Verify that in a test rather than trusting it.
Param keys verified against the controllers: /sign-up uses
champion_signup[email] (who_you_are_params, signups_controller.rb:488);
/profile-link uses a flat email (signups_controller.rb:192).
| Name | Limit | Discriminator | Rationale |
|---|---|---|---|
signup-creates/ip |
5 / 30 min | req.ip, scoped per §3.2 |
Mirrors portal-signups/ip and opportunity-responses/ip |
signup-creates/email |
3 / 15 min | champion_signup[email], downcased + stripped |
Mirrors portal-signups/email. Precondition for 2.4 |
profile-link/ip |
5 / 15 min | req.ip |
Volume backstop |
profile-link/email |
3 / 15 min | flat email, downcased + stripped |
This is the §3.4 email-bomb fix. Per-email is the one that matters — per-IP alone still lets one attacker bomb many addresses |
signup-email-check/ip |
10 / 1 hour | req.ip |
Decision D’s stated limit. Endpoint arrives in 27.1; the throttle ships here so it is never live unmetered |
Every discriminator normalizes case and whitespace, matching the existing
logins/email comment about case-sensitive bypass (rack_attack.rb:91).
signup-email-check/ip is written against 27.1’s not-yet-existing route. That is
deliberate — an unmatched path simply never throttles, so the rule is inert until 27.1
adds the endpoint, and cannot be forgotten at the moment it starts to matter.
GET /sign-in → Signup::SessionsController#new, or a signups#sign_in action.
Prefer a new thin controller — signups_controller.rb is already ~630 lines and owns
the whole multi-step funnel; a sign-in page is a different job.
The page renders one partial, shared with the landing form, containing the email field,
the honeypot (SignupHoneypot::SHORT_FORM_MIN_SECONDS applies — single-field form), and
the submit. It posts to the existing POST /profile-link. No controller logic changes in
request_link.
The link_sent and link_request states currently redirect to
new_signup_path(...) and render as banners on the landing page
(steps/_landing.html.erb:9-33). Give them a better home: redirect to /sign-in instead,
where the message sits directly above the form that produced it. Keep the landing-page
banners working for anyone who submitted from there.
Copy must stay enumeration-neutral — identical response for known and unknown addresses. The existing wording (“If we found your information, we’ve sent a link…”) is already correct; reuse it verbatim rather than rewriting it.
_header.html.erb is a sticky blue bar, justify-between, with the Champions mark left
and the Belmont logo right. There is no nav slot.
Decided: wrap the right side in a flex group — Sign in as a small white text link,
then the Belmont logo. Preserves the two-logo structure and the bar height.
Pinned alternative (revisit after seeing it rendered, per the August 6 interview): a slim right-aligned secondary strip below the blue bar. It has more room for 27.3’s recognized-tier states — “Sign out,” “Not you?” — so if the header group feels cramped once 27.3 adds those, that is the fallback. Note this in the 27.3 spec so the decision is re-examined at the point it gets harder, not forgotten.
Do not show the sign-in link when @session_signup is present — that person is already
in, and the landing page’s resume_banner already speaks to them.
signin_link_sent is recorded where link_requested is today
(signups_controller.rb:195). Do not replace link_requested — the README’s §5 says
it is unchanged, and existing rows would lose their meaning.
Decision: link_requested stays as the event for the landing-page form;
signin_link_sent records sends originating at /sign-in, with the entry point in
metadata. If that distinction proves useless at wrap, collapse it then — collapsing later
is cheap, splitting a merged history is not.
signin_completed belongs to 27.3, where a session is actually established. Not here.
Does ignoring the inline nudge and submitting anyway trigger an email?
Yes — decided August 6, 2026. The README deferred this to 27.2 pending the per-email
throttle, and signup-creates/email (§3.3) is that precondition, landing in this
sub-phase.
Behavior: the duplicate row is still created and the session still adopts it — Decision C is untouched, and no create-time merge happens. Additionally, the matched address receives a “you already have an account — here’s your link” email.
Why this is safe here and would not have been in README order:
This does not make 27.1’s merge tooling optional. It teaches the person something staff-side merge cannot, at no schema cost — but the row is still a duplicate, and 27.1 still resolves it.
The send is skipped when the honeypot trips, matching request_link’s existing guard.
27.1’s scope was also settled in the August 6 interview and is recorded here because the two sub-phases traded items:
merge_duplicates_for_email, the duplicates_by_email scope fix, and full admin
surfacing — sidebar count, all.html.erb row flags, and the show banner alongside the
BUID equivalents.merge_duplicates
merges immediately today with no preview, staff can already see both rows before
clicking, and sources are soft-deleted so a bad merge is recoverable. A plain
confirmation dialog naming the two rows replaces it. The launch guide’s
“requires side-by-side staff confirmation” check is corrected accordingly — what
remains enforceable is that no bulk email merge exists.Measured August 6, 2026 against the development database — 203 active signups — so production may differ:
| Measure | Count |
|---|---|
| Active signups with no BUID | 15 of 203 |
Of those, rows whose email matches an Alumni record |
1 |
| Email-duplicate sets where no row has a BUID (unmergeable today) | 1 |
| BUID-duplicate sets outstanding | 0 |
| Soft-deleted rows whose email no active row carries | 16 of 38 |
| Soft-deleted rows holding an access code | 2 |
The 0 outstanding BUID duplicates is not an argument that the tooling is unnecessary — it is the signature of staff assigning BUIDs proactively, which is what makes duplicates visible at all today. Email keying removes that dependency on a human keeping up.
Throttles (test/integration/ — assert the throttle exists and that it does not fire
on legitimate traffic; the second half is what §3.2 shows we can get wrong):
STEPS — is not throttledwho_you_are create from one IP within 30 min is throttledwho_you_are submit carrying a signup_token (hub edit) is never counted as a create/profile-link POST for the same email within 15 min is throttled/profile-link throttles by email across differing IPssignup-creates/email (nil discriminator)/login and /signup are still throttled after the renamefail2ban/throttle-abuseSign-in page:
GET /sign-in renders for an anonymous visitorprofile_link_email and records the eventlink_sent / link_request states render on /sign-inHeader:
@session_signup is presentNudge email (§6):
# Confirm the renamed throttles still match the portal's live routes
bin/rails routes | grep -E '(POST)\s+/(signup|login)\b'
# Confirm the live endpoints are what the new throttles target
bin/rails routes | grep -E '(POST)\s+/(sign-up|profile-link)\b'
# Count POSTs a real completion makes — the §3.2 hazard, measured not assumed
grep -c '^\s*[a-z_]*$' /dev/null; ruby -e 'puts %w[who_you_are affinities where_you_are question1 question2 question3 question4 question5 question6 question7 quiz_results role].size'
Built August 7, 2026. bin/test: 5583 runs, 0 failures, 0 errors, 3 pre-existing skips.
Model handoff: planning and implementation both ran on Opus. The user approved skipping the model switch explicitly (“start building”), per CLAUDE.md’s Model Handoff Workflow allowance.
GET /sign-in → Signup::SessionsController#new (app/controllers/signup/sessions_controller.rb),
a thin controller as planned. Redirects an already-signed-in visitor to their hub.signup/signups/_link_request_form, rendered by both the
new page and the landing section, with entry_point / layout / submit_label locals.
The landing form’s copy and behavior are unchanged.session_signup is present.Added after QA of the first build. The spec shipped a “Sign in” affordance over a
mechanism that did not sign anyone in, and the reordering in §1 is what exposed it:
27.2 promotes the front door into the header while champion_signup_sessions — the thing
that makes sign-in persist — was assigned to 27.3.
Observed: click the header link, request a link, click it, land on the hub — and the header still read “Sign in”. Clicking the logo made you anonymous again.
Root cause was narrower than “27.3 isn’t built yet”. show was the only entry point
that never established a session:
| Entry point | Adopted the session? |
|---|---|
Just signed up (handle_who_you_are) |
Yes |
Access code (#access) |
Yes |
Profile token (#show) — the magic link |
No |
The hub threads its token through its own links via profile_path_for, so the hub worked
and nothing else did. That was an inconsistency rather than a policy: the never-expiring
access code granted a 2-week session while the 72-hour profile token — a stronger,
time-limited proof of the same claim — granted none.
Fixed in three parts:
#show adopts the signup into the session on a valid token. Ordering is
load-bearing: arrived_with_session is captured before the adoption, because
record_return_visit reads the same key. Token still beats session, so opening
someone else’s valid link switches you to their record — matching load_signup’s
documented resolution order.button_to, not
link_to method: :delete — @rails/ujs is gone and Turbo would silently GET.#reset gained an intent param. “Start a new signup” on a shared device still
lands on the blank form; intent=sign_out lands on the landing page with a
confirmation banner. Dropping someone onto a signup form they didn’t ask for is the
same “we’ve forgotten you” mistake 19.7 fixed for expired tokens.No decisions changed. Decision B already defines an active signup session as verified, which grants the full hub, so a first name in the header discloses nothing new. A 2-week session from a verified click is strictly more conservative than the 6-month cookie 27.3 plans. The header partial carries a comment forbidding any further PII, because 27.3’s recognized tier will share it.
Considered and rejected: switching from magic links to an emailed code. The
credential mechanism was not the cause — an OTP that doesn’t establish a session produces
the identical experience, and a reusable emailed code is access_code, which never
expires and already has the §3.3 defect. Cross-device (email on phone, browser on laptop)
is the one place OTP genuinely wins; recorded as a possible later addition, not a
replacement.
What this leaves for 27.3: duration (6 months), the recognized/verified split for shared devices, the consent checkbox, and device revocation. It is now an upgrade rather than the thing that makes sign-in work.
session_signup was promoted to Signup::BaseController as a helper_method,
not duplicated into the new controller as §4.1’s first draft suggested. The header
renders on every page of the surface, so an ivar assigned by one controller could not
serve it. Signup::SignupsController’s private copy was removed.link_request now redirects to /sign-in from all three of its origins — the
expired-token step guard, the unauthorized hub view, and the unknown access code —
rather than only being renderable there. Six existing assertions in
signups_controller_test.rb were updated; each test’s intent (“offers a fresh link
prompt”) is unchanged, only the URL.signup-creates/email gained the signup_token.blank? condition that §3.2 said
it would not need. The reasoning in the spec was wrong: later steps do skip the
throttle on their own, but a hub edit of step 1 submits champion_signup[email],
so an alum fixing a typo then their phone then their ZIP would have hit a
3-per-15-minutes cap on their own profile. Caught by the tokenful-edit test.Both surfaced from the first test ever written against a throttle in this repo. Neither
was introduced by this sub-phase; both are documented in /debug and
.github/copilot-instructions.md.
throttled_responder / blocklisted_responder were written as ->(env), but
rack-attack 6.0 passes a Rack::Attack::Request, which has no #[]. So no
Retry-After was ever sent and the responders’ own log lines never ran. Regression
coverage: test/integration/rack_attack_responder_test.rb.fail2ban/throttle-abuse never banned anyone. It incremented its counter from
inside the blocklist, reading rack.attack.matched — a key no throttle has written
yet, because Rack::Attack evaluates blocklists before throttles. The blocklist now
only reads Allow2Ban.banned?; the counter is incremented from the
throttle.rack_attack subscriber. Decision D’s escalation claim was false until
this fix and is now true and tested.All five new rules landed as specified in §3.3, plus the §3.2 scoping and deviation 3
above. The three portal rules were renamed portal-* with a comment naming the surface;
test "throttles are named for the surface they actually protect" fails if a
champion-* name returns.
ChampionSignupMailer#existing_signup_notice + text/HTML templates following the
canonical eyebrow-and-headline header. Wired in handle_who_you_are via
notify_existing_signup; the match is resolved before save, since
find_active_by_email takes the newest row and would otherwise return the row just
created.
signin_link_sent added to ChampionSignupEvent::EVENT_TYPES. link_requested kept for
the landing form as planned. The nudge records signin_link_sent against the
pre-existing record with entry_point: "duplicate_nudge" and
triggered_by_signup_id.
| File | Runs | Covers |
|---|---|---|
| test/integration/signup_throttling_test.rb | 15 | §8’s throttle matrix, funnel-not-throttled first |
| test/integration/rack_attack_responder_test.rb | 6 | The 429/403 response contract |
| test/controllers/signup/sessions_controller_test.rb | 36 | Page, enumeration neutrality, entry-point routing, header, session adoption, sign out |
| test/controllers/signup/duplicate_nudge_test.rb | 14 | §6, incl. “the matched record is not modified” |
PROFILE_TOKEN_EXPIRY) and Q2 (remember-me placement) remain open for 27.3, as
planned.signup-email-check/ip is inert until 27.1 adds POST /sign-up/email-check. Its
registration and limits are asserted; there is no endpoint to hit yet, by design.