alumni_lookup

Phase 26 — Identity Resolution & Write Direction

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 IdentityField Blocked on: the Alumni Network coming off hold. Parts are doable before that; §8 says which.


1. The Question This Phase Answers

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.


2. Why It Was Drafted

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.


3. Findings

Measured July 30, 2026.

3.1 Cardinality is asymmetric, and only one service knows the rule

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.

3.2 Two write policies, in opposite directions, for the same fields

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.

3.3 Two change logs, one of which already generalized the other

  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.

3.4 The bridge between them exists, is fully tested, and is never called

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.

3.5 Three fields have no counterpart, and one table has two columns for one concept

From 23.3 §6.3:


4. Proposed Decisions

To be confirmed at phase start. Recorded now so the reasoning is not re-derived.

Decision A — Read-time resolution wins; no app writes another app’s row

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

Decision B — Alumni::Identity becomes the one resolver

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

Decision C — Retire cp_profile_changes, do not port it

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

Decision D — Field gaps are closed or explicitly declared unmappable

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.


5. Proposed Scope

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

6. Open Questions

  1. Does the portal ever write 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.
  2. What happens to 327 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.
  3. Should champion_signups gain a preferred-name column, or is preferred name portal-and-CRM-only by design?
  4. Is “newest active signup” still right at higher volume? It is right today. An alum with a stale complete signup and a fresh partial one is the case to test it against.
  5. Does 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.

7. Risks

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

8. What Can Proceed Before the Portal Unfreezes

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

9. Surface Note

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.


10. Verification Commands

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.