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)
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.
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).
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.
Signup::IdentityDelete the private current_signup and read signup_identity from
Signup::BaseController. That single change:
session_signup → follow_merge), closing the
silent-anonymous bug above,Identity already resolves in
that order and reads params[:signup_token] itself.prefill_contact_block stays, but is now called only for verified. Recognized gets
the masked summary and a server-side fill.
| 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.
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.
signup_verifiedcreate 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.
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.
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.
assert_no_match on the real
email, phone, ZIP and last name in the response body. This is the assertion that
makes Decision B enforceable rather than aspirational — a later refactor that
“helpfully” adds a hidden field fails here.signup_email_matched, not signup_verified?.signup_verified? true, hub link on
thank-you.| 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) |
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:
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?.mask_contact: on the extracted partial; their answers still render in
full, because those are theirs.None. Decision B’s contradiction was the only one, and §2 resolves it.