Status: ✅ Built August 12, 2026 Surface: Signup (alum-facing) —
app/controllers/signup/signups_controller.rb,app/views/signup/signups/show.html.erbHeld surfaces touched: None Migration: None Depends on: 27.3 (ChampionSignupSession,device_label,forget_device!)
Phase 27 §5 lists three items: “past opportunity responses, sign out, device list.”
Sign out already shipped in 27.2. The header renders first name plus “Sign out” for a signed-in visitor, and signing out lands on the landing page with a confirmation rather than a blank form. 27.3 then made it destroy the device row as well as the cookie. Nothing left to build; it is listed here only so the wrap does not go looking for it.
So 27.5 is two things: past opportunity responses and the device list — and the device list was cut on August 12 after being built, on the grounds that sign out already covers the only case that matters. See §4.
Both live on the hub, which is verified-only — authorized_for_profile?, unchanged since
19.7 and untouched by this sub-phase.
Phase 27 §6.4: “Does a recognized-but-not-verified alum see their own past opportunity responses? Decision B says no — that is PII. Confirm the hub degrades gracefully rather than 404ing.”
Resolved August 12, 2026: no, and the degradation already exists and is already correct.
A recognized visitor cannot reach the hub at all. show redirects anyone failing
authorized_for_profile? to signup_sign_in_path(link_request: true) — a working
sign-in page with the email-a-link form, not a 404 and not an error. That is the
graceful degradation the question asked for, and it predates the question.
The answer is therefore structural rather than a new guard: past responses are rendered inside a view only verified visitors can load, so there is no recognized-tier branch to write and no PII decision left open. Adding one would be the mistake — a recognized-tier partial on this page would be dead code guarding a route recognized visitors cannot reach.
This closes the last open question in phase-27 §6.
Interview, August 12, 2026. Title and date always visible; a disclosure expands to the same answer list the thank-you page already renders as “What you sent us.”
Things you've said yes to
▸ Admissions Fair — Nashville Aug 2
▾ Send-Off Party Host Jul 14
Can host Yes
Approx. guests 25
Notes Backyard, need a tent
▸ Speak to Students May 3
Full detail is safe here because the hub is verified-only, and it is the only thing that answers the question an alum actually has — what did I tell them? Rejected: title and date alone (nothing to expand, so the question goes unanswered); a link back to the opportunity form (which renders blank, and answers a different question).
The thank-you page’s receipt block
(thank_you.html.erb:63-95)
becomes a shared partial that both pages render. This is CLAUDE.md’s “the shared layer is
the first answer, not the last resort” applied to a partial: two consumers exist on the
day it moves, so it moves on that day rather than being copied with a note to extract it
later.
It renders @response.answered_fields, which already handles checkbox arrays (21.7) and
blank-answer skipping — no per-page logic to fork.
Placement: app/views/signup/opportunities/_response_receipt.html.erb. Both consumers
are on the signup surface, so this is not app/views/shared/ — that layer is for
cross-surface sharing, and a file there must be able to name a consumer outside its own
surface.
@signup.opportunity_responses.order(created_at: :desc) — the association already exists
via OpportunityResponse belongs_to :champion_signup. Includes :opportunity so the
titles do not N+1.
Both link methods are included — signup_verified and signup_email_matched alike.
An email-matched response is one the person filled in with their own address; withholding
it would make the hub silently incomplete, and the row is already theirs in Signup Admin.
Section hides entirely when empty. The hub’s existing convention (@opportunities is
rendered only when there are any), and an empty state here would be a shrug.
Placement: main column, after “Ways to show up.” The pairing is deliberate — what you can say yes to, then what you have said yes to.
Cut August 12, 2026, after the sub-phase was built. Rationale, from the user: “We don’t need to worry about the devices section. They can just sign out if they need to.”
The list was built as specced — sidebar card, per-row revoke, “This device” marker,
scoped lookup, empty state — and then removed: _devices.html.erb, signups#revoke_device,
the DELETE /sign-up/:id/devices/:device_id route, and the two hub ivars.
What stays, and why it is not dead code: ChampionSignupSession, device_label, and
Signup::Identity#device_session all remain. The table backs the cookie itself, the label
still names the device in device_remembered / device_revoked events, and
device_session still resolves while verified so a reload cannot stack duplicate device
rows on sign-in — the first of the two reasons Identity#resolve! documents. Only the
second reason, this list, is gone.
Sign out already does the job. It destroys the device row, not just the cookie (27.3), so the one action an alum actually needs — stop remembering me here — was covered before this list existed. What the list added was managing a device you are not currently using, which presumes both that an alum knows they left one remembered and that they would think to look. Neither is likely enough to earn a permanent card on the hub.
Worth noting for a future phase: this leaves no way to revoke a lost or stolen device remotely. The exposure is bounded by what recognized grants — a first name and a masked email, no hub, no edits — which is exactly the asymmetry Decision B bought. If the recognized tier is ever widened, this cut should be revisited with it.
No new types. device_revoked exists (27.3). Nothing is recorded for viewing the hub
beyond the profile_viewed / return-visit stamps that already fire — a section appearing
on a page is not an action.
signup_verified and signup_email_matched responses appear./sign-up/:id lands on the sign-in page —
§2’s degradation, asserted so it stays true.The device-list assertions were removed with the feature (§4).
| Piece | Where |
|---|---|
| Responses list | signup/signups/_past_responses — <details> rows, no JavaScript |
| Shared receipt | signup/opportunities/_response_receipt — two consumers on the day it moved |
| Association | ChampionSignup has_many :opportunity_responses, dependent: :nullify |
| Tests | test/controllers/signup/hub_additions_test.rb (7) |
| Built, then cut — see §4 |
OpportunityResponse’s own comment says the checkbox-answer helpers exist because “four
unrelated consumers need identical semantics: validation, the staff detail view, the CSV
export, and the notification email.”
The public thank-you page is a fifth, and it went through neither. It rendered
<%= value %>, so a two-box answer printed FridaySaturday with no separator; and it
skipped blanks with value.to_s.strip.blank?, which is never true for an array — the same
[].to_s == "[]" gap the model’s own comment describes — so a checkbox with nothing
ticked printed an empty row.
Found while extracting the block into a partial. Fixed there rather than copied, which
would have doubled the defect into the hub. The lesson is the one the comment already
tried to teach and did not reach far enough to prevent: a list of consumers written into
a comment does not stay accurate, and 21.7’s own view-layer change was the thing that
falsified it. Routed to /debug and .github/copilot-instructions.md.
ChampionSignup had no inverse for opportunity_responsesbelongs_to :champion_signup has existed since 21.2 with nothing on the other side, so a
real destroy would have raised on the foreign key. Added as dependent: :nullify: a
response is a lead in its own right, with staff follow-up and its own contact details on
the row, and it began life able to stand alone. Severing the link returns it to that state
rather than deleting work.
None. §2 closes phase-27 §6.4, which was the last one outstanding in the phase.