Status: Not started — drafted July 30, 2026 from findings during Phase 23 planning Effort Class: Medium-Large (design-first; the code is small once the policy is decided) Surfaces: Alumni Lookup, Signup, Signup Admin, Alumni Network 🛑 (all four; see §9) Prerequisites: Phase 23 complete — or at minimum 23.3, which supplies
IdentityFieldBlocked on: the Alumni Network coming off hold. Parts are doable before that; §8 says which.
A single BUID can be described by three tables at once. When they disagree, which one is true — and who is allowed to write to whom?
Today there is no single answer. There are two, they were arrived at two years apart, and they contradict each other.
This is not a naming problem and Phase 23 does not fix it. 23.3 makes the three tables speak the same vocabulary; this phase decides what happens when they say different things.
Raised during Phase 23.3 planning: Alumni::SignupProfile solves the Alumni↔ChampionSignup relationship, but the Alumni↔Cp::Champion relationship was solved separately and earlier, with its own change-log model. Connecting three or more definitions of one person, across fields that are named differently in each, is a problem the repo has approached twice without ever stating a rule.
Reconnaissance confirmed the concern and found it is already producing defects.
Measured July 30, 2026.
| Table | BUID constraint | Rows per person |
|---|---|---|
alumni |
unique index | exactly 1 |
cp_champions |
unique where not null | 0 or 1 (may be unlinked) |
champion_signups |
non-unique, indexed with created_at |
0 or many |
So the shape is 1 : 0..1 : 0..N. Alumni::SignupProfile had to invent a resolution policy — the newest active signup for the BUID drives everything — with a good reason recorded in its header:
“a profile that coalesced field-by-field across all of them would stop describing any single submission”
That policy is correct and it is private to one service. Nothing else in the repo is bound by it. Any second consumer that picks a different signup — first, last, most-complete — produces a different answer for the same person, and nothing would flag the divergence.
| Path | Policy |
|---|---|
Cp::SyncChampionToAlumni |
Writes the alumni row. alumni.zip = champion.zip_code, then alumni.save! |
Alumni::SignupProfile |
Never writes. Signup data is pending truth, resolved at read time so the CRM value survives |
This is the core issue. If the portal comes off hold with both live, the same alum changing their ZIP gets a different outcome depending on which app they used: the portal overwrites alumni.zip; the signup overlays at read time and leaves it.
Renaming the columns to match — which 23.3 does — does not fix this. It removes the last thing making the conflict visible, because the two policies would then collide on identically-named fields.
cp_profile_changes |
crm_data_changes |
|
|---|---|---|
| Subject FKs | cp_champion_id only |
alumni_id, cp_champion_id, champion_signup_id |
| Natural keys | — | buid, contact_id |
| Source discrimination | — | change_source (champion_portal / champion_signup / staff_edit / affinaquest_import) |
| Export lifecycle | — | export_status, exported_at, export_batch_id |
crm_data_changes is the four-way join, built in Phase 22. cp_profile_changes is the narrower predecessor that was never retired.
Cp::SyncChampionToAlumni is the only code that turns a portal edit into a CrmDataChange. It has 17 references in its test file and zero application callers. Built, tested, never wired.
The consequence shows in dev row counts:
| Log | Portal-sourced rows |
|---|---|
CrmDataChange where change_source: "champion_portal" |
1 |
Cp::ProfileChange |
327 |
Portal profile edits land in the old log and stop. They do not reach the export pipeline, so they do not reach Advancement Services. The portal is on hold, so nothing is broken today — but the moment it is unfrozen, alum-reported name and address changes are silently not exported.
From 23.3 §6.3:
alumni has no street column (already flagged in CrmDataChange::PROTECTABLE_FIELDS)champion_signups has no preferred-name column — a preferred name reported at signup has nowhere to landchampion_signups has both address and streetcp_champions.state is limit: 2, alumni.zip is limit: 10, the other columns unbounded — a value valid in one app may not fit in anotherTo be confirmed at phase start. Recorded now so the reasoning is not re-derived.
Recommended. No application writes a field it does not own. Every alum-reported change is logged to crm_data_changes; Advancement Services keys it into BruinQuest; the next sync flows it into alumni. Consumers resolve an effective value at read time.
Rationale:
Cost: Cp::SyncChampionToAlumni becomes a decision to revisit rather than dead weight — under Decision A the correct move is to keep only its CrmDataChange logging and drop the alumni.save!.
Alumni::Identity becomes the one resolverOne shared entry point answering “given a BUID, what records exist and which is canonical,” with SignupProfile’s newest-active-signup rule promoted from private policy to the rule. Alumni::SignupProfile and Alumni::EducationProfile become consumers.
cp_profile_changes, do not port itcrm_data_changes already carries everything it holds plus source discrimination and export lifecycle.
⚠️ This item lost its forcing moment. It was filed on the assumption that Phase 23.5’s cp_* → an_* migration would force the question — porting a superseded log into a new namespace is the moment inertia becomes permanent. 23.5 was cut (July 30, 2026; see phase-23 §8). No migration is coming, so nothing will surface this on its own. Phase 26.4 has to raise it unaided, and the table will otherwise sit there indefinitely looking maintained.
Requires: backfill existing rows into crm_data_changes with change_source: "champion_portal", or a recorded decision that history stays in the old table read-only.
For each of §3.5, decide: add the column, or declare it one-directional in IdentityField. Not left as nil with no note — 23.3 records the gaps; this phase resolves them.
| Part | What | Depends on |
|---|---|---|
| 26.1 | Alumni::Identity resolver; SignupProfile / EducationProfile become consumers |
Decision B |
| 26.2 | Write-direction invariant documented in CLAUDE.md + enforced by test where possible | Decision A |
| 26.3 | Wire or retire Cp::SyncChampionToAlumni; make portal edits reach crm_data_changes |
Decisions A + C, portal off hold |
| 26.4 | Retire cp_profile_changes; backfill or archive |
Decision C. No longer coordinated with 23.5 — it was cut, so this is self-standing work with no external trigger |
| 26.5 | Close the field gaps (§3.5) | Decision D |
alumni again? Decision A says no. If Advancement Services needs a faster path than the CRM round trip for some fields, that is a real constraint and changes the design — ask them before deciding.cp_profile_changes rows? Backfill, archive read-only, or discard. They represent alum-reported changes that never reached the CRM, so discarding is a data-loss decision, not a cleanup.champion_signups gain a preferred-name column, or is preferred name portal-and-CRM-only by design?Alumni::Identity need to resolve without a BUID? Unlinked signups and cp_champions.pending_buid both exist. AlumniLookupService and AlumniMatcher already do fuzzy matching — the resolver should probably delegate rather than duplicate.| Risk | Mitigation |
|---|---|
| 23.3’s vocabulary map removes the last visible sign of the write-policy conflict | Explicitly noted in 23.3 §6.3 and here. 23.3 must not be treated as having resolved this |
Retiring cp_profile_changes loses unexported alum-reported history |
Backfill first, verify counts, then retire. Never drop-then-decide |
Wiring SyncChampionToAlumni as-is enshrines the write-through policy by accident |
Decide A before 26.3. The service works, which makes wiring it the path of least resistance and therefore the risk |
Alumni::Identity becomes a god object |
Resolver only — “which records, which is canonical.” Field values stay in the *Profile services |
| Item | Blocked? |
|---|---|
| Decisions A–D (design, written down) | No |
26.1 Alumni::Identity + SignupProfile refactor |
No — active surfaces only |
| 26.2 Write-direction invariant documented | No |
26.5 Field gaps on alumni / champion_signups |
No |
26.3 Wiring portal → crm_data_changes |
Yes — held surface |
26.4 Retiring cp_profile_changes |
Yes — held. The 23.5 coordination is gone (cut), so nothing else will prompt this |
Touches four of five surfaces, which per the Phase 23.3 §10.2 rule requires justification: the phase is about a shared domain object, not about any app. One person’s identity is exactly the thing no single surface owns, and the defect in §3.4 exists precisely because each app solved it locally. A version scoped to one surface would reproduce the problem it exists to fix.
Re-measure before starting — dev numbers understate production.
# §3.4 — are portal edits reaching the export pipeline?
CrmDataChange.group(:change_source).count
Cp::ProfileChange.count
# 23.3 §6.3 — the vocabulary defect, against PRODUCTION
high = Cp::ProfileChange::HIGH_PRIORITY_FIELDS
actual = Cp::ProfileChange.distinct.pluck(:field_name)
puts "declared but never logged: #{(high - actual).inspect}"
puts "logged but not high-priority: #{(actual - high).inspect}"
# §3.1 — how often is the cardinality actually 0..N?
ChampionSignup.where.not(buid: nil).group(:buid).having("count(*) > 1").count.size
Drafted July 30, 2026 during Phase 23.3 planning, from a question about whether Alumni::SignupProfile and the older Cp::ProfileChange approach would collide. They already have — see §3.4. Not scheduled; Phase 23 comes first, and 23.3 supplies the vocabulary layer this phase builds on.