alumni_lookup

Phase 28.3 — Step One and the Prefill Handoff

Status: ✅ Complete — shipped August 14, 2026 Effort Class: Small-to-medium. No new table. One data backfill. Surfaces: Signup (alum-facing). No held-surface writes. Prerequisites: 28.2 for the prefill contract.


1. The Finding That Reframed This Sub-Phase

Planning began from a proposal to move ZIP into step 1. Checking the view first turned up that it has been there since roughly November 2025: _who_you_are.html.erb already collects email, first name, last name, maiden name, graduation year, ZIP (required: true, line 134), phone, and SMS consent — all in one step.

Which means the consequence is not hypothetical. It is live:

def calculate_status
  return 0 unless email.present? && graduation_year.present?
  if zip_code.present?
    5 # zip_code completed

Finishing step 1 marks you status: 5, which status_label prints as “Completed” (champion_signup.rb:320-334).

Evidence  
Active rows at status: 5 with no role and no interests 6
Blank-ZIP rows — all legacy 61, newest created 2025-11-19
Blank-ZIP rows created in the last 90 days 0

The 61 mid-funnel rows are residue from before ZIP moved forward. Since then, everyone who finishes step 1 reports Complete, and 6 of them did nothing else.

So the ask — a shorter first step — and the repair are the same edit. That is the shape of this sub-phase.

2. Step 1 Becomes Identity-Only

Step 1 — who you are
    [ Continue with Google ]
    ─────── or ───────
    Email  ·  First name  ·  Last name  ·  Graduation year

Step 2 — where you are
    ZIP  ·  Phone  ·  SMS consent  ·  maiden name  ·  everything else

Graduation year stays. It is already required (champion_signup.rb:2), it gates calculate_status before any other branch, and it is the anchor that makes BUID matching plausible in the first place.

ZIP, phone, SMS consent and maiden name move to step 2. _where_you_are.html.erb already exists and is where they belong.

The property worth naming: for a Google user, graduation year becomes the only field typed at step 1. Email, first and last all arrive from the token, locked. Adding ZIP would double that.

3. The Status Ladder Repairs Itself — Partially

With ZIP back in step 2, calculate_status means what it says again: zip_code (5) is a later-funnel marker rather than something a brand-new record trips on its first save.

Two things this does not fix on its own, both needing an explicit decision:

The 6 mis-statused rows. They are stamped 5 and have no role or interests. A update_status! pass over active rows recomputes them correctly only after the form change lands — run before, and they are still Complete-by-ZIP. Sequence: ship the form change, then backfill. The rake task goes in the launch guide.

⚠️ Correction — this paragraph was wrong (August 14, 2026)

No update_status! pass could ever have fixed those rows, before or after the form change. calculate_status reads zip_code.present?, and moving the field out of step 1 does not remove ZIPs already stored. Worse, before_save :ensure_status_updated re-derives status on every save, so even a direct UPDATE would have been undone by the next write to the row.

The dry run proved it: after the form change and before touching the model, the task reported “Every active signup’s status already matches its data.”

What actually shipped is a change to calculate_status itself — the top rung now requires a ZIP plus evidence the person got past step 1 (a quiz result, a chosen role, or an interests answer). See §3a. The backfill task then makes stored values agree with the corrected rule, which is the only thing a backfill can do.

The count is 5, not 6. The sixth was the duplicate merged away in 28.1.

status is still derived from field presence, not from funnel position. ZIP moving back makes the ladder correct for the current form; it does not make it robust against the next reordering. The durable fix is an explicit furthest_step column with a backfill across 244 rows and re-pointing every reader of completed?.

Recommendation: do not do that here. It is a bigger change than everything else in this sub-phase combined, it touches Signup Admin reporting, and the field-derived version is correct once ZIP moves. Backlog it with this finding attached, so the next person to move a field between steps finds the reason rather than rediscovering it.

Still the right call, but the stated reason was wrong. “The field-derived version is correct once ZIP moves” is false — moving ZIP into an optional section made zip_code.present? mean less, not more, because an address can now arrive from the hub’s missing-ZIP banner with nothing else done. What makes deferring furthest_step right is its cost and blast radius, not the ladder being fine. §3a is the minimum repair; the durable fix is still backlogged.

3a. What the Ladder Repair Actually Is

One method changed. The top rung asks for a ZIP and evidence of progress past step 1:

if zip_code.present? && started_the_funnel?   # interests OR selected_role OR result_role
  5

Choosing that evidence took three attempts, and the two rejected ones are the useful part of this record:

Evidence required alongside ZIP Rows changed Verdict
The v2 “Where you are now” fields (industry/company/job title) 136 Wrong. v1’s five-step wizard ended at ZIP and never collected employment data, so this demotes 131 signups that genuinely finished.
The rung immediately below (interests) 18 Wrong. Still demotes 12 v1 rows that chose a role and skipped the interests step, which v1 evidently allowed.
Any progress at all (interests, role, or quiz result) 5 Correct. Exactly the rows holding a step-1 ZIP and nothing whatsoever besides.

The lesson generalizes past this phase: when tightening a derived field, measure the blast radius against real data before accepting the rule. Each of the first two looked principled in the abstract, and each would have quietly restated “Completed” for people who had completed — a reporting regression dressed as a bug fix. The row count is what distinguished them.

Scope kept deliberately narrow: the enum, scope :completed, status_label, Alumni’s prospect logic and every Signup Admin filter are untouched. Only the derivation moved, so the only thing that changes is which rows land where.

4. The Prefill Handoff

Outcome 4 of 28.2 §3 — a verified Google identity matching no record — lands here.

Field State
Email Prefilled from the verified token, locked
First name Prefilled from the token, editable
Last name Prefilled from the token, editable
Graduation year Empty, focused

“Locked” means the field cannot be edited on this form. It does not mean any stored value gets overwritten — that distinction matters on the BUID path, which never reaches this form precisely because it matched.

The reasoning for locking: the address was verified moments ago, and letting someone retype it is letting them type their way into a duplicate — the thing 28.1 exists to prevent, and the one path that could still reach it.

Carry the pending Google identity in the session, not in the URL or a hidden field, and attach google_uid when the record is created. Same reasoning as 27.7’s return_to: it never travels through a URL, so it cannot be aimed at anyone, and the browser that authenticated is provably the browser completing the form.

Session key must be cleared on completion and on abandonment. A stale pending identity surviving into a later signup would attach one person’s Google account to another person’s record — on a shared computer, the realistic version of that is a family member.

5. What Does Not Change

6. Tests

Area Asserts
Step 1 Renders exactly the four identity fields; ZIP, phone and SMS consent are absent (assert_select ... false)
Step 2 ZIP, phone and SMS consent present and saving
Status A record with email + grad year and no ZIP is started, not zip_code
Status Completing both steps still reaches zip_code
Backfill The 6 mis-statused rows recompute to their true funnel position
Prefill Email locked and not editable; a POST attempting to change it does not
Prefill google_uid attaches to the created record
Prefill Pending identity cleared on completion and on abandonment
Prefill A second person on the same browser cannot inherit a stale pending identity
District assign_district_from_zip still fires when ZIP arrives at step 2

Sabotage check: revert the form change and confirm the status tests fail. If they pass with ZIP still in step 1, they are asserting the old behavior.

7. Watch For

assign_district_from_zip (champion_signup.rb:681) runs on ZIP assignment and clears street/city/state when relocating?. Moving ZIP one step later moves district assignment later too, so a signup abandoned after step 1 now has no district where it previously would have had one.

That is correct — an abandoned signup genuinely has no location — but Signup Admin’s district reporting will see slightly fewer districted rows among incomplete signups. Worth knowing before someone reports it as a regression.


8. What Was Implemented

Piece Where
Step 1 shortened _who_you_are.html.erb — email, first, last, graduation year
Fields moved _where_you_are.html.erb — ZIP, phone, SMS consent, maiden name, above the employment questions
Permit lists Signup::SignupsController#who_you_are_params / #where_you_are_params
Locked prefill #pending_google_email (cosmetic) + #apply_pending_google (enforcing, shipped in 28.2)
Ladder repair ChampionSignup#calculate_status, #started_the_funnel?, #interests_answered?
Backfill bin/rails champion_signups:recalculate_status (CONFIRM=1 applies)
Hub links show.html.erbedit_contact_path → step 2, new edit_identity_path → step 1

Full suite after: 5951 runs, 17210 assertions, 0 failures, 0 errors, 3 skips.

9. Spec Deviations

1. §3’s backfill plan could not work. Corrected in place above — the fix had to be to calculate_status, not to the stored values.

2. TCPA consent-clearing moved from the controller to the model. Signup::SignupsController#finalize_communication_consent held the rule “clear sms_opt_in when the phone is blank” and is called only from the step-1 handler. 28.3 moved the phone field off step 1, so that guarantee would have kept living in the one step that can no longer set it, while the step that can had none. It is now #stamp_opt_in_timestamps on the model — an invariant of the record, holding for every writer including the console and any future import.

Not in the spec, and the kind of thing a field move quietly breaks: nothing fails, the checkbox simply starts being honoured without a number behind it.

3. The hub’s contact links needed re-pointing, and split in two. edit_contact_path was who_you_are — correct while ZIP lived there. It now points at where_you_are, so the “Add your location” banner lands on the form that actually has a ZIP field. The “Your details” card shows email and location, which are now on different steps, so email gained its own small “Change” link (edit_identity_path) rather than sending someone to a form without the field they clicked next to.

4. A %> inside a Ruby comment inside an ERB code block 500s the page. Self-inflicted while writing a comment warning about ERB comment tags: Erubi closes a <% block at the first closing delimiter regardless of Ruby comment syntax. test/views/erb_comment_integrity_test.rb gained a second test that compiles every view and asserts no SyntaxError — a strictly stronger check than the existing comment-shape scan, which could not see this because the failure is a 500 rather than a silent leak. Sabotage-verified.

5. The Google hand-off had no exit. Reported the same day, after ship.

Sign in with Google, land on the prefilled form, realise it is the wrong account — and there is no way back. The email is locked, and the landing page’s “Get Started” returns to the same prefilled form, because the identity rides in the session rather than the URL. §4 specified clearing on completion and on abandonment and never defined how someone abandons.

Two additions, both reusing the existing reset action, which already clears the pending identity:

The reported suggestion was to make “Get Started” reset. That silently loses the Google link for someone who came back to the landing to read about the program and then continued — the common direction, not the rare one. Naming the state costs one banner and has no silent-loss case, so the state became visible rather than the button becoming destructive.

6. One test’s premise was deleted rather than updated. duplicate_nudge_test.rb’s “a submission missing zip nudges nobody” exercised a controller-side requirement failing before the uniqueness validation ran. With #enforce_who_you_are_requirements now empty, that path has no members; the case is replaced by a missing-name one and the reasoning recorded in the test.