alumni_lookup

Phase 23.3 — The Shared Layer & Dev-Process Extraction

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.


1. Why This Sub-Phase Exists

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:

  1. Building any one app must not be blocked from touching what surrounds a shared object — its taxonomies, its presentation, its calculations.
  2. When one app gains a helper or calculation for a shared object, the others should benefit automatically — so the experience, the label, and the number are the same everywhere.

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).

The rule this sub-phase installs

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).


2. The Organizing Principle

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.


3. Decisions

Decision 1 — Do not invent a pattern; name and enforce the one that already worked

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.

Decision 2 — include_all_helpers stays ON, by decision rather than by default

Rails 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.

Decision 3 — Artwork moves to app/views/shared/, not to whichever app renders it most

The seals are a deliberate cross-app consistency choice. They get a home that says so.

Decision 4 — Dev-process artifacts are not app files

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.

Decision 5 — Phase docs are not re-sorted per app

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.


4. Reconnaissance Findings

Measured against the working tree, July 30, 2026. These are the reason the sub-phase is scoped as it is.

4.1 Shared artwork is homed inside a held surface

_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.

4.2 One taxonomy, three tables, two apps

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.

4.3 The same taxonomy is enforced in one app and not the other

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.

4.4 Two affinity-category lists, neither shared

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.

4.5 No US state list exists anywhere

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.

4.6 AlumniHelper is a root-level helper full of signup domain logic

Nine 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.


5. 23.3a — Shared View Layer

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.

Acceptance


6. 23.3b — Shared Taxonomies

Principle: a set list about a shared object lives with the shared object, and every app that writes the column validates against it.

6.1 Close the 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.

6.2 Give 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).

6.3 The identity field vocabulary map

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

This has already caused a live defect

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.

The map is not purely mechanical — two traps

  1. The name mapping is conditional. Per Cp::SyncChampionToAlumni, if 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.
  2. Three fields have no counterpart at all, and each is a real gap rather than an oversight:
    • alumni has no street column — already flagged in CrmDataChange::PROTECTABLE_FIELDS
    • champion_signups has no preferred-name column, so a preferred name reported in signup has nowhere to land
    • champion_signups carries both address and street — two columns for one concept in one table

Two 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.

What 23.3 builds

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_name written to cp_profile_changes and crm_data_changes must 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.

6.4 Leave alone, deliberately

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.

Acceptance


7. 23.3c — Shared Helpers and Services

7.1 The convention, stated

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.

7.2 Helper visibility is a convention, not a boundary — say so

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:

7.3 Inventory only — no helper refactor

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.

Acceptance


8. 23.3d — Dev-Process Extraction

8.1 The roadmap controller leaves champions/

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:

  1. CLAUDE.md rule 5 — delete the exception entirely. It exists only because the file was misfiled. This is the point.
  2. CLAUDE.md rule 7 — new path for the phase-status source of truth.
  3. .claude/skills/phase-wrap/SKILL.md:27 — new path.

Also update .github/copilot-instructions.md and phases/README.md.

8.2 Why this must precede the alumni_network/ rename

The 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:

8.3 The docs directory says something false

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.

Acceptance


9. Architecture Test Changes

9.1 New assertion — cross-surface rendering (the door)

The 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.

9.2 Loosen the Cp:: assertions for the shared layer

23.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:

9.3 Update the held baselines

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.


10. Rules Going Forward

Added to CLAUDE.md in 23.6, and the durable output of this sub-phase.

10.1 Surface is a field, not a folder

Every phase spec header gains:

**Surfaces:** Signup, Signup Admin

The phases index gains a Surface column. No phase docs are re-filed (Decision 5).

10.2 A phase touching more than two surfaces must justify it

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.

10.3 The shared layer is the first answer, not the last resort

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).

10.4 Editing shared code is a cross-app change

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.


11. Held-Surface Approval Required

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.


12. Risks

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

13. Out of Scope

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

14. What This Sub-Phase Deliberately Does Not Resolve

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.


15. What Was Implemented — Spec Deviations

Recorded as built. bin/test: 5,297 runs, 15,014 assertions, 0 failures, 0 errors.

1. §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.

2. The 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.

3. A fourth identity gap, worse than the three §6.3 predicted

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.

4. The industry audit found no bad rows — but the test suite had drifted

0 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.

5. §8.3’s docs table omitted two directories, and both are product docs

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.

6. 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.

7. Two additions to §9 the spec did not anticipate

8. A Zeitwerk trap worth the CLAUDE.md line it earned

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.

Held baselines lowered, in the same commit as the removals

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.