Status: ✅ Complete — shipped July 30, 2026 Effort Class: Medium (~40 files; no migration) Prerequisites: 23.1 ✅, 23.2 ✅ Surface: ✅ Mostly active. Two narrow held-surface writes require approval — see §11. Sequencing: Must ship before the old 23.3 (now 23.4,
champions/→alumni_network/). See §9.3.Numbering note: This sub-phase was inserted after 23.2 shipped. The former 23.3/23.4/23.5 are renumbered 23.4/23.5/23.6 so numbers match implementation order, per the phase-plan convention.
23.1 and 23.2 built walls. This one builds the doors.
Phase 23’s premise is that each of the five apps should be addressable — that “in the lookup” or “in the signup admin” should have an unambiguous answer. That premise is correct and 23.1–23.2 delivered it. But taken alone it implies something false: that every file belongs to exactly one app.
It does not. The core domain objects are shared by all five apps:
Alumni · Education · EducationAreaOfStudy · Affinity · College · Major · Degree · District · Region · ZipCode · Employment · Opportunity · CrmDataChange · StaffNotification
An app is not an owner of these. An app is a view onto them. Two consequences that the walls, as built, get wrong:
CLAUDE.md already names this layer for models (“Shared, identity-agnostic models”). It has never existed for views, taxonomies, or helpers — and the cost is measurable, not theoretical (§4).
Any surface may use the shared layer. No surface may reach into another surface’s internals.
That is more permissive than what 23.1 shipped, and deliberately so. 23.1’s architecture test has no concept of a legitimate cross-surface reference, which makes correct code look like a violation and — worse — makes the correct fix (extract to shared) indistinguishable from the wrong one (duplicate it).
Three questions, in order, decide where a file goes:
| Question | If yes | Home |
|---|---|---|
| Is it about a shared domain object? | Shared | Root-level / shared/ |
| Is it one app’s presentation of a shared object? | Surface | That surface’s namespace |
| Is it about an object only one app has? | Surface | That surface’s namespace |
Industry::ALL is shared (three tables across two apps hold an industry string). Cp::LocationHelper#champion_primary_location is the portal’s presentation of a shared District — surface. Cp::SeededQuestion is portal-only — surface.
The tie-breaker when it is genuinely ambiguous: put it in shared. A shared thing used by one app costs a slightly-too-general home. A duplicated thing costs divergence, and §4.3 shows what divergence actually looks like.
ChampionRoleService is root-level, is the documented “single source of truth” for role definitions, and its header says outright:
# Used by both:
# - Public signup flow (ChampionSignupsHelper)
# - Champion Portal (Cp::ChampionRolesHelper)
ChampionQuizService is the same shape. Both predate this phase. The shared layer is therefore not a new idea to be introduced — it is an existing successful pattern that was applied ad hoc, never named, and never enforced. Every extraction in this sub-phase copies that shape: a root-level owner of the rule, with thin per-surface presenters wrapping it.
include_all_helpers stays ON, by decision rather than by defaultRails defaults config.action_controller.include_all_helpers to true and the repo has never set it. Every helper in app/helpers/ is therefore callable from every view on every surface, today, in both directions.
That could be read as a wall failure to be fixed. It is not — it is the requirement, already satisfied. “When a helper is created, benefit consistently in the other apps” is exactly what global helper inclusion provides. Turning it off would buy tidiness and cost the property this sub-phase exists to protect.
So it stays on, and 23.6 documents it as a decision rather than leaving it an unexamined default — because an unexamined default is one refactor away from being “cleaned up” by someone who reads the walls literally.
Honest limitation: this makes helper placement a convention, not an enforceable boundary. A test cannot stop Cp::LocationHelper being called from a Lookup view when Rails deliberately makes it available. §7 says so plainly instead of pretending otherwise. Placement still matters for finding things and for review; it just is not enforced, and the spec should not imply it is.
app/views/shared/, not to whichever app renders it mostThe seals are a deliberate cross-app consistency choice. They get a home that says so.
The phase docs, the roadmap page, and the backlog describe the repo’s development, not any app’s behavior. They are currently filed inside two different app surfaces, and one of them (champions/) is on hold — which is why CLAUDE.md needed a hand-carved exception to let /phase-wrap work at all. That exception is a workaround for a misfiling, and this sub-phase deletes both.
Rejected: giving each of the five apps its own phase-docs tree.
Phases routinely span apps. Phase 25 was “Signup Data in the Lookup Portal” — two apps. Phase 23 touches all five. Per-app phase folders would force every cross-app phase to be misfiled or duplicated, which is the /champions/ vs /champion_signups/ mistake in reverse: a directory promising a boundary it cannot keep. Phase history is also chronological and is read chronologically.
Instead, surface becomes a field, not a folder (§10). Greppable, honest, and zero churn across 25 phases of history.
Measured against the working tree, July 30, 2026. These are the reason the sub-phase is scoped as it is.
_seal.svg.erb and _icon.svg.erb live in app/views/champions/champion_signups/ — the frozen Alumni Network Admin tree. (De-linked in 23.6: this path is the pre-23.3 location and no longer exists. The artwork is now app/views/shared/champion_interest/, and the tree itself was renamed alumni_network/ in 23.4.)
| Consumer | App |
|---|---|
cp/home/_role_card.html.erb:84, cp/directory/show:304, cp/profile/show:348 |
Alumni Network |
| alumni_helper.rb:88,122,137 | Alumni Lookup |
| its own directory | Alumni Network Admin |
So editing the seals is currently a held-surface write. The rule intended to protect a frozen product also forbids editing a deliberate cross-app design choice. That is the strongest possible argument for a shared layer, and it was found by tripping over it: 23.2’s mechanical rename rewrote these paths and had to be reverted.
_interest_spectrum.html.erb has the mirror-image problem — it lives in signup/signups/steps/ and is rendered by alumni/show.html.erb:587 (Lookup) and signup_admin/signups/show:357.
Two shared assets, each homed in a different app, each consumed by three others. Neither is misplaced through carelessness — each sits where it was first needed.
industry is a string column on three tables across two apps:
| Table | App |
|---|---|
champion_signups.industry |
Signup |
cp_champions.industry |
Alumni Network |
cp_communities.industry |
Alumni Network |
23.1 extracted Industry::ALL for a different reason (to unblock 23.4’s rename). This confirms it was the right shape independently.
ChampionSignup does not validate industry at all. Its only validation is presence of name/grad-year/email. Cp::Champion validates inclusion: { in: INDUSTRIES }.
So the app that actually collects the value from a dropdown accepts anything; the app that merely displays it enforces the list. An altered form POST stores an arbitrary string that no report, filter, or community-matching rule will recognise — and nothing fails loudly.
This is what divergence looks like, in the tree, today, on a taxonomy that was shared for all of one week. Fixed in §6.
Affinity is a shared model. Two different apps carry their own category list about it:
| Constant | Location | App |
|---|---|---|
AFFINITY_CATEGORY_VALUES |
cp/seeded_question.rb:52 | Alumni Network (held) |
EXCLUDED_AFFINITY_CATEGORIES |
signup/signups_controller.rb:52 | Signup |
They answer different questions (“which categories can a seeded question target” vs “which to hide in the signup picker”), so this is not a duplicate to collapse — but both are facts about a shared model, and neither lives with it. Add a category to Affinity and there is no single place that tells you what to reconsider.
state appears on zip_codes and address fields, and there is no canonical list. Nothing is broken today. Noted because it is the next industry: the first app to need a state dropdown will hardcode one, and the second will hardcode a different one.
AlumniHelper is a root-level helper full of signup domain logicNine of its methods are signup presentation — champion_signup_lean_key/name/classes/icon, prospect_champion_seal, has_completed_champion_signup?. Under definition-by-exclusion, root-level reads as Alumni Lookup, so the file’s location now asserts something false.
It is also the natural place for Signup Admin to get the same lean badge — and Signup Admin currently renders the spectrum partial directly instead, which is why the two surfaces can drift on how a lean is labelled.
Not fully refactored here (§7.3) — the inventory is the deliverable, and splitting it is a bigger change than this sub-phase should absorb.
Create app/views/shared/champion_interest/ and move the cross-app artwork into it.
| From | To |
|---|---|
app/views/champions/champion_signups/_seal.svg.erb |
app/views/shared/champion_interest/_seal.svg.erb |
app/views/champions/champion_signups/_icon.svg.erb |
app/views/shared/champion_interest/_icon.svg.erb |
app/views/signup/signups/steps/_interest_spectrum.html.erb |
app/views/shared/champion_interest/_spectrum.html.erb |
Update all render sites: 3 in alumni_helper.rb, 4 in cp/home/_role_card, 1 each in cp/directory/show, cp/profile/show, alumni/show, signup_admin/signups/show, signup/signups/steps/_quiz_results, signup/signups/steps/_role. Plus Cp::ChampionRolesHelper#champion_icon_svg.
Each moved partial gets a consumer list in its header. This is where “take caution when editing” actually lands — not in a test, but in the first three lines of the file you opened:
<%#
Shared across surfaces. Editing this changes all of them:
- Alumni Lookup (alumni#show, via AlumniHelper)
- Signup (quiz results, role step)
- Signup Admin (signup detail)
- Alumni Network (home role card, directory, profile)
These seals are a deliberate cross-app consistency choice. If one surface
needs different artwork, add a variant — do not fork the partial.
%>
app/views/shared/ already exists (_flash, _engagement_score, email/), so this extends a convention rather than inventing one.
app/views/shared/champion_interest/alumni#show, the signup quiz reveal, Signup Admin detail, and the portal role cardbin/test — 5,297 runs, 0 failures, 0 errorsPrinciple: a set list about a shared object lives with the shared object, and every app that writes the column validates against it.
industry validation gap (§4.3)Add to ChampionSignup:
validates :industry, inclusion: { in: Industry::ALL }, allow_blank: true
Check existing data before adding this. The column has been unvalidated since Phase 19, so v1-migrated and hand-edited rows may hold values outside the list:
ChampionSignup.where.not(industry: [nil, ""]).where.not(industry: Industry::ALL).pluck(:id, :industry)
If rows exist, decide deliberately: extend Industry::ALL if the values are legitimate, or normalize them in a rake task. Do not add the validation without checking — it would make otherwise-valid records unsavable on their next update, silently breaking the returning-visitor flow for exactly the alumni whose data is oldest.
Affinity its category vocabulary (§4.4)Move both category lists onto the shared model as named constants, so Affinity is the one file that tells you what a category change affects:
class Affinity < ApplicationRecord
# Categories hidden from the alum-facing signup picker: geographic and
# post-graduation groups are derived from CRM data, not self-selected.
# Read by Signup::SignupsController.
SIGNUP_EXCLUDED_CATEGORIES = ["Geographic", "Post Graduation"].freeze
# Categories a seeded question may target. Read by Cp::SeededQuestion.
QUESTION_TARGETABLE_CATEGORIES = [...].freeze
end
Both callers read from Affinity. Cp::SeededQuestion keeping its own alias for now is acceptable — that edit is held (§11).
The single highest-value item in this sub-phase, and the one that generalizes past artwork and industry lists.
The same person can be described by three tables — alumni, cp_champions, champion_signups — and they use different names for the same field:
| Canonical (alumni vocabulary) | alumni |
cp_champions |
champion_signups |
|---|---|---|---|
first_name |
first_name |
first_name + legal_first_name |
first_name |
pref_name |
pref_name |
pref_first_name |
— none |
last_name |
last_name |
last_name |
last_name |
maiden_name |
maiden_name |
college_last_name |
maiden_name |
email |
email + email_school/personal/business/other |
email + unconfirmed_email, work_email |
email |
phone |
phone |
phone |
phone |
street |
— none | street_address |
street + legacy address |
city |
city |
city |
city |
state |
state |
state (limit 2) |
state |
zip |
zip (limit 10) |
zip_code |
zip_code |
Cp::ProfileChange::HIGH_PRIORITY_FIELDS — the list telling Advancement Services which changes matter most — declares pref_name and maiden_name. But Cp::Champion’s after_update :log_profile_changes callback writes the champion vocabulary. Measured against dev data:
declared high-priority but NEVER logged: ["pref_name", "maiden_name"]
logged but NOT high-priority: ["pref_first_name", "college_last_name", "legal_first_name", ...]
name-change rows invisible to the priority filter: 35
Cp::ProfileChange.for_field("pref_name") returns zero rows and always will. The constant and the callback that feeds it live in the same file and disagree about what a preferred name is called. Nothing failed, nothing logged, and the fields most valuable for CRM reconciliation are the ones silently dropped.
That is the entire argument for this map, at minimum scale, already in the tree.
legal_first_name is present then alumni.first_name = legal_first_name and alumni.pref_name = champion.first_name; otherwise alumni.first_name = champion.first_name and pref_name is left alone. So cp_champions.first_name maps to either canonical field depending on a second column. A flat one-to-one hash cannot express this and must not pretend to.alumni has no street column — already flagged in CrmDataChange::PROTECTABLE_FIELDSchampion_signups has no preferred-name column, so a preferred name reported in signup has nowhere to landchampion_signups carries both address and street — two columns for one concept in one tableTwo width mismatches are also worth recording: cp_champions.state is limit: 2 while the other two are unbounded, and alumni.zip is limit: 10. A value that is valid in one app can be untruncatable into another.
A declarative map plus a lookup, in the shared layer. Not a translation engine, and explicitly not a rewrite of either change log:
# app/models/identity_field.rb
class IdentityField
# Canonical names are the `alumni` vocabulary, because `alumni` is the
# CRM-facing row and CrmDataChange::PROTECTABLE_FIELDS already uses it.
# nil means the source has no column for this concept — a real gap, recorded
# rather than papered over.
MAP = {
"pref_name" => { alumni: :pref_name, cp_champion: :pref_first_name, champion_signup: nil },
"maiden_name" => { alumni: :maiden_name, cp_champion: :college_last_name, champion_signup: :maiden_name },
"zip" => { alumni: :zip, cp_champion: :zip_code, champion_signup: :zip_code },
# ...
}.freeze
# Canonical name for a source's column: IdentityField.canonical(:cp_champion, :college_last_name) => "maiden_name"
def self.canonical(source, column) = ...
end
Then a test — the payload, cheaper than the map itself:
Every
field_namewritten tocp_profile_changesandcrm_data_changesmust be a canonical name, or a declared alias of one.
That makes the §6.3 defect class unrepresentable rather than merely fixed once.
Scope limit, deliberate: 23.3 adds the map, points Cp::ProfileChange::HIGH_PRIORITY_FIELDS at canonical names, and adds the test. It does not rewrite existing rows, retire either log, or resolve the conditional name mapping into code — see §14 and Phase 26.
| Constant | Why it stays |
|---|---|
Cp::Champion::EMPLOYMENT_STATUSES |
employment_status exists on cp_champions only. One app, one owner. |
Cp::Champion::BELMONT_DEPARTMENTS |
Portal fac/staff affiliation. Not a shared object. |
EngagementType::LEVEL_POINTS, ACTIVITY_GROUPS |
Lookup-only scoring. Already correctly root-level. |
| US state list (§4.5) | Does not exist. Creating one with no caller is an orphan (CLAUDE.md rule 11). Filed to BACKLOG so the second app to need it finds a decision instead of a blank page. |
industry values audited before the validation is added; findings recorded hereChampionSignup validates industry against Industry::ALLnil and "" stay validAffinity owns both category lists; Signup::SignupsController reads from itIdentityField::MAP exists, with nil recorded for the three real gapsCp::ProfileChange::HIGH_PRIORITY_FIELDS uses canonical names and matches rows — for_field("pref_name") returns 6 and for_field("maiden_name") returns 16 against dev data, both structurally 0 before. Fixed by read-side translation, not by pointing the callback at canonical names — see Spec Deviation 3field_name written to either change log is canonical or a declared aliaslegal_first_name mapping documented in IdentityField, not encoded as a flat pairbin/test — 5,297 runs, 0 failures, 0 errors| Layer | Shared home | Surface home |
|---|---|---|
| Models / taxonomies | app/models/*.rb (unnamespaced) |
app/models/<surface>/ |
| Services | app/services/*.rb (unnamespaced) |
app/services/<surface>/ |
| Helpers | app/helpers/*.rb (unnamespaced) |
app/helpers/<surface>/ |
| Views / partials | app/views/shared/ |
app/views/<surface>/ |
A shared file must carry a header naming its consumers, in the shape ChampionRoleService already uses. That header is the deliverable, not decoration: it is what tells the next person that a two-line change has four audiences.
Per Decision 2, all helpers are global. This sub-phase does not pretend otherwise and adds no test that implies otherwise. What 23.6 documents:
AlumniHelper’s signup methods (§4.6) are recorded and filed to BACKLOG, not moved. Rationale: nothing is broken, all callers work, and Rails’ global inclusion means the file’s location has no functional effect. Moving nine methods with their tests is real churn for a documentation-shaped benefit, and this sub-phase already carries two held-surface asks.
The one thing worth doing now is cheap: a header on AlumniHelper saying which of its methods are signup-domain and that Signup Admin may use them, so the next person needing a lean badge finds it instead of writing a second one.
ChampionRoleService, ChampionQuizService, Industry, and the three moved partialsAlumniHelper header flags its signup-domain methods as reusableAlumniHelper splitchampions/app/controllers/champions/roadmap_controller.rb is an HTML rendering of the phase docs. It began as an Alumni Network roadmap and grew, with the docs, into a record of all five apps. It is repo development tooling that happens to sit inside a frozen app.
| From | app/controllers/champions/roadmap_controller.rb + app/views/champions/roadmap/ |
| To | app/controllers/roadmap_controller.rb + app/views/roadmap/ (root-level = Alumni Lookup by exclusion) |
| Route | get 'roadmap' under scope module: 'champions', helper cp_roadmap, belmontalum host, non-production only → root-level /roadmap, helper roadmap, alumnilookup host, non-production only |
| Layout | layout "champions" → the Lookup default |
⚠️ This changes which domain serves the page. It is staff-facing development tooling, so alumnilookup.com is the correct host and belmontalum.com serving it was an artifact of where the file landed. Flagged explicitly because it changes a URL people may have bookmarked, and because it is the one judgment call in this sub-phase that is not purely mechanical.
Three things must change in the same commit or /phase-wrap breaks silently:
CLAUDE.md rule 5 — delete the exception entirely. It exists only because the file was misfiled. This is the point.CLAUDE.md rule 7 — new path for the phase-status source of truth.Also update .github/copilot-instructions.md and phases/README.md.
alumni_network/ renameThe former 23.3 (now 23.4) carried this risk in its own spec: “CLAUDE.md rules 5 + 7, the /phase-wrap skill, and copilot-instructions must be updated in the same commit as the move, or the wrap process breaks silently.”
Extracting the file first removes that risk instead of managing it:
/phase-wrap, CLAUDE.md rule 5, or rule 7alumni_network/, which is no more correct than champions/docs/planning/ now holds the development record for the whole repo: phases/ (25 phases across five apps), BACKLOG.md, qa/, development/, ai/.
But it also still holds genuinely portal-specific product docs — source/ (the canonical identity and philosophy documents), STAKEHOLDER-OVERVIEW.md, JOBS-TO-BE-DONE.md. A straight rename would relabel the problem rather than fix it, so split by what is actually true:
| Contents | To | Why |
|---|---|---|
phases/, BACKLOG.md, qa/, development/, ai/, README.md |
docs/planning/ |
Repo-wide development process |
source/, STAKEHOLDER-OVERVIEW.md, JOBS-TO-BE-DONE.md |
docs/planning/alumni-network/ |
Genuinely Alumni Network product docs |
Existing siblings (champion-flow/, mobile-api/, mobile-app/) are untouched.
~20 path references across CLAUDE.md, .claude/skills/ (phase-plan, phase-wrap, debug, patterns, design-ux), copilot-instructions.md, and docs/REPO_OVERVIEW.md. Mechanical, but a missed one means an agent silently reading a stale doc — so verify with a grep for champion-portal returning only intentional matches.
Docs-only. No held-surface approval needed for 8.3.
champions//roadmap resolves on alumnilookup, non-production only; old route removed/phase-wrap skill path updated and verified with a dry run — every path the skill names resolves (app/controllers/roadmap_controller.rb, docs/planning/phases, docs/planning/BACKLOG.md, docs/planning/qa, docs/planning/alumni-network/ai/)grep -rn "champion-portal" returns only intentional matchesbin/test — 5,297 runs, 0 failures, 0 errorsThe only genuinely enforceable half of the shared-layer rule, because a render target is a static string:
A view or helper may render a partial from its own surface, or from
shared/. Not from another surface’s directory.
Ratchet as usual, with a dated baseline. app/helpers/alumni_helper.rb rendering champions/champion_signups/* is a current violation that 23.3a fixes, so it should leave the baseline in this sub-phase rather than enter it.
Cp:: assertions for the shared layer23.1 treats any Cp:: reference outside the held trees as suspicious. Under Decision 1, a shared file legitimately referencing a portal model — an association on a shared model, say — is correct code. Add an explicit SHARED_LAYER concept so the failure message can distinguish:
Both 23.3a and 23.3d remove files from held trees:
| Baseline | Change |
|---|---|
app/views/champions |
78 → 76 (two partials out) minus the roadmap views |
app/controllers/champions |
21 → 20 |
ALUMNI_NETWORK_ADMIN_CONTROLLERS |
drop roadmap_controller.rb |
23.1’s “removals are allowed but reported” assertion will fire on these. That is working as designed: lower the baselines in the same commit as the removals. Shrinking is the ratchet’s purpose; only growth is the finding.
Added to CLAUDE.md in 23.6, and the durable output of this sub-phase.
Every phase spec header gains:
**Surfaces:** Signup, Signup Admin
The phases index gains a Surface column. No phase docs are re-filed (Decision 5).
In its spec, in a sentence. Phase 23 earns it. Most would not, and being made to write the justification is the cheap check against the scope creep that produced this mess — 18 months of it, by your own account.
When a taxonomy, calculation, or presenter is needed for a shared domain object: put it in the shared layer first. Not in the app you happen to be building, to be extracted later — later is how _seal.svg.erb ended up inside a frozen app (§4.1) and how industry ended up validated in one app and not the other (§4.3).
Not forbidden — expected, and the point of having the layer. But the consumer header is there so the blast radius is known before the edit, not after. If one surface needs different behavior, add a variant or a parameter. Do not fork.
Narrower than 23.4/23.5. Nothing in this sub-phase changes portal behavior; the writes are moves and one alias.
| Write | Path | Why unavoidable |
|---|---|---|
| Delete two partials, update 6 render sites | app/views/champions/champion_signups/, app/views/cp/{home,directory,profile}, app/helpers/cp/champion_roles_helper.rb |
The seals cannot leave a held tree without deleting them from it. Leaving them means shared artwork stays un-editable (§4.1) |
| Move roadmap controller + views | app/controllers/champions/, app/views/champions/roadmap/ |
Removing it from champions/ is the deliverable |
(Optional) Cp::SeededQuestion::AFFINITY_CATEGORY_VALUES → alias Affinity::QUESTION_TARGETABLE_CATEGORIES |
app/models/cp/seeded_question.rb |
One line. Skippable — 23.5 can do it |
§6.1, §6.2 (signup half), §7, §8.3, §9, and §10 need no approval and can proceed independently.
| Risk | Mitigation |
|---|---|
industry validation makes existing records unsavable |
Audit before adding (§6.1). This is the one item here that can break a live alum-facing flow |
| Roadmap host change breaks staff bookmarks | Non-production-only page; note in launch guide. No redirect — it is not reachable in production |
A missed champion-portal docs path leaves an agent reading a stale doc |
Verify by grep, not by eye; the failure is silent by nature |
| Missed seal render site → 500 on a portal page | Partial-render errors raise loudly; portal smoke test covers the four cp/ sites |
| Held baselines not lowered → 23.1 test fails on arrival | Lower them in the same commit (§9.3) |
| Shared layer becomes a junk drawer | Consumer header is mandatory. A shared file that cannot name a second consumer is not shared — it is misfiled |
| Item | Rationale |
|---|---|
Splitting AlumniHelper |
Inventory only (§7.3); BACKLOG |
Turning off include_all_helpers |
Decision 2 — it would remove the property this sub-phase protects |
| Re-sorting phase docs per app | Decision 5 — a directory promising a boundary it cannot keep |
| A US state taxonomy | No caller; would be an orphan (§6.3). BACKLOG |
Relocating the engagement_stats / OnboardingInsights / ActionItemsService Cp:: analytics |
Still held-surface work; allowlisted in 23.1 |
Renaming docs/planning/champion-flow, mobile-api, mobile-app |
Accurate names already |
| Any behavior change | Pure refactor plus one missing validation |
| Resolving which record is true when three disagree | This sub-phase makes the three tables speak one vocabulary. Deciding what happens when they say different things is Phase 26 — a data-integrity design question, not a namespacing one, and it needs the portal off hold to finish |
Wiring or retiring Cp::SyncChampionToAlumni |
Phase 26.3. Do not wire it opportunistically — it works, which makes wiring it the path of least resistance and thereby enshrines the write-through policy by accident |
Retiring cp_profile_changes |
Phase 26.4, coordinated with 23.5’s cp_* → an_* rename |
23.3 §6.3 gives the three tables a shared vocabulary. That is necessary and not sufficient, and the distinction matters enough to state on its own:
Making the columns agree on their names removes the last visible sign that the two apps disagree on their write policy. Cp::SyncChampionToAlumni writes the alumni row; Alumni::SignupProfile never writes and resolves at read time. Today those policies collide on differently-named fields, which is confusing but noticeable. After 23.3 they would collide on identically-named ones.
So: do not read a green 23.3 as evidence that identity is solved. It is Phase 26, it is drafted, and 23.3 is its prerequisite rather than its replacement.
Recorded as built. bin/test: 5,297 runs, 15,014 assertions, 0 failures, 0 errors.
§11’s approval table was incomplete — §6.3 needed a held write it never listed§11 named three held writes and said “§6.1, §6.2 (signup half), §7, §8.3, §9, and §10 need no approval.” §6.3 appears in neither list, but its acceptance criteria require editing app/models/cp/profile_change.rb — a held path. Flagged before writing; approval granted separately.
industry fix is read-side translation, not a callback rewrite§6.3’s acceptance said to verify for_field("pref_name") is non-zero “after the callback is pointed at canonical names.” Pointing the callback at canonical names would have required rewriting Cp::Champion#log_profile_changes and backfilling existing rows — which decides a write-through policy §14 explicitly defers to Phase 26.
Built instead: HIGH_PRIORITY_FIELDS declares canonical names, and high_priority / for_field / #priority translate through IdentityField to the columns actually stored. Same outcome, no data rewrite, Phase 26’s question left open. Verified: for_field("pref_name") → 6 rows, for_field("maiden_name") → 16, both structurally 0 before.
Found while building the map. Cp::Champion’s before_save :normalize_names assigns pref_first_name = first_name on every save where first_name is present. So cp_champions.pref_first_name is a mirror of first_name, not a preferred name — a champion cannot express one at all, and the only pref_first_name rows in the change log are shadows of first_name edits.
§6.3 recorded champion_signups as having no preferred-name column. In practice cp_champions effectively has none either. The map is schema-accurate and behaviorally dead at that one entry, which is why IdentityField::PREF_NAME_IS_MIRRORED exists and is pinned by a test. Fixing normalize_names is a portal behavior change → BACKLOG 9.3, Phase 26.
industry audit found no bad rows — but the test suite had drifted0 production-shaped rows held an off-list value, so the validation was safe. But three tests stored "Education", "Music", "Entertainment" — titleized display labels the form’s <select> can never submit, since it submits the key and humanizes only the label. Exactly the divergence §4.3 predicted, in the test suite rather than the data. Corrected to canonical keys; one CRM-logger assertion that expected "Reported industry: Entertainment" was asserting a capitalization production has never produced.
The table routed ai/ and README.md to the repo-wide bucket and did not mention features/ at all. Reading them: AI_00_PORTAL_OVERVIEW.md, features/01-AUTHENTICATION.md, and README.md (“Alumni Network — Technical Overview”) are Alumni Network product docs. All three went to docs/planning/alumni-network/ with source/, STAKEHOLDER-OVERVIEW.md, and JOBS-TO-BE-DONE.md.
Scale was also understated: §8.3 estimated “~20 path references.” Actual: ~230 textual references across 83 files, plus 88 markdown links whose relative depth changed with the move. Links were rewritten by resolving each to an absolute repo path before the move and recomputing it after, rather than by hand. Broken-link count went 88 → 85 — no regression, and 23.6 still owns policy for the rest.
Cp::ChampionRolesHelper#champion_icon_svg was not a render site§5 listed it among the render sites to update. It holds a hardcoded inline <svg> string and never renders the partial — and it is a different mark from the shared one. Left alone; filed to BACKLOG 9.4.
layouts/signup.html.erb legitimately renders signup/signups/header. The mapping is not identity — layouts/champions belongs to cp/ — which is itself Phase 23’s naming problem in miniature.Cp:: assertion. Its phase descriptions name Cp:: classes as prose inside string literals, which #strip_comments cannot see. Excluded as a DOCUMENTATION_ARTIFACTS category rather than baselined as a count — a count would read as debt, and these references stopped being cross-surface the moment the file left the held tree.AFFINITY_CATEGORY_VALUES = Affinity::QUESTION_TARGETABLE_CATEGORIES inside module Cp resolves lexically to Cp::Affinity — a real, different model — and the failed autoload re-evaluates the class body, surfacing as "you tried to define an enum named status ... already defined". Every shared-layer reference from a namespaced surface needs the :: prefix. Documented in CLAUDE.md rule 7.
| Baseline | From | To |
|---|---|---|
app/controllers/champions |
21 | 20 |
app/views/champions |
78 | 75 |
ALUMNI_NETWORK_ADMIN_CONTROLLERS |
21 names | 20 (roadmap_controller.rb dropped) |
SIGNUP_REFERENCES_INSIDE_HELD_TREES |
2 entries | 1 (roadmap’s 7 refs left the held tree) |
SURFACE_LAYOUTS["app/controllers/champions"] |
champion_admin, champions |
champion_admin |
CROSS_SURFACE_RENDERS |
— | empty, on purpose (23.3a removed the violations rather than allowlisting them) |
Created July 30, 2026, after 23.1 and 23.2 shipped. Prompted by a correction to the phase’s framing: walls between apps must not prevent editing what is deliberately shared. Reconnaissance found the shared-layer pattern already present and working in ChampionRoleService, shared artwork stranded inside a frozen surface, and a taxonomy enforced in one app and not the other.