alumni_lookup

27.4 — Opportunity Flow for a Returning Alum

Status: ✅ Built August 12, 2026 Surface: Signup (alum-facing) — app/controllers/signup/opportunities_controller.rb, app/views/signup/opportunities/ Held surfaces touched: None Migration: None Depends on: 27.3 (Signup::Identity, ChampionSignupSession)


1. The Gap

Phase 27 §3.7: “Opportunity responses are the largest source of contact data that never reaches a signup — the form accepts an anonymous submitter and nothing follows up.”

Two halves, and only one of them is actually missing.

The account offer already exists. 21.6 built the thank-you page’s “Want to make it official?” CTA, carrying OpportunityResponse#conversion_token so the signup form arrives prefilled and the response links back on save. 27.4 does not rebuild it.

What is missing is the recognized tier. opportunities_controller.rb:143 still carries its own private current_signup, written in 21.2 and untouched by 27.3:

def current_signup
  if params[:signup_token].present?           # verified
  return nil if session[:champion_signup_id].blank?
  ChampionSignup.active.find_by(id: session[:champion_signup_id])   # verified
end

Profile token or session — both are verified. A remembered device resolves to neither, so the alum 27.3 greets by name in the header is, one click later, an anonymous stranger filling in a blank form. Signup::Identity’s own header comment already names this controller as a consumer it was built for; it was simply never wired up.

It is also a second authority on identity, which is the shape of defect 27.1 §5.1 existed to remove. It does not follow a merge (ChampionSignup.active.find_by(id:), no follow_merge), so a session pointing at a merged-away row silently goes anonymous here while the hub resolves it correctly.


2. Decision — Masked summary, hidden values

Interview, August 12, 2026. Decision B contradicts itself and had to be resolved before anything could be built. Its tier table grants recognized a “prefilled/collapsed opportunity contact block.” The paragraph directly beneath says recognized “never renders PII beyond first name and a masked email, and never permits an edit.”

A prefilled block is last name, full email, phone and ZIP sitting in the HTML. Both statements cannot hold.

Resolved: the collapsed block renders a masked summary; the real values never reach the page.

┌─ Your contact info ──────────────┐
│ Chip H.                          │
│ c•••@gmail.com                   │
│ Not you?                         │
└──────────────────────────────────┘

The alum types nothing. first_name, last_name, email, phone and zip_code are filled server-side on submit from the device session — they are not in the form, not in a hidden field, and not in view source. Whoever borrows that laptop learns a first name and a domain, which is exactly what 27.3 already decided a stranger may learn.

This is why the option is not merely a rendering choice: a hidden input holding the real email would satisfy “collapsed” while defeating the entire point of the recognized tier. No hidden_field may carry contact values for a recognized visitor.

Rejected: full visible prefill (best conversion, leaks a complete contact record on a shared device — the exact scenario Decision B’s asymmetry was written for); greeting plus an empty form (strictest, and discards the convenience that is 27.4’s whole purpose).

Verified is unchanged

A verified visitor keeps today’s behavior exactly: real values, visible, editable, in the form. They are looking at their own hub-adjacent data with proof in hand. Nothing about this decision touches them.


3. What Gets Built

3.1 Route the controller through Signup::Identity

Delete the private current_signup and read signup_identity from Signup::BaseController. That single change:

prefill_contact_block stays, but is now called only for verified. Recognized gets the masked summary and a server-side fill.

3.2 Three form states, not two

Tier Contact block Values in HTML
verified Real values in editable fields (today’s behavior) Yes — their own, with proof
recognized Masked summary card + “Not you?” No — filled server-side on submit
anonymous Empty fields (today’s behavior) n/a

“Not you?” routes to the existing forget_device! path — DELETE /sign-up/session (signups#reset), which 27.3 already made serve both “Sign out” and “Not you?” — and returns to the same opportunity form, now anonymous with empty fields.

3.3 Server-side fill on submit

In create, when the tier is recognized, contact attributes are taken from the resolved signup rather than from params. Params are ignored for those five fields, not merged — a recognized submitter had no way to type them, so anything arriving in them was not typed by the person at the keyboard.

create currently calls @response.link_verified_signup(@signup) for anyone current_signup returns. Recognized must not take that path.

SIGNUP_LINK_VERIFIED means this person proved who they are. A device cookie is Decision B’s explicitly weaker claim — it is why recognized cannot open the hub. Stamping it “verified” would tell staff a response was identity-proven when it was cookie-matched, and that flag is visible in Signup Admin.

Recognized therefore links by the ordinary email-match path (link_champion_signup_by_email), landing in signup_email_matched where it belongs. Verified keeps link_verified_signup.

Source stays opportunity_hub for both — they arrived knowing us either way.

3.5 Thank-you page — a third branch

Today: verified gets a name greeting plus “Back to your profile”; everyone else gets a deliberately identical anonymous page. That identity is the enumeration-neutrality contract from 21.2 and must not be weakened.

It is not weakened by greeting a recognized visitor. The contract exists so that typing someone else’s address cannot produce a different page. A recognized visitor is not guessing — they are holding a cookie this application issued and stored. The proof is on the request, exactly as it is for a profile token.

Tier Greeting Onward CTA
verified “Thanks, Chip” “Back to your profile” (hub link)
recognized “Thanks, Chip” “Sign in to see your profile” — the email-a-link form
anonymous “Thank you” “Want to make it official?” + “Already signed up with us?”

No hub link for recognized. Decision B: recognized cannot open the hub. The way in stays a link emailed to the address on file — and the recognized branch does not show “Want to make it official?”, because they already did.

Same re-check rule as today: the branch is decided by the tier on this request, not by @response.signup_verified?. A stored flag is not proof.


4. Events

No new ChampionSignupEvent types. opportunity_viewed and opportunity_submitted already exist and already fire for a known signup; recognized visitors now reach them, which is the intended widening.

Metadata gains tier so the wrap can answer “did the recognized tier actually convert anyone?” without inferring it from signup_link_method.


5. Test Coverage


6. What Was Built

Piece Where
Tier adoption Signup::OpportunitiesController — private current_signup deleted, signup_identity in its place
Masked contact card opportunities/fields/_recognized_contact_block
Contact-block skip opportunities/_form_fields — new recognized: local, default false
Server-side fill #fill_contact_block_from_device, RECOGNIZED_CONTACT_FIELDS
Weaker link method OpportunityResponse#link_recognized_signup
Reusable masking Signup::Identity.mask_email (class method, extracted from the instance one)
Thank-you third branch opportunities/thank_you@recognized_signup
“Not you?” return signups#reset intent: "not_you" + #return_to_opportunity_path
Tests test/controllers/signup/opportunity_recognized_test.rb (18)

7. Spec Deviations

The masked card renders above the form, not at the contact block’s position. §3.2 assumed a swap in place. _contact_block sits wherever the CMS put it, inside form_with — and “Not you?” is a button_to, which is a <form>. Nesting one there is invalid HTML.

The usual escape, link_to ... data: { turbo_method: :delete }, does not work here either: the opportunity form carries data: { turbo: false }, which disables Turbo for its descendants, so the link would silently GET. That is the same trap signups/_header documents for the opposite reason.

So _form_fields skips the contact block when recognized, and the public form renders the card above it. The relocation is also the better reading: for a recognized visitor this is no longer a field to fill in, it is a statement of who we think they are, which belongs before the questions rather than in the middle of them.

Two additions not in the spec, both found while building:

  1. The redirect after a recognized submit was minting a profile token. signup_token: @signup&.profile_token was unconditional, and @signup is now present for recognized. A 6-month cookie would have bought a credential that opens the hub — the precise upgrade Decision B exists to prevent. Now gated on verified?.
  2. The thank-you receipt needed masking. The page reads back the five contact values as a receipt, which is right for someone who typed them and is disclosure for someone who did not. mask_contact: on the extracted partial; their answers still render in full, because those are theirs.

8. Open Questions

None. Decision B’s contradiction was the only one, and §2 resolves it.