# Opportunity "Most Recent Activity" feed — design & implementation plan

Status: design / for review
Date: 2026-07-20
Scope: a Salesforce-parity **"Most Recent Activity"** panel on the **Opportunity** record page (v1) — one time-sorted (latest→oldest) feed that merges **field changes + activities + posts**, with click-through to activity/email detail. **Account is out of v1 scope** — generalization is deferred (see §6 Phase 2 / §7 decision 5).
Relationship: builds on Epic #3748 (field-history). **Reuses** the `_System_AuditLog` field-history SSoT rather than adding a second store.

---

## 1. What Salesforce's "Most Recent Activity" is (verified on prod)

The right-hand record panel (sort toggle **"Most Recent Activity" / "Latest Posts"**) is the record's **Chatter feed**, latest→oldest, merging three streams. Verified live on a production Opportunity via the `sf` CLI (record id, actor, and values redacted; the examples below are illustrative):

| Stream | SFDC source | Live evidence |
|---|---|---|
| **Field changes** | `OpportunityFeed` / `FeedItem` `Type='TrackedChange'` + `FeedTrackedChanges` child (FieldName, OldValue, NewValue) | a `TrackedChange` row on a currency field (`ARR_Amount_USD__c`, old→new value) plus an `opportunityCreatedFromLead` create-event |
| **Activities** | `Task` / `Event` with `WhatId` = opp (Email = `EmailMessage`), click-through to detail | several Tasks (e.g. a Completed *Call* logged via a sync integration), 0 Events |
| **Posts / comments** | `FeedItem` text posts + `FeedComment` | (none on this record; third stream in general) |

### ⚠️ Critical distinction — this is a *different* SFDC feature than the History tab

Salesforce has **two independent field-tracking features**:

- **Field History Tracking** ("Set History Tracking") → the `<Object>History` object + History reports. **This is what Epic #3748 built.**
- **Feed Tracking** (Setup → Feed Tracking) → `FeedItem` `TrackedChange` rows → the **"Most Recent Activity"** feed.

They have separate config surfaces and separate stores in Salesforce. **NexusCRM should not replicate the second store** — see §3.

---

## 2. Design thesis

Source the feed's field-change stream from the **same `_System_AuditLog` SSoT** (Epic #3748), and **merge** it with the two streams NexusCRM already serves — the **activity timeline** (Task/Event/Email) and **posts** (`_System_Comment`) — into one normalized, time-sorted feed rendered in a new record panel.

```
  <Object>History  (field changes over _System_AuditLog)  ─┐
  Task / Event / EmailMessage  (activity timeline)         ─┼─▶  normalize → FeedEntry[] → sort DESC → "Most Recent Activity" panel
  _System_Comment  (posts / comments)                      ─┘         (per-kind click-through)
```

This keeps one source of truth per fact, reuses three already-served back ends, and adds **no backend / L0 change**.

---

## 3. NexusCRM current state (substrate map)

Three streams exist but are **separate, non-adjacent, and un-merged**; on the Opportunity page most aren't even surfaced.

| Stream | Status | Where |
|---|---|---|
| **Field changes** | ✅ served | `useAuditLogs` → `SELECT Id, Field, OldValue, NewValue, CreatedDate, CreatedBy.Name FROM <Object>History …`; backend virtual view `features/audit/history_soql.go` over `_System_AuditLog` (actor `ChangedById`, ts `ChangedAt`). |
| **Activities** | ✅ served, but partitioned | app-pack panel `crm.activityTimeline` (`activity-timeline-*.ts(x)`): `useCrmActivities` queries Task (`ActivityDate`) + Event (`StartDateTime`), client-merges + sorts DESC but **splits upcoming vs past**; Task/Email click-through, Events not. |
| **Posts / comments** | ✅ served (`_System_Comment`) | `feedAPI` / `useComments` / `CommentList` — threaded, attachments, keeps a raw epoch date. |
| **Salesforce `FeedItem` / Chatter** | ⚠️ **inert** | `_System_FeedItem` table exists but has **no producer, no `<Object>Feed` SOQL handler**; `FeedComment`/`FeedLike` tables absent. The `Chatter.tsx` `Type==='TrackedChange'` branch is speculative UI nothing feeds. **Do not build on this.** |
| **Unified feed / merge layer** | ❌ **missing** | No common `FeedEntry` type, no fan-out loader, no cross-stream sort. `ActivityFeed` only unifies Comments+History behind two sub-tabs and **isn't on the Opportunity layout at all**. |

**Record-page composition:** `MetadataRecordDetail.tsx` renders from `PageLayout` (`tabs`, `sections`, `relatedLists`, `recordPanels`) as a **2/3 main + 1/3 sidebar** grid; the sidebar is near-empty today (only the Approval Chain) — the natural Salesforce-style slot for this feed. The Opportunity layout (`opportunity_page_layout.json`) has an `Activity` panel but **no `Chatter`/`Related` tab**, so Comments+History are currently unreachable on Opportunity.

---

## 4. Target design

### 4.1 Normalized entry
A single client type the three streams map into:
```ts
type FeedEntry = {
  id: string;
  kind: 'field-change' | 'activity' | 'post';
  timestampMs: number;         // monotonic sort key (raw epoch, not a display string)
  actorName?: string;          // OPTIONAL — filled only where the source stream carries it (see note below)
  title: string;               // "updated this record" | activity subject | "commented"
  detail?: { field?: string; oldValue?: string; newValue?: string } | { body?: string };
  target?: { objectApiName: string; recordId: string };  // click-through (activity/email detail, etc.)
};
```

> **Actor resolution is per-stream, so `actorName` is optional.** **Field changes** resolve `CreatedBy.Name` (traversable on the history objects). **Posts** select the scalar `CreatedById` only — `CreatedBy` is **not** traversable on `_System_Comment` — so v1 shows no post author. **Activities** carry no actor field in the reused query policy, so v1 renders a neutral fallback. Full per-stream actor names (esp. for activities) are a v2 follow-on and depend on the Created-By resolution fix (Epic #3748 area).

### 4.2 Panel-owned fan-out (no shared-hook change)
The new panel runs its **own** three queries (so it keeps raw timestamps and doesn't touch shared hooks):
- **Field changes:** read the object’s field-history source via the raw-SOQL `dataAPI.queryAll` path (the proven `useAuditLogs` reader; the structured `query` path is unverified for the virtual history objects), keeping `CreatedDate` as an epoch (`useAuditLogs` discards it, so we do **not** reuse that hook). The source object and parent key resolve **per object**: **Opportunity → `OpportunityFieldHistory` / `OpportunityId`** — **not** `OpportunityHistory`, which is the separate stage-summary object; custom `*__c` → `*__History` / `ParentId`; every other standard object → `<Object>History` / `<Object>Id`. Select `Id, Field, OldValue, NewValue, CreatedDate, CreatedBy.Name` (`CreatedBy.Name` **is** traversable on history objects) `ORDER BY CreatedDate DESC` → `field-change` entries.
- **Activities:** reuse `useCrmActivities`' queries (Task + Event by lookup), flattened into a single DESC stream, keeping only **occurred** activity — a closed item, or one dated `≤ now` (over-fetch, then filter, so a heavily-scheduled record isn’t truncated out of the fetch limit) → `activity` entries with existing Task/Email click-through. The activity query policy selects **no actor field**, so these entries carry no `actorName` in v1 (see the actor note above).
- **Posts:** `_System_Comment` by `RecordId` (select `Id, Body, CreatedById, CreatedDate`), **skipped for external-identity / Salesforce-connected sessions** (`_System_Comment` is NexusCRM-backend only, mirroring `useComments`) → `post` entries.

Merge → `sort by timestampMs DESC` → render. **Unified pagination:** over-fetch a page from each stream, merge-sort, emit the top N, keep a per-stream cursor for "load more" (SFDC-style "View All").

### 4.3 Field-change grouping ("X updated this record")
SFDC groups all field changes from one save into one item. `<Object>History` returns per-field rows (the `__record__` head row is filtered out). Two options:
- **v1 (simplest):** one entry per field change ("X changed Stage: A → B"), time-sorted. Ships immediately on the existing projection.
- **v2 (SFDC-parity grouping):** collapse rows from the same save into one "X updated this record" entry listing the fields. Needs a save/request group key — `_System_AuditLog` already stores `RequestId`/`TraceId`, but the `<Object>History` projection doesn't expose them yet (small L0 projection add). **Open decision — see §7.**

### 4.4 Placement
Register a new app-pack record panel `crm.recordActivityFeed` exactly like `crm.activityTimeline` (renderer-id + `frontend-contribution` + `host-adapter` registration), and place it in the **right sidebar** via the Opportunity layout `recordPanels` (v1; Account generalization is Phase 2). Keep the existing Activity tab, or fold it in (open decision §7).

---

## 5. Layer ownership (per `l0-l1-standard`)

| Unit | Layer | Note |
|---|---|---|
| The merge layer + `FeedEntry` normalizer + `crm.recordActivityFeed` panel | **L1** (`nexuscrm-app-packs`) | CRM record surface; registered like the existing `crm.activityTimeline` L1 panel |
| Placing the panel in the Opportunity layout `recordPanels` (sidebar; Account = Phase 2) | **L2** (this repo) | pack layout JSON (edit-layout path) |
| Data sources (`<Object>History`, `_System_Comment`, Task/Event SOQL) | **L0 — already served** | **no change** |
| (v2 only) expose `RequestId` group key on the `<Object>History` projection | **L0** | small, optional; only if we do per-save grouping |

**Blast radius: minimal.** v1 is L1 panel + L2 layout wiring, no backend/L0 change, no shared-hook change.

---

## 6. Implementation path

- **Phase 1 — v1 feed (L1 + L2).** New `crm.recordActivityFeed` panel: fan out the three queries, normalize to `FeedEntry`, sort DESC, render with per-kind icons + click-through (Task/Email detail; field-change/post → record). Per-field change entries (no grouping yet). Wire into the Opportunity sidebar via `opportunity_page_layout.json`. **Acceptance:** the Opportunity page shows one latest→oldest feed interleaving field changes (from the #3748 tracked fields), Tasks/Events, and comments; Task/Email rows click through to detail.
- **Phase 2 — Account + generalization.** Add the panel to the Account layout and confirm it generalizes to any object with `<Object>History` + activities + comments.
- **Phase 3 — unified pagination ("View All").** Interleaved cursor across the three streams.
- **Phase 4 (optional) — per-save grouping (v2).** Expose `RequestId` on the projection (L0) and collapse same-save field changes into "X updated this record". Gated on the §7 decision.
- **Phase 5 — polish.** Sort toggle parity ("Most Recent Activity" vs "Latest Posts"), filters (all / field-changes / activities / posts), empty states, FLS/RLS spot-checks (each source is already gated — verify nothing leaks in the merge).

---

## 7. Decisions (resolved 2026-07-20)

1. **Field-change grouping:** ship **v1 per-field entries now** (zero backend); **v2 "X updated this record" per-save grouping is a fast-follow** (needs a small L0 projection add for a `RequestId`/save group key).
2. **Panel placement:** right **sidebar** (Salesforce-style); **keep the existing Activity tab** alongside it (not a replacement).
3. **Posts source:** `_System_Comment` (implemented). **Do not** revive the inert Salesforce `FeedItem`/Chatter path (a much larger, separate build) — out of scope.
4. **Field config:** ~~**one config** — the feed reuses the **#3748 `trackHistory` field set** (drives both the History tab and this feed); no separate feed-tracking list.~~ **Superseded 2026-09-02** — there are **two authorities**, and conflating them was wrong:

   ```text
   trackHistory       -> complete Field History eligibility (the History surface)
   coreHistoryFields  -> Most Recent Activity summary eligibility (this panel)
   ```

   `trackHistory` cannot serve both. Disabling it does **not** hide history already
   recorded — `AllowedHistoryFieldNames` deliberately allows every live, FLS-readable
   field regardless of the flag, because temporal eligibility belongs to the interval
   ledger and closed intervals must stay visible after a disable. So using it to quieten
   this panel would fail on existing rows while also removing the field from Field
   History, reports and audit troubleshooting.

   `coreHistoryFields` is authored per panel in L2 layout config and consumed by
   `selectCoreHistoryRows` in L1 (nexuscrm-app-packs#822). Absent config shows every
   tracked field, so packs and objects that author none are unaffected. Salesforce
   works the same way: Field History Tracking and Chatter Feed Tracking are separate
   field lists, and this panel mirrors the **feed**.
5. **Scope:** **Opportunity only now.** Account generalization is deferred to a later story.

Delivery follows §6: v1 (Opportunity sidebar) → v2 per-save grouping → pagination & polish. Account is out of the current scope.

---

## 8. Risks
- **Timestamp fidelity:** `useAuditLogs` discards the raw timestamp — the new panel must query `<Object>History` directly and keep `CreatedDate` as epoch (don't reuse that hook).
- **Interleaved pagination** is the trickiest part (three cursors, one merged order); Phase 3 isolates it.
- **Created-By raw-ID bug** (known issue on #3748) affects the field-change stream's actor rendering — the same resolve-actor-name fix must land (or the feed shows raw UUIDs too). Depends on `task_7caabcab`.
- **FLS/RLS in the merge:** each source is independently gated; verify the client merge never surfaces a row the user couldn't read directly.

## 9. References
- SFDC live: a production Opportunity `OpportunityFeed` (TrackedChange) + Task stream (`sf` CLI, prod; record id and values redacted).
- NexusCRM: `features/audit/history_soql.go`; `useAuditLogs.ts`; `activity-timeline-*.ts(x)`; `feedAPI`/`useComments`/`_System_Comment`; `MetadataRecordDetail.tsx` (recordPanels + sidebar); `opportunity_page_layout.json`.
- Related: Epic #3748 (field-history); `docs/design/field-history-audit-reporting-design.md`.

---

## 10. Addendum (2026-07-20) — sidebar placement is L1, not L0 (verified vs the standard)

Starting the v1 build surfaced that **there is no app-pack "sidebar" record-panel position** — `RecordPanelPosition = 'tab' | 'header-banner'` only (`salesforce-crm-standard/frontend/src/platform/types/layout.ts`; resolved in `recordPanelRegistry.tsx`). The design's §4.4 "place it in the sidebar via `recordPanels`" was wrong: the sidebar column is rendered by the record renderer directly. So the sidebar needs a **new `'sidebar'` position + a render slot** in `MetadataRecordDetail.tsx`.

**Layer of that change — reviewed against `l0-l1-standard` (T0–T5):** **L1**, not L0.
- **Ownership is declared L1.** `nexuscrm-app-packs/salesforce-crm-standard/frontend/README.md`: *"the Salesforce-compatible CRM frontend owned by the `salesforce-crm-standard` L1 pack"*; *"The metadata record renderer remains **CRM-L1** … unless a future non-CRM consumer proves an app-blind renderer contract."* `source-manifest.json` lists the `platform/**` renderer files as this pack's authored entrypoints.
- **T0:** record-page panel *positioning* is app UI composition — not a floor metamodel concept and not a listed L0 engine kind (§1: metadata/persistence, permissions, expression, orchestration, events/audit/outbox, pack-lifecycle — **no UI/record-render engine**). The **only** L0 piece is the host-sdk **seam** `registerRecordPanelHostAdapter` (`@nexuscrm/host-sdk`), which app-packs consume **unchanged**. Adding a position value + render region is inside the L1 renderer.
- The README even states the L0-promotion trigger in standard terms (a demonstrated non-CRM consumer / app-blind renderer contract) — none exists → **stays L1** (T5 default L1 anyway).
- The NexusCRM `main` copy (`frontend/src/features/record/…`) is **transitional Phase-2B residue** ("the platform repo still owns transitional runtime binding … until the matching NexusCRM consumption slices remove the local CRM adapters"); ownership per the README is L1.

**Net:** v1 with the sidebar = **L1 (panel + new `'sidebar'` position & render slot) + L2 (layout `recordPanels` entry)**. **No L0 / NexusCRM change, no cross-repo PR.** (My earlier "small L0 host add" was wrong — I conflated "platform host component" with "L0"; the record renderer is L1 post-relocation.) **One transitional caveat to verify at build:** confirm the deployed app builds the app-packs L1 renderer (not the NexusCRM transitional copy), so the `'sidebar'` add takes effect.
