Spec: ../phases/phase-23/README.md Created: July 29, 2026 Update this file as each migration is written — not after.
| Sub-phase | Migration | Risk | Notes |
|---|---|---|---|
| 23.1 | none | — | Test + shared-constant extraction |
| 23.2 | none | — | Controller/view moves only |
| 23.3 | none | 🟡 Medium | No migration, but adds a validation to existing data — see below |
| 23.4 | none | — | Route + directory moves only. Adds 4 permanent 301s — see §5 |
RenameCpTablesToAn |
— | 🚫 CUT. See phase-23 README §8 |
23.5 carried the phase’s only schema change, and it was cut. Nothing in Phase 23 touches the database — every sub-phase is a code-and-routes deploy, revertible by reverting the deploy.
(Preserved for anyone reviving 23.5: rename_table is mechanically reversible, but the
polymorphic UPDATE statements are not tracked by Rails. An explicit down must reverse both,
or a rollback leaves the DB in a mixed state — and take a production snapshot first.)
None. 23.5 was the only sub-phase that would have needed one, and it was cut.
None. This phase introduces no external links.
None. No staff content is required for this phase to be useful.
No user-facing change. QA is on the test and the extracted constant.
bin/test includes architecture_test.rbapp/controllers/champions/ makes the suite fail — verify by actually trying it, then delete the fileCp::Champion’s inclusion validationAlum-facing URLs did not change — only module and helper names. /sign-up, /sign-up/:id, /profile/:code, /opportunities/:slug, /privacy all resolve as before, so outreach links in alumni inboxes are unaffected.
Staff URLs did change: /champion_signups → /signup_admin, /champion_signups/all → /signup_admin/signups/all.
alumnichampions.comChampionSignupEvent rows still recordedStaffNotification#url persists a route path to the database, so notification rows written before this deploy point at /champion_signups/.... Eight 301 redirects cover them. This is the thing most likely to be reported as a bug after deploy, and it needs a click-through on real production rows — not just fixtures.
/notifications and click a notification created before the deploy — it must land on the signup detail page, not a 404/champion_signups/opportunity_responses/:id → /signup_admin/opportunity_responses/:idrequest.path against hardcoded strings, which the rename broke silently — no test failure, just nav that stops highlighting/champion_signups land on the dashboardThey are permanent. The database rows they serve are already written. The one intentional oddity is as: :champion_signups, kept so a held-surface view keeps resolving without a held-surface write; it retires with 23.4.
Spec: ../phases/phase-23/23.3-shared-layer.md
industry validation is applied to existing datachampion_signups.industry has been unvalidated since Phase 19. 23.3 adds inclusion: { in: Industry::ALL }. If any existing row holds an off-list value, that record becomes unsavable on its next update — which breaks the returning-visitor flow for exactly the alumni whose data is oldest, and does so only when they come back, not when we deploy.
Run this before the deploy, on production:
ChampionSignup.where.not(industry: [nil, ""]).where.not(industry: Industry::ALL).pluck(:id, :industry)
[], or a deliberate decision is recorded here (extend Industry::ALL, or normalize via rake task)nil industry still save (validation is allow_blank)The seals moved out of a held tree. A missed render site is a 500, not a silent miss.
alumni#show — lean icon and seal_role_card alone)The numbers behind 23.3 §6.3 came from dev data and probably understate production. Re-run there before treating the fix as sized:
high = Cp::ProfileChange::HIGH_PRIORITY_FIELDS
actual = Cp::ProfileChange.distinct.pluck(:field_name)
puts "declared but never logged: #{(high - actual).inspect}" # dev: ["pref_name", "maiden_name"]
puts "logged but not high-priority: #{(actual - high).inspect}"
Cp::ProfileChange.where(field_name: %w[pref_first_name college_last_name legal_first_name]).count # dev: 35
Cp::ProfileChange.for_field("pref_name") returns rows — previously always zerofield_name values unless a backfill was explicitly decided; the map is forward-looking, and a silent rewrite of history would corrupt the CRM reconciliation record/roadmap resolves on alumnilookup, non-production only; renders every phase/roadmap on belmontalum is gone (no redirect — never reachable in production)/phase-wrap dry run succeeds against the new controller pathgrep -rn "champion-portal" returns only intentional matches — a missed path means an agent silently reading a stale docNo migration, no schema change, no Devise change. The portal is on hold, so the member-facing side is smoke-level — but the staff admin is what moved, and two of its defects are silent.
StaffNotification#url rowsExactly the 23.2 hazard, on a surface with higher notification volume.
Cp::NotifyAdminsJob writes /champions/champions/:id and /champions/verifications/:id into the
database on every member signup; Cp::SupportThreadsController and Cp::FeedbackController
write their own rows; two portal mailers put /champions/... links in alumni inboxes.
/champions → 301 → /alumni_network/champions/champions/:id → 301 → /alumni_network/members/:id (the doubled segment — this one is rewritten, not prefix-swapped)/champions/verifications/:id → 301 → /alumni_network/verifications/:id/champions/feedbacks/:id, /champions/content_submissions/:id, /champions/support_threads/:id → 301 (covered by the wildcard)/champions/communities?needs_leader=true → 301 with the query string intactDo not “clean up” these redirects later. They are permanent — the rows are already written.
The admin sidebar compares request.path against 25 hard-coded literals. If any still says
/champions/..., the surface works perfectly and simply never highlights the current page.
ActionItemsService): every Alumni Network item links into /alumni_network/..., and the two filtered ones keep their query string/alumni_network/* staff routes resolve/alumni_network/members list + detail; verification tools; impersonationalumni_network_admin shell; member portal in the alumni_network shell — both layouts were renamed<select> on the portal profile wizard and the communities filter still populate (Cp::Champion::INDUSTRIES was deleted in favor of Industry::ALL)Every sub-phase deploys independently. Each is green on its own. With 23.5 cut there is no big-bang left in the phase, and no migration.
One hard ordering constraint, already satisfied:
23.3 before 23.4. 23.3 moved the roadmap controller out of champions/. Shipping 23.4 first
would have relocated the phase-status source of truth to alumni_network/ — a second wrong home —
and forced /phase-wrap plus CLAUDE.md rules 5 and 7 into the same commit or wrapping breaks
silently. Shipped in the right order.
(The other constraint was 23.1 before 23.5 — Cp::Champion::INDUSTRIES had to leave the frozen
model before the class rename, or the alum-facing signup flow would go down. 23.5 is cut, and 23.4
deleted the duplicate outright, so both halves are moot.)
# 23.2 — new routes resolve
bin/rails routes | grep -E "signup_admin|signup/" | head -30
# 23.2 — legacy staff paths still redirect (protects persisted notification URLs)
curl -sI https://<host>/champion_signups | head -3 # expect 301 -> /signup_admin
curl -sI https://<host>/champion_signups/1 | head -3 # expect 301 -> /signup_admin/signups/1
curl -sI https://<host>/champion_signups/opportunities | head -3 # expect 301 -> /signup_admin/opportunities
# 23.2 — no persisted notification URL is left unreachable
echo 'StaffNotification.where("url LIKE ?", "/champion_signups%").count' | bin/rails console
# 23.3 — legacy industry values (run BEFORE deploying the validation)
echo 'ChampionSignup.where.not(industry: [nil, ""]).where.not(industry: Industry::ALL).pluck(:id, :industry)' | bin/rails console
# 23.3 — roadmap moved off the held surface
bin/rails routes | grep roadmap # expect root-level /roadmap, no cp_roadmap
ls app/controllers/champions/roadmap_controller.rb # expect: No such file
grep -rn "champion-portal" .claude/ CLAUDE.md .github/ docs/ | grep -v "alumni-network" # expect none
# 23.4 — new routes resolve
bin/rails routes | grep "alumni_network" | head -20
# 23.4 — the only route helpers left containing "champion" are the keep-list:
# cp_champion_* (Devise), cp_champions_landing, alumnichampions_root
bin/rails routes | awk '{print $1}' | grep -i champion | sort -u
# 23.4 — legacy staff paths still redirect (protects persisted notification URLs)
curl -sI https://<host>/champions | head -3 # expect 301 -> /alumni_network
curl -sI https://<host>/champions/champions/1 | head -3 # expect 301 -> /alumni_network/members/1
curl -sI https://<host>/champions/verifications/1 | head -3 # expect 301 -> /alumni_network/verifications/1
curl -sI "https://<host>/champions/communities?needs_leader=true" | head -3 # query string preserved
# 23.4 — how many persisted rows the redirects are actually carrying
echo 'StaffNotification.where("url LIKE ?", "/champions%").count' | bin/rails console
# 23.4 — one industry list repo-wide
echo 'Cp::Champion.const_defined?(:INDUSTRIES, false)' | bin/rails console # expect false
# 23.4 — no sidebar nav literal left on the old path (the silent defect)
grep -c "'/champions" app/views/alumni_network/_sidebar.html.erb # expect 0
| Sub-phase | Rollback |
|---|---|
| 23.1 / 23.2 / 23.6 | Revert the deploy. No schema change, so no data risk. |
| 23.4 | Revert the deploy — no schema change, so no data risk. But the revert is not clean for anyone who already followed a redirect. See below. |
301 is cached by the browser, so reverting 23.4 is not symmetrical301 Moved Permanently is cacheable by default and browsers cache it aggressively and
indefinitely. Once a staff member’s browser has followed /champions/verifications →
/alumni_network/verifications, it will keep going straight to the new path without
asking the server again. Revert the deploy and that path no longer exists, so they get
a 404 from a URL they never typed — and re-deploying the old code does not fix it,
because the browser is not consulting the server at all.
This is the correct trade for the reason the redirects exist (persisted database rows and links already sitting in alumni inboxes really are permanent, and 23.2 set the precedent with eight of them). It just means the rollback story is:
Cmd+Shift+R) or clearing the site’s
cached redirects fixes it per browser. There is nothing to run on the server.Do not “solve” this by downgrading to 302. A temporary redirect for rows that are
permanently written is a lie the cache would have to be told again on every request,
forever.
| 23.3 | Revert the deploy. No schema change. But if the industry validation blocked saves before you noticed, no data was corrupted — writes were rejected, not mangled. Fix forward by extending Industry::ALL rather than reverting |
| 23.5 | 🚫 Cut. No migration ships in this phase. |
No sessions are invalidated by Phase 23. That warning existed for 23.5’s Devise scope rename
(devise_for :cp_champions → :an_members), which is not happening. 23.4 did not touch Devise.
industry values on production before 23.3 deploysas: :champion_signups from routes.rbsignup_admin_root_pathCp::Champion::INDUSTRIES into Industry::ALLStaffNotification rows the 23.4 redirects are carrying/alumni_network% at the time of the deploy, which is expected — every existing row predates the rename. Without the redirects, 87% of the staging staff notification inbox would 404 on click. This is the evidence for keeping them permanently; do not remove them in a later cleanupengagement_stats Cp:: services (allowlisted in 23.1; real CLAUDE.md violation). Held approval for 23.4 did not cover them