alumni_lookup

Phase 23 — Namespace & Naming Unwind

Status:Complete — 23.1 ✅, 23.2 ✅ (July 29, 2026); 23.3 ✅, 23.4 ✅, 23.6 ✅ (July 30, 2026). 23.5 🚫 CUT. Effort Class: Large — reduced. Both the lookup/ namespace and the Cp::An:: model rename were dropped after measuring value against churn. Prerequisites: Phase 22 Complete, Phase 25 Complete Branch convention: One branch per sub-phase. All remaining sub-phases ship independently; the one big-bang (23.5) was cut.


0. Start Here — Picking This Phase Up Cold

Done and on main:

   
23.1 test/architecture_test.rb — surface-boundary ratchet, 7 tests, baseline dated July 29 2026. Plus shared Industry::ALL + a drift test against Cp::Champion::INDUSTRIES
23.2 public/signup/, champion_signups/signup_admin/. Alum-facing URLs unchanged. Staff URLs changed, with 8 permanent 301s protecting persisted StaffNotification#url rows
23.3 Shared layer + dev-process extraction. Seals/spectrum → app/views/shared/champion_interest/; Affinity and Industry own their vocabularies; IdentityField map + read-side translation fixing the pref_name/maiden_name defect; roadmap controller out of champions/ (CLAUDE.md rule 5 deleted, not ported); docs split. Held baselines lowered, cross-surface render assertion added with an empty baseline
23.4 champions/alumni_network/. 185 files. Held approval granted. Also pulled in three items 23.5 was carrying: resources :members, both “champion” layout renames, and the Cp::Champion::INDUSTRIES collapse. Permanent wildcard 301s protect persisted StaffNotification#url rows

23.6 shipped alongside 23.4 — CLAUDE.md rules 3–3b inverted, the Canonical Surface Inventory promoted, Cp:: documented as permanent, BACKLOG 2.1 reconciled against Decision 1, the 23.4 findings routed into the debug and patterns skills, a Phase 23 roadmap entry added, and the doc-link policy applied (115 broken links → 34, with the residual categorised in §9).

Carried out of 23.3/23.4, do not lose:

Before writing code, read:

Two things that are easy to get wrong:

Roadmap controller status: added in 23.6. Since 23.3 moved the file to app/controllers/roadmap_controller.rb — root level, no longer a held path — the entry is an ordinary write.


1. What This Phase Is

This repo is effectively five apps in one codebase: Alumni Lookup (+ settings/tools), Champion Signup, Champion Signup Admin, Alumni Network, and Alumni Network Admin. After roughly 18 months of continuous growth and scope creep, none of the namespaces reliably say which app they belong to:

The actual problem being solved: addressability

The goal is not aesthetic. It is being able to say “in the lookup” or “in the champion signup admin” and have an unambiguous answer about what is in scope — for humans and for agents.

Documentation alone has already been tried and failed. CLAUDE.md documents the /champions/ vs /champion_signups/ boundary in a table, in bold, and as a numbered rule calling it “the naming trap.” It was violated often enough that a hard gate had to be added putting two of the five apps on hold.

The mechanism matters for choosing a fix: an agent deciding where a file belongs is usually reasoning over grep output, and grep -rn "champion" returns both directories with nothing indicating that one is a frozen portal and the other is the live product. The disambiguating fact is not present in the evidence being reasoned over. Structure encoded in a path is present at every decision point; documentation is present only if it was loaded and attended to. That is not a gap a better model closes.

Corollary that shapes this phase: prefer enforcement over description, and structure over both. Hence 23.1.

Target end state

Surface Audience Namespace Path
Alumni Lookup Staff none — defined by exclusion unchanged
Signup — alum-facing Alumni (public + signed-in) Signup:: /signup/*
Signup — staff Signup Admin staff SignupAdmin:: /signup_admin/*
Alumni Network — member-facing All alumni An::Cp:: retained unchanged (belmontalum.com)
Alumni Network — staff Staff AlumniNetwork:: /alumni_network/*

Namespacing the four exceptions makes the fifth unambiguous for free: Alumni Lookup is everything not in signup/, signup_admin/, cp/, or alumni_network/. See Decision 6.

One row of that table did not happen. 23.5 was cut (§8), so the member-facing portal keeps Cp::. The addressability goal survives it: Cp:: is a fossil but it is unambiguous — nothing else in the repo looks like it, so nothing has ever been misfiled into it. The four-directory rule above still holds; one of the four is just spelled cp/ instead of an/.


2. Decisions Made During Planning (July 29, 2026)

Decision 1 — /admin/ is rejected

The original draft proposed /champions//admin/. Rejected: a generic /admin/ reintroduces exactly the ambiguity this phase exists to remove, because two surfaces have staff admins. The admin path must name the product it administers → /alumni_network/.

Decision 2 — the model rename is back in scope — ⚠️ SUPERSEDED July 30, 2026

The July 28 revision deferred Cp::ChampionCp::Member. That deferral was reversed here, then reinstated permanently when 23.5 was cut (§8). The model stays Cp::Champion.

The naming argument was never wrong — the primary object of the Alumni Network is a member, and “Champion” is the signup ecosystem’s celebratory language. 23.4 acted on it where it was cheap and visible: the URL is /alumni_network/members and the controller is AlumniNetwork::MembersController. What was rejected is paying a 389-file migration to carry that naming into a frozen model’s class name.

Decision 3 — cp becomes an, not net or ntwk

net was considered and ruled out on a technical basis: Ruby’s stdlib Net::HTTP is used in photo_from_url_service.rb:35 and import_champion_signups.rake:274. Declaring an app namespace Net gives Zeitwerk ownership of the Net constant and makes Net::HTTP resolution inside app code ambiguous — a failure mode that is slow and confusing to diagnose.

ntwk was ruled out as unpronounceable in code review and conversation.

An:: / an_* was confirmed, then never used — 23.5 was cut (§8). Recorded because the Net::HTTP finding above is real and would bite anyone revisiting this.

Decision 4 — FK columns and tables ARE renamed

The original spec left cp_champion_id in place, reasoning “too much churn for zero behavior change.” That rationale assumed a live product needing a zero-downtime column migration. It does not apply here — the Alumni Network has effectively no traffic, so the add-column/dual-write/backfill/swap sequence is unnecessary and a single rename_column migration is safe.

The genuine hazard is not the columns. It is the polymorphic type strings: author_type, sender_type, subscribable_type and similar columns store the literal string "Cp::Champion". Without data UPDATEs, every polymorphic association silently returns nothing. That work is mandatory for the module rename regardless of whether FK columns are touched — which makes the columns nearly free once the migration exists.

Decision 5 — signup splits by audience, not auth state

public/ becomes signup/ (all alum-facing pages, authenticated or not) and champion_signups/ becomes signup_admin/. Audience — not auth state — is what actually determines layout, guards, and copy rules.

Decision 6 — Alumni Lookup gets NO namespace; it is defined by exclusion

A lookup/ namespace (Lookup::, /lookup/*) was planned, then dropped after measuring it.

   
Cost ~927 route-helper references across 155 files (settings_* alone is 536), plus permanent verbosity (alumni_pathlookup_alumni_path)
Risk The only sub-phase on an active surface — the one staff use daily, so the only one that can break production work
Misroutes prevented Zero. Nothing competes with root-level, so nothing has ever been misfiled into it

Highest risk, lowest incident value. And it is unnecessary: once the other four surfaces are namespaced, “in the lookup” has a precise definition by subtraction. That gets the addressability goal for one line of CLAUDE.md instead of the largest active-surface refactor in the phase.

Replacement (lands in 23.6): a CLAUDE.md rule stating that root-level controllers/views/services are Alumni Lookup by definition, and that every other surface is namespaced — so an unnamespaced file is a Lookup file, and anything else must declare itself.

Recorded in BACKLOG as revisitable. It becomes worth reconsidering only if the monolith split (§12) is revived, since that would need explicit boundaries everywhere.


3. Measured Scale

Taken from the working tree on July 29, 2026. Re-measure before starting each sub-phase.

Sub-phase Refs Files Surface
23.1 Boundary enforcement + decoupling ~6 → 8 actual ✅ Active / test
23.2 signup/ + signup_admin/ ~15 est. → ~436 actual ~15 est. → 79 actual ✅ Active
23.3 Shared layer + dev-process extraction ~40 ✅ Mostly active; 2 narrow held writes
23.4 champions/alumni_network/ 1,367 est. → 1,401 actual 147 est. → 185 actual 🛑 Held (approved)
23.5 Cp::An:: + Champion → Member 4,413 refs, 56 tables, 109 schema lines 389 🚫 CUT — §8
23.6 Docs & skills reconciliation ~20 Docs

Ranked by misroutes prevented per unit of churn

This ordering is why the phase is scoped the way it is:

Sub-phase Churn Prevents misrouting?
23.1 enforcement ~6 files Yes — and it is the only item that enforces rather than describes.
23.2 signup split ~15 files Yes. Best ratio of the renames.
23.3 shared layer ~40 files Yes, in the other direction — it stops the walls causing misroutes. Without a shared home, the correct fix (extract) and the wrong one (duplicate) look identical, and §4.3 of its spec shows the duplicate already happened.
23.4 alumni_network/ 147 files Yes — this is the collision the hold gate exists for.
23.5 An:: + Member 389 files No — and this row is why it was cut. Cp:: is a fossil but unambiguous: nothing else looks like it, so nothing has been misfiled into it. Onboarding and consistency cost, not a safety cost. The counter-argument was only about timing (“on-hold is the only zero-conflict window”), which does not outweigh the largest file count, the only migration, and the only irreversible step in the phase. See §8.
lookup/ 155 files No — dropped, Decision 6

4. Sub-Phase Order and Why

Enforcement first, then the shared layer, then active surfaces, then held surfaces. Four reasons this ordering is deliberate rather than arbitrary:

  1. 23.1 is first because it is cheap, independent, and protects everything after it. It also catches the failure mode directly, which no rename does on its own — a rename removes today’s collision; a test prevents tomorrow’s.
  2. 23.1 and 23.2 cannot be blocked on the held-surface approval gate, so they deliver value while that decision sits.
  3. 23.3 must precede 23.4 — it extracts the roadmap controller out of champions/, which removes 23.4’s most dangerous coupling entirely rather than managing it. See 23.3 §8.2.
  4. 23.5 combines the module rename and the model rename on purpose. Moot — 23.5 was cut (§8). The reasoning was sound and is preserved there in case it is ever revived.
23.1  Boundary enforcement + decoupling    (active/test; protects 23.2-23.5)   ✅ Complete
23.2  signup/ + signup_admin/              (active, independent, ships alone)  ✅ Complete
23.3  Shared layer + dev-process extract   (mostly active; unblocks 23.4)  ✅ Complete
23.4  champions/ -> alumni_network/        (held; routes + dirs + labels)     ✅ Complete
23.5  Cp:: -> An:: + Champion -> Member    (held; big-bang, migration)        🚫 CUT
23.6  Docs, CLAUDE.md, skills, copilot     (reconciles all of the above)

23.1 is a hard prerequisite for 23.5, not merely a nice-to-have — see §5.2.

23.3 was inserted after 23.2 shipped

The phase as originally scoped treated every file as belonging to exactly one app. That is false for the core domain objectsAlumni, Education, Affinity, College, District and the rest are shared by all five, and an app is a view onto them rather than an owner of them.

Two concrete costs of the omission, both found in the tree rather than argued in the abstract: the champion interest seals live inside the frozen champions/ tree while being rendered by three other apps, so editing a deliberate cross-app design choice is currently a held-surface write; and industry — shared across three tables in two apps — is validated in one app and not in the other.

23.3 adds the shared layer so the walls have doors, and moves the development-process artifacts (roadmap page, phase docs) out of the app surfaces they were filed into. Spec: 23.3-shared-layer.md. The former 23.3/23.4/23.5 were renumbered to 23.4/23.5/23.6.

⚠️ Held-surface approval gate

23.4 wrote to app/controllers/champions/**, app/views/champions/**, layouts/champion_admin, and (for the three items pulled out of 23.5) a handful of app/**/cp/** files — all ON HOLD per CLAUDE.md → Default Work Surface. Explicit approval was granted July 30, 2026 and is recorded in 23.4-alumni-network.md §3. 23.1, 23.2, and 23.6 need no such approval.

The hold is not lifted. Approval was for this sub-phase’s named scope, not standing permission. app/controllers/alumni_network/** and app/**/cp/** remain held; the architecture test still enforces them.

23.3 needs a much narrower approval — two moves out of held trees (the seals, the roadmap controller) plus their render-site updates. No portal behavior changes. See 23.3 §11; the majority of 23.3 can proceed without any approval.

⚠️ The phase-status source of truth moves in 23.3, not 23.4

app/controllers/champions/roadmap_controller.rb is the documented source of truth for phase status (CLAUDE.md rules 5 and 7), and /phase-wrap depends on its path.

This was originally 23.4’s job, and reassigning it to 23.3 is a real risk reduction, not a reshuffle. The file is repo-wide development tooling that happens to sit in a frozen app — which is the only reason CLAUDE.md needed a hand-carved exception (rule 5) to let /phase-wrap work. 23.4 would have moved it to alumni_network/, which is no more correct. Extracting it to root level in 23.3 instead:

CLAUDE.md rules 5 + 7, the /phase-wrap skill, and .github/copilot-instructions.md must still be updated in the same commit as the move — now in 23.3 — or the wrap process breaks silently on the next phase.

Note that CLAUDE.md’s narrow pre-approved exception covers phase-status edits to that file, not relocating it. The move needs approval.


5. Sub-Phase 23.1 — Surface Boundary Enforcement + Decoupling

Surface: ✅ Active + test only. No approval gate. Do this first regardless of what happens to 23.2–23.5.

Renaming removes today’s ambiguity. A test prevents tomorrow’s drift. Only this sub-phase does the second thing, and it is the cheapest item in the phase.

5.1 — test/architecture_test.rb

Assert surface boundaries so a violation fails bin/test instead of relying on an agent or reviewer noticing.

This must be a ratchet, not a clean assertion. The tree does not pass a strict version today (§5.3). Commit the current violations as a named, dated allowlist, assert the count never grows, and shrink it deliberately. A test that fails on arrival gets disabled within a week and buys nothing.

✅ Implementedtest/architecture_test.rb, 7 tests / 107 assertions, baseline dated July 29, 2026.

# Assertion As built
1 Held trees gain no new files Per-directory file counts for all 11 held trees, plus a full 21-name allowlist for app/controllers/champions/ — the specific collision the hold gate exists for, so the failure message can name the new file
2 No new cross-surface Cp:: reference Per-file allowlist; a file not on it may not reference Cp:: at all
3 Existing Cp:: couplings do not deepen Per-file reference counts, asserted <= so removals always pass
4 Held code does not reach further into the signup ecosystem Same treatment for ChampionSignup refs inside held trees (2 files, both legitimate)
5 Controllers do not claim another surface’s layout Reframed — see below

Assertion 5 was reframed during implementation. “Every controller declares a layout” is not assertable: layouts are inherited from base controllers, and most controllers correctly declare nothing. The testable form of the same concern is the inverse — no controller may declare a layout belonging to a surface it does not live in. That catches the actual failure mode (a feature built inside the wrong app’s shell) without punishing correct inheritance. Dynamic declarations (layout :determine_layout in settings/users_controller.rb) are ignored as resolved-at-request-time, not a static surface claim.

Comments are excluded from reference counts, and this mattered more than expected: it cut the apparent Cp:: coupling inventory from 34 files to 17. Half of what a raw grep -rn "Cp::" reports is documentation about the coupling rather than the coupling itself. Worth knowing before anyone estimates the monolith split (§12) off a grep count.

Two surface roots needed an explicit exception. champion_signups_controller.rb (Signup Admin dashboard) and public_controller.rb (alum-facing signup base) sit at app/controllers/ root, so path alone cannot distinguish them from Lookup controllers — the ambiguity 23.2 exists to remove. They are listed in MISPLACED_SURFACE_ROOTS with a note to delete that constant in 23.2, once the directory carries the information instead.

5.2 — Extract Cp::Champion::INDUSTRIES — a hard 23.5 prerequisite

The live public signup flow imports a constant from the frozen portal model.

Location Reference
public/champion_signups_controller.rb:431 @industries = Cp::Champion::INDUSTRIES
public/champion_signups/steps/_where_you_are.html.erb:4 documents the same dependency
Defined at app/models/cp/champion.rb:258, and used by its own validates :industry at line 300

23.5 renames Cp::ChampionAn::Member. Without this extraction first, 23.5 breaks the live alum-facing signup flow — a production outage on an active surface caused by refactoring a frozen one.

Extract to an identity-agnostic shared location per CLAUDE.md’s shared-models rule (unnamespaced app/models/), e.g. Industry::ALL. Both Cp::Champion’s validation and the signup controller then read from the shared constant. This is a small change that removes a cross-app coupling permanently.

✅ Implemented, with one half deferred to 23.5. app/models/industry.rb now holds Industry::ALL, and public/champion_signups_controller.rb reads it. Industry is a plain class, not an ActiveRecord model — there is no industries table; the values persist as strings on three separate columns.

Spec deviation: the plan called for Cp::Champion’s validation to read the shared constant too. That is a write to app/**/cp/** — a held path — so it was not done. Cp::Champion::INDUSTRIES still holds its own copy of the list, which means the two can drift.

Rather than leave that to vigilance, test/models/industry_test.rb asserts Industry::ALL == Cp::Champion::INDUSTRIES and that every shared value passes the member model’s inclusion validation. Reading held code is permitted; the test fails loudly if the lists diverge.

23.5 must collapse them: point An::Member::INDUSTRIES at Industry::ALL or delete it outright, then delete the drift test rather than updating it. The active-surface half — the part that would have caused the outage — is complete, so 23.5’s prerequisite is satisfied.

Also audit before 23.5: Cp::Champion::CHAMPION_ROLES and Cp::Champion::COMMUNITY_TRIGGER_ATTRIBUTES are referenced from lib/tasks/role_ideas.rake and tests — outside app/, so easy to miss in an app/-scoped find/replace.

5.3 — Known violations to allowlist (found during planning)

These exist today and are why 23.1 must ratchet:

Location Violation Disposition
engagement_stats/retention_service.rb Wholesale Cp:: analytics in engagement_stats/directly violates CLAUDE.md’s “Never put Cp:: analytics in engagement_stats.” Its own header comment admits it queries Cp:: tables. Allowlist + BACKLOG entry. Relocating it is held-surface work; do not expand 23.1’s scope.
engagement_stats/discussion_boards_service.rb Same violation — 16 Cp:: references across board posts, comments, flags, moderation Allowlist + BACKLOG entry
settings/districts_controller.rb:11,55 Cp::Champion.group(:district_id).count for a member-count column Allowlist — arguably legitimate staff reporting
cp/confirmations_controller.rb:215 Portal reads ChampionSignup.active Allowlist — legitimate cross-surface account linking
public/champion_signups_controller.rb:431 Cp::Champion::INDUSTRIES Fix in §5.2, do not allowlist — it breaks 23.5

That the first two sat in the tree — ruled against explicitly in CLAUDE.md, with a confessional code comment — is the case for this sub-phase in one line.

Acceptance


5A. Canonical Surface Inventory

Refactoring and documenting are complements, not alternatives. This is the documentation half: the authoritative answer to “what is in the lookup?” Established during planning by reading the files, and it corrects several assumptions. Promoted to CLAUDE.md in 23.6.

Updated July 30, 2026 to the post-23.4 tree. Apps 2–5 all moved; the paths below are current, not aspirational.

App 1 — Alumni Lookup (staff) — root-level, no namespace

alumni_controller.rb, alumni_affinities_controller.rb, engagement_stats_controller.rb, statistics_controller.rb, notifications_controller.rb, push_subscriptions_controller.rb, public_controller.rb, settings/ (11 controllers), tools/ (5 controllers), api/, users/ (Devise), plus matching app/views/*.

Corrections to the working assumption list:

App 2 — Champion Signup (alum-facing) — signup/ ✅ shipped 23.2

signup/signups_controller.rb, signup/opportunities_controller.rb, signup/policies_controller.rb, signup/base_controller.rb, app/views/signup/**, layouts/signup. Models: ChampionSignup, ChampionSignupEvent (names retained — Decision 5).

App 3 — Champion Signup Admin (staff) — signup_admin/ ✅ shipped 23.2

signup_admin/signups_controller.rb, signup_admin/ (incl. opportunities/ CMS), app/views/signup_admin/**, layouts/signup_admin.

App 4 — Alumni Network (member-facing) — Cp:: 🛑 Held

app/controllers/cp/, app/views/cp/, app/models/cp/, app/helpers/cp/, app/services/cp/, app/jobs/cp/, app/mailers/cp/, layouts/alumni_network — 389 files, 56 tables.

Keeps Cp:: — 23.5 was cut (§8). Its layout was renamed in 23.4, so layouts/alumni_network.html.erb is the member shell while the controllers are still cp/. That is the one remaining non-identity mapping in the architecture test’s LAYOUT_SURFACES.

App 5 — Alumni Network Admin (staff) — alumni_network/ ✅ shipped 23.4, 🛑 still Held

app/controllers/alumni_network/ (20 controllers), app/views/alumni_network/ (75 files), app/helpers/alumni_network/ (3), layouts/alumni_network_admin, /alumni_network/* routes.

The member resource is /alumni_network/members (AlumniNetwork::MembersController) backed by Cp::Champion — the path says members because the URL is what gets reasoned over; the model kept its name when 23.5 was cut.

The shared layer — belongs to no app, and every app may use it

Unnamespaced app/models/*.rbAlumni, Education, EducationAreaOfStudy, Affinity, College, Major, Degree, Opportunity, ZipCode, StaffNotification, District, Region, CrmDataChange, Employment — plus ApplicationController, concerns/, and app/views/shared/. Industry joined this layer in 23.1 §5.2.

This is a sixth category, not a sixth app, and the original spec under-described it: it listed shared models only. In practice a shared object drags along taxonomies, presenters, calculations, and artwork, and every one of those needs the same identity-agnostic home. An app is a view onto these objects, not an owner of them — so building any one app must be able to reach the whole shared layer, and a calculation added for one app should benefit the rest.

23.3 makes that layer explicit and gives it view/taxonomy/helper tiers. See 23.3-shared-layer.md §2 for the placement rule and §4 for what the omission has already cost.

Development process — belongs to no app either

docs/planning/, the roadmap page, BACKLOG.md, qa/, .claude/skills/. These describe how the repo is developed, not how any app behaves. Two were filed inside app surfaces — one of them frozen — until 23.3 §8 extracted them. The roadmap controller is now root level, which is why 23.4 never touched /phase-wrap.


6. Sub-Phase 23.2 — signup/ + signup_admin/

Surface: ✅ Active. No approval gate.

✅ Implemented. Actual scale: 79 files, not the ~15 estimated at planning — the estimate counted files to move and missed the ~436 route-helper references across views, tests, jobs, and mailers.

From To Audience
app/controllers/public_controller.rb app/controllers/signup/base_controller.rb Alumni
app/controllers/signup/signups_controller.rb app/controllers/signup/signups_controller.rb Alumni
app/controllers/public/opportunities_controller.rb app/controllers/signup/opportunities_controller.rb Alumni
app/controllers/public/policies_controller.rb app/controllers/signup/policies_controller.rb Alumni
app/views/public/champion_signups/ app/views/signup/signups/ Alumni
app/views/layouts/public.html.erb app/views/layouts/signup.html.erb Alumni
app/controllers/champion_signups_controller.rb app/controllers/signup_admin/signups_controller.rb Staff
app/controllers/champion_signups/ app/controllers/signup_admin/ Staff
app/views/champion_signups/ app/views/signup_admin/ (page views under signups/) Staff
app/views/champion_signups/_sidebar.html.erb app/views/signup_admin/_sidebar.html.erb — it belongs to the layout, not the signups controller Staff
layouts/signup_admin.html.erb unchanged — already correctly named Staff

Returning-visitor / signed-in pages moved into signup/ alongside the anonymous ones. The guard stays per-action; the namespace no longer claims everything inside it is public.

Models stay put. ChampionSignup, ChampionSignupEvent, and the champion_signups table are not renamed — “Champion Signup” is the product’s own name for this flow and is correct. Only the controller/view namespaces changed.

Alum-facing URLs are unchanged — deliberately

/sign-up, /sign-up/:id, /profile/:code, /opportunities/:slug, and /privacy all still resolve. Only the module and helper names moved. Mail-merge outreach to v1 signups uses permanent /profile/:code links, and those links are in alumni inboxes already.

Helper renames: public_champion_signup_*signup_*, public_opportunity_*signup_opportunity_*, public_privacy_*signup_privacy_*.

Staff URLs changed, and StaffNotification#url persists them

This was the sub-phase’s one real hazard, and it was not in the plan. /champion_signups/signup_admin, and /champion_signups/all/signup_admin/signups/all. But StaffNotification#url writes a route path into the database — four jobs do it (notify_champion_signup_admins_job.rb, ..._return_job, ..._consent_change_job, notify_opportunity_response_job.rb). Every row written before this rename points at the old path, so the rename alone would have 404’d the entire existing staff notification inbox on click.

Eight 301 redirects in routes.rb cover it, ordered specific-first so champion_signups/:id cannot swallow champion_signups/opportunities as id="opportunities". Covered by test/controllers/signup_admin/legacy_redirects_test.rb, including one end-to-end case that follows the redirect and asserts a staff member reaches the real page.

These redirects are permanent, not transitional — the rows are already written. Do not delete them in a later cleanup.

Two related details:

Acceptance

Spec deviations

  1. app/controllers/public_controller.rbsignup/base_controller.rb was not in the plan’s table but had to move: it declared layout "public" and was the base class for all three signup controllers.
  2. layouts/public.html.erblayouts/signup.html.erb likewise — the layout name carried the same wrong axis as the directory.
  3. Docs updated now rather than in 23.6. CLAUDE.md and copilot-instructions.md name paths that this sub-phase deleted. Stale agent instructions are the exact mechanism this phase exists to fix (§1), so leaving them for 23.6 would have left the misrouting hazard live in the interim. 23.6 still owns the rest.

6A. Sub-Phase 23.3 — The Shared Layer & Dev-Process Extraction

Full spec: 23.3-shared-layer.md (own file — new sub-phase, exceeds ~100 lines).

Surface: ✅ Mostly active. Two narrow held-surface writes, both removals, need approval — far less than 23.4/23.5.

Corrects a false premise in this spec: that every file belongs to exactly one app. It does not, for the shared domain objects, and 23.1’s boundary test had no concept of legitimate cross-surface reference — which made the correct fix (extract to shared) indistinguishable from the wrong one (duplicate).

Part What
23.3a Shared view layer — champion interest seals/icons/spectrum → app/views/shared/champion_interest/
23.3b Shared taxonomies — close the industry validation gap; Affinity owns its category vocabulary
23.3c Shared helper/service convention, plus the honest limits of enforcing it
23.3d Dev-process extraction — roadmap controller out of champions/; docs split

Findings that justify it, all from the tree:

Also settles two process questions: phase docs are not re-sorted per app (a directory that promises a boundary it cannot keep), and include_all_helpers stays on by decision — global helper availability is how a calculation written for one app benefits the rest, which is the requirement, not a leak.


7. Sub-Phase 23.4 — champions/alumni_network/

Full spec: 23.4-alumni-network.md (own file — the sub-phase grew past ~100 lines once the 23.5 pull-ins and the two silent regressions were recorded).

✅ Complete, July 30, 2026. 185 files. No migration, no schema change.

champions_*alumni_network_* came to 1,401 references / 154 files, against the 1,367 / 147 measured at planning. The rename was generated from bin/rails routes rather than grepped, because four helper families contain champion and are not this namespace (23.4 §2).

Three items the spec deferred to 23.5 were folded in, since 23.5 was cut and they had no later home: resources :championsresources :members, both “champion” layout renames, and the Cp::Champion::INDUSTRIES collapse into Industry::ALL.

Two findings worth carrying forward:


8. Sub-Phase 23.5 — Cp::An:: + Champion → Member — 🚫 CUT

Cut July 30, 2026, before any work began. 389 files, 4,413 Cp:: references, 56 tables, 109 FK/index schema lines, plus a migration whose polymorphic-type-string UPDATEs are the riskiest data change in the phase.

Why

The phase’s own value-vs-churn ranking (§3) already put 23.5 last and answered “prevents misroutes? No.” Cp:: is a fossil, but it is an unambiguous fossil — nothing else in the repo looks like it, so nothing has ever been misfiled into it. It was included only because “on hold is the only zero-conflict window that will ever exist,” which is an argument about timing, not value.

Set against that: the largest file count in the phase, the only migration, the only irreversible step, and the only place where a missed polymorphic type string makes associations silently return nothing rather than fail loudly. Paying that for a consistency-and-onboarding benefit, on a frozen surface, did not clear the bar that dropped the lookup/ namespace for the same reason (Decision 6).

What the cut costs, and where each cost landed

Consequence Disposition
Cp::Champion::INDUSTRIES duplicate would have survived indefinitely Fixed in 23.4 — collapsed into Industry::ALL, drift test replaced with a no-copy assertion
/alumni_network/champions would have been permanently doubled-up Fixed in 23.4resources :members
Two layouts both named “champion” would have survived Fixed in 23.4alumni_network and alumni_network_admin
The member portal keeps Cp:: / cp_* / Cp::Champion Accepted. Documented in the target-end-state table (§1) rather than left as an unmet promise
verification_status: champion_verified keeps its name Accepted. Application-level only
Phase 26.4 loses its forcing moment for cp_profile_changes Re-anchored in BACKLOG. 23.5’s migration was cited as the moment that would force the crm_data_changes reconciliation; Phase 26.4 now has to carry it unaided
Cp:: references from active surfaces stop being scheduled outages Lowers urgency, not the rule. The architecture test still ratchets CP_REFERENCES_OUTSIDE_HELD_TREES; its failure message was reworded to say so

Reviving it

Filed to BACKLOG as revisitable, on the same terms as the lookup/ namespace: worth reconsidering if the monolith split (§12) is revived, since that would carry the naming confusion across a service boundary where it becomes far more expensive to fix. The migration inventory in the git history of this file remains the starting point — in particular the instruction to enumerate polymorphic columns with grep -rn "as: :\|polymorphic: true" app/models/cp/*.rb and verify, not trust, the known list.


9. Sub-Phase 23.6 — Docs & Skills Reconciliation

This is the “make the documentation good” half of the refactor-vs-document question. It is not an alternative to 23.1–23.4; it is what makes them navigable.

It also has to record a cut, not just renames: 23.5 was dropped after the target-end-state table in §1 had already promised An::. Documentation that promises an architecture the repo does not have is the exact failure mode §1 blames for this phase existing.

A link sweep across docs/planning/** found 88 broken relative links. Almost all predate this phase (phase-1, phase-6, phase-9, archive/, source/, features/). Two things worth separating, because they need opposite treatment:

Kind Treatment
Historical phase specs (17, 19, 21, 22, 25, 3.9) referencing app/controllers/public/** etc. Leave the prose. They describe what was built at the time, and rewriting them falsifies the record. Optionally de-link the dead paths so they stop looking navigable
Current-state reference docs describing the system as it is Fix. Already done for the three that 23.2 broke: docs/development/ARCHITECTURE.md, docs/features/AUTH_AND_ROLES_SYSTEM.md, docs/operations/DOMAIN_MIGRATION.md

The policy 23.6 settled — and the result

The July 30 sweep was re-run across all of docs/** rather than docs/planning/** alone, which raised the honest count to 115. Applying the two-treatment rule above took it to 34.

Action Count Detail
De-linked, prose kept 68 Historical phase specs and archive/. [text](dead-path.md)`text`. The path is still readable; it no longer looks navigable. Rewriting the prose would falsify the record
Repointed 11 Targets that exist but had moved — DECISIONS.md, DATA-ARCHITECTURE.md, AVOIDING-DUPLICATION.md, MODEL_RELATIONSHIPS.md, AGENTS.md, JOBS-TO-BE-DONE.md, 01-AUTHENTICATION.md, AI_CONTEXT.md
Fixed — Phase 23’s own 2 23.3’s link to the pre-move seals directory, and 23.2’s note pointing at the held view that 23.4 relocated

The residual 34 is deliberate, not overlooked. It is three categories, none of which a link fix addresses:

Category Count Why it stays
App routes, not files 8 /privacy, /community-guidelines, /cookie-policy in the compliance drafts, plus /CLAUDE.md and /AGENTS.md. These resolve at runtime. They are not broken — a checker must be configured to skip them, which is the single most important note for whoever builds one
Canonical source docs never written 19 ALUMNI_CHAMPIONS__ONBOARDING_PHILOSOPHY.md and HOPE_TRANSFORMS__FUNDING_PRIORITIES.md, both promised by source/README.md. A gap in the corpus, not rot — and CLAUDE.md forbids inventing theological language, so an agent cannot fill them. The links are the record of what is owed; removing them would erase the debt
Planned work never started 7 event-checkin-integration/, affinaquest-import/UNIFIED_DATA_SYNC.md, ENV_SETUP_IMPLEMENTATION_CHECKLIST.md, ENGAGEMENT_SCORE_CALCULATOR.md

A link checker in CI is the obvious follow-on and is not in this phase — filed to BACKLOG with both of its required exclusions.

(Note for whoever runs the sweep: qa/PHASE_21_LAUNCH_GUIDE.md contains a javascript:alert(1 string. That is an XSS test payload documented for opportunity-form QA, not a vulnerability and not a broken link — expect any checker to flag it.)


10. Risks

Risk Mitigation
23.5 breaks the live signup flow via Cp::Champion::INDUSTRIES Resolved twice over. 23.1 extracted the shared Industry; 23.4 deleted the portal’s duplicate. 23.5 is cut, so the rename that would have triggered it is not coming
Polymorphic type strings missed → associations silently return nothing Moot — 23.5 cut. Keep the mitigation on file: enumerate with grep before writing any future cp_* migration; assert count == 0 per column post-migrate
A rename driven off the route table misses hard-coded path strings Found in 23.4 (§7): the admin sidebar’s nav literals and ActionItemsService’s bell links both compared request.path to string constants, invisible to a helper rename, and failed silently. Grep string literals separately from helpers, and pin the behavior with a test that asserts on the specific element rather than the page body
23.4 breaks /phase-wrap by moving the roadmap controller Reassigned to 23.3, which removes the risk instead of managing it — the roadmap controller leaves champions/ for root level, so 23.4 never touches the wrap process. Still requires CLAUDE.md rules 5 + 7 and the skill updated in 23.3’s move commit, verified with a wrap dry-run
industry validation added in 23.3 makes existing signup rows unsavable Audit legacy values before adding the validation (23.3 §6.1). The column has been unvalidated since Phase 19, so v1-migrated rows may hold off-list values. This is the only item in 23.3 that can break a live alum-facing flow
Shared layer becomes a junk drawer Consumer header mandatory on every shared file. A shared file that cannot name a second consumer is misfiled, not shared
String literals comparing "champion_verified" survive find/replace on symbols Grep the bare string separately from :champion_verified
Cp::Champion constants referenced from lib/tasks/ and test/ Scope find/replace to lib/ and test/ as well as app/role_ideas.rake:89 would otherwise be missed
Architecture test fails on arrival, gets disabled Ratchet from a committed baseline allowlist (23.1 §5.3), never a clean assertion
Devise scope rename invalidates existing sessions Expected and acceptable — portal is on hold; note in launch guide
Sub-phase 23.5 too large to review Single-purpose commits within the branch: migration, models, Devise, views, tests

11. Explicitly Out of Scope

Item Rationale
lookup/ namespace for Alumni Lookup Dropped — Decision 6. ~927 refs / 155 files on the only active surface, preventing zero known misroutes. Replaced by definition-by-exclusion plus a CLAUDE.md rule. Filed to BACKLOG as revisitable if the monolith split (§12) is revived.
people_controlleralumni_import rename Worthwhile but was carried by the dropped lookup/ sub-phase; BACKLOG
Relocating the two engagement_stats Cp:: services Real CLAUDE.md violation, but fixing it is held-surface work and would expand 23.1; allowlisted + BACKLOG
AlumnusEngagementDecoratorAlumniEngagementDecorator Pre-existing BACKLOG item (deferred from 18.4). Would have ridden along with lookup/; now unaccompanied, so it stays in BACKLOG.
Cp::An:: and Cp::ChampionMember Cut July 30, 2026 — §8. 389 files, 56 tables, the phase’s only migration, preventing zero misroutes. BACKLOG, revisitable with the monolith split
ChampionSignup model / champion_signups table rename “Champion Signup” is the product’s correct name
CHAMPION_ROLES constant Feature name, not a model name
Merging statistics into engagement_stats Different domains — and per §5A they are not duplicative
Devise users scope rename (Lookup staff) Unrelated to this phase’s naming problem
New features of any kind Pure refactor

12. Note on the Monolith Split

Extracting the five apps into separate services with an API was previously proposed and rejected. It may be worth reapproaching — but after this phase, not instead of it.

Clean namespaces are step one of any extraction, so 23.2–23.5 is the same work either way and is not wasted if the split is revived. The reverse does not hold: extracting Cp::-as-it-stands would carry the naming confusion across a service boundary, where it becomes far more expensive to fix.

23.1’s boundary test is also the cheapest available measurement of how separable the apps actually are — the allowlist in §5.3 is the coupling inventory. Let it shrink for a few phases, then revisit the split with real data instead of estimates.


Original draft created February 2026 as “Phase 15.” Renumbered to 22, then 23. Scope revised July 28, 2026. Spec fully rewritten July 29, 2026 — the pre-rewrite body targeted /admin/ and Cp::Member, both superseded. Re-scoped the same day after a value-vs-churn review: architecture enforcement added as 23.1, lookup/ namespace dropped.