# CampaignMember Active Formula Read-Through

## Problem Statement

Imported CampaignMember rows can show blank person fields even when the related Lead or Contact has the correct values. Issue #2258 was opened on 2026-06-01 after staging showed 29 CampaignMember rows from 2021-08-24 with `Name`, `CompanyOrAccount`, and `Email` rendered as empty in the UI and returned as null through the API, while SOQL relationship traversal could still read the linked Lead's `Name` and `Company`.

This is not a normal data hygiene problem. These fields are Campaign Member Person Fields: their displayed value should be derived from the related Lead or Contact. A one-time backfill would make one historical slice look better, but it would not preserve Salesforce-compatible read behavior for future imports, source Lead/Contact edits, or any record path that bypasses NexusCRM's write lifecycle.

## Salesforce-Compatible Implementation Understanding

Salesforce exposes CampaignMember as the participation record between a Campaign and a related Lead or Contact. The person-facing CampaignMember fields are read-only surfaces over that related record. For Lead-backed members, fields like `Name`, `FirstName`, `LastName`, `Email`, `Phone`, and `CompanyOrAccount` reflect Lead fields such as `Lead.Name`, `Lead.Email`, and `Lead.Company`. For Contact-backed members, the same surfaces reflect Contact fields, and `CompanyOrAccount` follows the Contact's Account name.

Salesforce does not require users or importers to write those fields onto CampaignMember. They are not independent business facts on the membership row. A read of a historical CampaignMember must resolve the current related Lead or Contact value the same way a read of a newly created CampaignMember does.

NexusCRM represents the in-scope PingCAP behavior with active computed metadata. This PRD's field set is exactly the Campaign Member Person Fields that the current PingCAP final pack preserves as active formulas: `Name`, `FirstName`, `LastName`, `Email`, `Phone`, and `CompanyOrAccount`. For example, `Name` is derived from `Lead.Name` or `Contact.Name`, `Email` from `Lead.Email` or `Contact.Email`, and `CompanyOrAccount` from `Lead.Company` or `Contact.Account.Name`.

The Salesforce object also contains other read-only CampaignMember person-adjacent fields, such as title, address, fax, mobile, description, and opt-out flags. Those broader read-only surfaces are not pulled into this #2258 closure. Restoring or expanding the broader official standard-pack field set needs a separate design.

Public Salesforce-shaped describe output should remain typed and read-only. It may expose `calculated` and `calculatedFormula` because NexusCRM stores these fields as active computed metadata, but external correctness must not depend on REST describe exposing NexusCRM's internal `formulaMode` field. `formulaMode` is an internal storage and lowering decision, not a Salesforce business concept.

## Status Quo

This repository is the downstream PingCAP pack repository. It does not own the platform runtime. The canonical tested artifact is the final downstream pack assembled from the official standard CRM pack plus `packs/Salesforce-PingCAP-L2`.

The current checked-in final downstream pack preserves active formula declarations for `CampaignMember.Name`, `FirstName`, `LastName`, `Email`, `Phone`, and `CompanyOrAccount`. The exact problem fields are not being downgraded into plain string/email columns by the current final-pack assembly.

The broader migration contract already recognizes that direct TiDB loads bypass the normal application write path. After import, apply, repair, rebuild, and verify are mandatory because raw inserts do not materialize every derived surface. #2258 shows the key missing guarantee: the six in-scope active CampaignMember person fields must read through the active formula projection after convergence. They must not depend on cached values written during create or update.

The upstream runtime architecture is already shaped around that target:

- active computed fields are projected through object formula views;
- query, search, analytics, single-record read, and flow reprojection paths are intended to route through the formula view resolver;
- apply rebuilds derived formula views;
- verify fails closed when an object with active computed fields has a missing or unreadable formula view.

The staging symptom therefore means the closure contract must prove the full installed behavior for CampaignMember, not just preserve metadata in the pack. A row imported with null stored snapshots or missing physical snapshots for CampaignMember person fields must still return derived values from its related Lead or Contact after the platform has converged.

## Solution

Close #2258 by making the six in-scope CampaignMember person fields authoritative read-through projections.

For every CampaignMember row whose `LeadId` points to a readable Lead, the platform must return the six in-scope Campaign Member Person Field values derived from that Lead. For every CampaignMember row whose `ContactId` points to a readable Contact, the platform must return values derived from that Contact and the Contact's Account when the field requires account context.

This must hold for newly created rows, rows inserted by the normal API, rows inserted through bulk/import paths, and rows imported directly into TiDB before convergence. The fix belongs in the L0 active formula read substrate and its install/verify proof, with downstream pack and watchdog coverage proving that the PingCAP final pack exercises the substrate correctly.

## Canonical Behavior Contract

| Surface | Required behavior |
| --- | --- |
| Lead-backed `CampaignMember.Name` | Returns the related Lead's display name |
| Contact-backed `CampaignMember.Name` | Returns the related Contact's display name |
| Lead-backed `CampaignMember.Email` | Returns `Lead.Email` |
| Contact-backed `CampaignMember.Email` | Returns `Contact.Email` |
| Lead-backed `CampaignMember.CompanyOrAccount` | Returns `Lead.Company` |
| Contact-backed `CampaignMember.CompanyOrAccount` | Returns the related Account name when Contact has an Account |
| Remaining fields in scope | `FirstName`, `LastName`, and `Phone` use the same read-through contract |
| Read-only fields outside this PRD field set | Title, address, fax, mobile, description, opt-out flags, and other Salesforce read-only CampaignMember surfaces are not required by this #2258 closure |
| Historical/imported rows | Do not require a CampaignMember PATCH or stored-column backfill to read correctly after platform convergence |
| Stale stored snapshots | If the storage layer can still carry a physical/stale value for an in-scope active computed field, the read result uses the related Lead/Contact value rather than the stored snapshot |
| Related-record edits | A subsequent Lead, Contact, or Account edit is reflected on the next CampaignMember read without touching CampaignMember |
| SOQL | `SELECT Name, Email, CompanyOrAccount FROM CampaignMember` returns the derived values |
| REST SObject read | `GET /sobjects/CampaignMember/{Id}` returns the derived values when fields are readable |
| UI list/related-list read | CampaignMember list columns use the same API/query values and do not apply a frontend-only fallback |
| Describe | Fields are read-only, typed, and calculated when backed by active computed metadata; public behavior does not depend on `formulaMode` being present |
| Missing formula view | Reads and readiness fail closed instead of silently returning null stored snapshots |
| Broken formula expression | Install or verify fails closed before staging can present a partially working CampaignMember surface |

## User Stories

1. As a marketing operator, I want imported Lead-backed CampaignMember rows to show the Lead name, so that campaign membership lists remain usable for historical campaigns.

2. As a marketing operator, I want imported Lead-backed CampaignMember rows to show Lead email, so that campaign follow-up lists do not lose contactability.

3. As a marketing operator, I want imported Lead-backed CampaignMember rows to show Lead company in Company (Account), so that campaign reporting still identifies the business.

4. As a sales user, I want Contact-backed CampaignMember rows to show the Contact name, so that campaign history is readable from Contact and Campaign views.

5. As a sales user, I want Contact-backed CampaignMember rows to show the Contact email, so that the member row mirrors the person record.

6. As a sales user, I want Contact-backed CampaignMember rows to show the Contact's Account name in Company (Account), so that the UI matches Salesforce expectations.

7. As a migration operator, I want raw imported CampaignMember rows to read correctly after convergence without backfilling person fields, so that direct-load migration remains safe and repeatable.

8. As a migration operator, I want the platform to ignore stale stored values for the six in-scope active CampaignMember person fields when such storage exists, so that old cached data cannot override the related-record source of truth.

9. As a QA owner, I want a fixture that inserts a CampaignMember outside the app write path, so that the test proves read-time derivation rather than write-time caching.

10. As a QA owner, I want both Lead-backed and Contact-backed examples, so that the formula path is not hard-coded for only the failing Lead case.

11. As a QA owner, I want Company (Account) to cover `Lead.Company` and `Contact.Account.Name`, so that multi-hop relationship traversal is pinned.

12. As a QA owner, I want FirstName, LastName, and Phone covered together with Name, Email, and Company (Account), so that the implementation fixes the exact active computed field set in the final PingCAP pack rather than only the three fields listed in #2258.

13. As a frontend user, I want CampaignMember list views to show the derived values from the API, so that the UI does not need a special null fallback.

14. As an API client, I want SOQL and REST record reads to agree, so that different external surfaces do not drift.

15. As a platform maintainer, I want missing formula views to fail readiness closed, so that stale null snapshots are not mistaken for valid data.

16. As a pack maintainer, I want final-pack verification to fail if CampaignMember person formulas are downgraded or removed, so that downstream assembly cannot silently reintroduce #2258.

17. As an upstream maintainer, I want formula relationship traversal resolved from metadata, so that the fix stays generic and does not special-case Lead, Contact, Account, or CampaignMember in query code.

18. As an upstream maintainer, I want public describe to stay Salesforce-shaped, so that internal `formulaMode` does not become a client-facing compatibility requirement.

19. As a release owner, I want staging verification to include a real CampaignMember read-through probe, so that deployment cannot pass with only metadata present.

20. As an operator, I want the recovery action for a missing or broken formula view to be explicit setup convergence, so that startup checks remain read-only and safe.

21. As a customer-facing support engineer, I want the #2258 root cause to be documented as platform read-through derivation, so that future reports are not triaged as one-off backfill requests.

22. As a standard-pack maintainer, I want the same six-field active formula contract to apply to official CRM metadata and PingCAP overlays, so that downstream does not carry a private behavior fork for this #2258 field set.

## Implementation Decisions

- Fix the authoritative read path in upstream L0 active formula behavior. Do not solve #2258 with a PingCAP-only UI fallback, CampaignMember-specific query branch, or one-time staging data patch.

- Treat the six in-scope Campaign Member Person Fields as active read-through fields. Stored base-table values for those fields, when present from old imports or historical schemas, are not authoritative.

- Preserve the current final-pack active formula declarations for `CampaignMember.Name`, `FirstName`, `LastName`, `Email`, `Phone`, and `CompanyOrAccount`. A downgrade from active computed metadata to plain stored fields is a regression unless a future design replaces it with an equivalent read-through projection contract.

- Keep relationship traversal metadata-driven. `Lead.Name`, `Contact.Name`, `Lead.Email`, `Contact.Email`, `Lead.Company`, and `Contact.Account.Name` must compile through the same generic relationship-resolution path used by other active formulas.

- Route all user-visible read surfaces through the active formula read target when the object has active computed fields. This includes SOQL, REST SObject reads, list views, related lists, record UI, and any read-back used by standard actions or flows.

- Keep write paths simple. Normal CampaignMember creates may still populate event snapshots for automation, but correctness of later reads must not depend on writing derived values into CampaignMember.

- Keep startup verification read-only. If a formula view is missing or unreadable, readiness should fail with an explicit convergence instruction rather than rebuilding implicitly on startup.

- Keep setup/apply convergence responsible for rebuilding active formula views. The migration pipeline must run convergence after direct TiDB loads before declaring imported data ready.

- Keep public describe compatibility separate from internal metadata. `calculated` and `calculatedFormula` should remain correct for NexusCRM active computed fields, but clients must not rely on `formulaMode` in Salesforce-shaped describe responses.

- Add downstream final-pack and watchdog proof because #2258 was found in the PingCAP pack, even though the runtime fix belongs upstream.

## Testing Decisions

- Add an upstream integration test that creates or imports a Lead-backed CampaignMember while leaving stored CampaignMember person fields null, then reads `Name`, `FirstName`, `LastName`, `Email`, `Phone`, and `CompanyOrAccount` through SOQL and REST without PATCHing CampaignMember.

- Add the same test shape for Contact-backed CampaignMember, including an Account name for `CompanyOrAccount`.

- Add a stale stored-value test where the storage shape permits it: poison or preserve a physical/stored CampaignMember value for an in-scope active computed field, then prove the read result returns the related Lead/Contact value. If the current schema no longer creates physical columns for active computed fields, add the assertion at the read-target level and document that null stored-value coverage is the only direct data fixture available.

- Add a source-edit test: change the related Lead email or Contact Account name after the CampaignMember exists, then re-read CampaignMember and assert the changed value appears without touching CampaignMember.

- Add formula view compile coverage for CampaignMember expressions that use standard relationship names and a multi-hop relationship such as `Contact.Account.Name`.

- Add a read-routing regression test proving `CampaignMember` reads select from the active formula read target when active computed fields exist, and fail closed if that read target is missing.

- Add describe coverage proving the six in-scope active CampaignMember person fields are read-only, typed, calculated, and expose the calculated formula where NexusCRM metadata carries one.

- Extend downstream final-pack metadata audit to fail if the final pack loses active formula metadata for `CampaignMember.Name`, `FirstName`, `LastName`, `Email`, `Phone`, or `CompanyOrAccount`.

- Convert the existing manual Campaign Member display-name watchdog note into an executable API case, or add a new executable case, that verifies a seeded/import-shaped CampaignMember returns derived person fields.

- Add a migration-readiness probe that runs after apply/repair/rebuild/verify and checks a CampaignMember row whose linked Lead/Contact has known values.

- Keep UI/browser coverage focused on one list-view or related-list proof that the rendered values come from the API. The exhaustive field matrix belongs in API/integration tests.

## Out of Scope

- One-time staging data backfill as the primary fix.

- Hot-patching production or staging outside the pack/deploy contract.

- Frontend fallbacks from `LeadId_Name`, `ContactId_Name`, or ad hoc relationship aliases.

- Rewriting the entire formula language SSoT program.

- Supporting Accounts as Campaign Members. The current issue and pack formulas cover Lead and Contact membership; Account-member behavior needs its own design if enabled.

- Restoring broader Salesforce read-only CampaignMember person-adjacent fields such as title, address, fax, mobile, description, or opt-out flags when they are not active computed fields in the installed PingCAP final pack.

- Changing CampaignMemberStatus behavior, member status picklist scoping, or campaign status automation.

- Changing migration object scope or import batching strategy beyond adding read-through verification.

## Further Notes

The tempting data-fix interpretation of #2258 is understandable because the broken records were old and newly created records appeared correct. The more durable reading is that new rows only masked the problem by passing through a write path that produced usable values. Salesforce-compatible behavior requires the derived CampaignMember person surface to work for every row, including rows whose storage history predates the current application write path.

The acceptance bar is therefore: if the related Lead or Contact data exists and permissions allow it to be read, the six in-scope CampaignMember person fields read correctly after convergence even when the CampaignMember row itself was imported with null person-field snapshots.
