> ## Documentation Index
> Fetch the complete documentation index at: https://docs.open.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 08 16 salesforce v2 two way sync design

# Salesforce v2 — two-way Case sync, agent/queue mapping, `ticket-updated` trigger

> **Point-in-time planning artifact.** What shipped names the trigger **Session Updated** (`session-updated`, not `ticket-updated`); the attribute prefixes stayed `sf_case_` / `sf_contact_` / `sf_<object>_`.
> The shipped contract is documented in `docs/integrations/salesforce/email-cases.mdx`.

Consolidated design for ONE minimal PR replacing #2229 (case field sync), #2223 (agent parity), and the sync-related half of the intent behind #2227. Scope is strictly `TicketingSystemEnum.SALESFORCE_V_2` (`salesforce_v2`); v1 (`salesforce`, MoneyGram MIAW + legacy email-to-case) is untouched.

Baseline: `origin/main` @ `14b7ab598f`. Every claim below cites the file it was verified against.

***

## 1. Goal

1. **SF → OpenCX freshness.** A Salesforce agent edits any Case field → the linked session's `sf_case_*` attributes refresh within seconds (today they refresh only on `new_case` / `new_reply` / `human_agent_comment` / `owner_changed` — `backend/src/salesforce-case-integration/salesforce-case.service.ts:786,1432,2396,2475`).
2. **OpenCX → SF write-back.** A session attribute that maps to a Case field changes on our side (workflow "Update Session Attributes", inbox agent edit) → the Case field updates.
3. **A workflow trigger that fires on those attribute changes** so agentic workflows can act on a Salesforce Case change. No such trigger exists today (`backend/src/workflow/enums/workflow-trigger.enum.ts` has no session/ticket "updated" member).
4. **Agent mapping + assignee sync.** `Case.OwnerId` ↔ `chat_sessions.assignee_id`, both directions, behind the existing `chatbots.sync_assignee_from_3rd_party` / `sync_assignee_to_3rd_party` flags (`backend/src/db/opencx.ts:1530-1531`) — which gate nothing for Salesforce today (`backend/src/chat/chat-integration.service.ts:220`).
5. **Queue → team mapping.** A Case owned by a Salesforce Queue (`00G…`) lands the session in the mapped OpenCX team.

## 2. Non-goals (explicitly deferred)

* Polling / reconciliation worker for Case fields (webhook-first; see §10 for the poll follow-up).
* Attributing SF agent comments/emails to OpenCX teammates with avatars (the identity half of #2223).
* Outbound team → Queue push (Zendesk v2 has no outbound group push either — `backend/src/zendesk-integration-v2/zendesk-group-mapping.service.ts:17-24`).
* Roster cache table + dashboard mapping UI for agents/queues (Zendesk has `zendesk_agents` + `PUT /agents/mapping`; phase 1 here is auto-map-by-email with a persisted link, no UI).
* Firing `ticket-updated` for `editable_custom_data` edits (the ticket payload exposes only `custom_data` — `backend/src/workflow/definitions/payloads/workflow-trigger-payload.utils.ts:111`).
* `case_closed` refreshing fields / firing `ticket-resolved` (raw update at `salesforce-case.service.ts:2481-2531`; separate fix).
* "For Each Matching Session" cron action bundled into #2229 — its own PR.
* "Scan Salesforce Case Queue" action (#2227) — read-only bulk read, independent; keep as its own PR.

## 3. What exists on `main` (facts the design leans on)

| Building block                                                                                                                                                                                                                      | Where                                                                                                                                                                     | Reuse                                                               |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Case snapshot → `chat_sessions.custom_data` under `sf_case_*` (all readable, non-compound, non-system fields) + contact + configured related objects, then AI-context-filtered diff → `salesforce_fields_updated` chat\_history row | `SalesforceCaseService.syncSalesforceFieldsToCustomData` `salesforce-case.service.ts:2629-2758`; `notifyAllowedFieldsChanged` `:2779-2828`                                | **The** inbound refresh; call it from the new event                 |
| Central session custom-data write with integration reflection (Intercom/HubSpot only)                                                                                                                                               | `chatSession_repo_mergeCustomData` `backend/src/chat-session/repo/merge-custom-data.ts:7-115` (reflect branch `:79-110`)                                                  | Add `salesforce_v2` reflect arm + fire trigger + `keysToClear`      |
| Agent-edited bag write with HubSpot diff push                                                                                                                                                                                       | `chatSession_service_updateCustomData` `backend/src/chat-session/service/update-custom-data.ts:59-80`                                                                     | Add `salesforce_v2` arm                                             |
| Contact-side precedent: repo fires `contact-updated` behind a value diff, supports `keysToClear`                                                                                                                                    | `backend/src/consumer/repo/merge-custom-data.ts:55-130`, `emit-contact-updated.ts`                                                                                        | Mirror 1:1 for sessions                                             |
| Trigger definition template with `changedFields`                                                                                                                                                                                    | `backend/src/workflow/definitions/triggers/contact-updated.trigger.ts`                                                                                                    | Template for `ticket-updated`                                       |
| Ticket payload already carries `customData` (= `custom_data`)                                                                                                                                                                       | `ticket.payload.ts:138-144`, `workflow-trigger-payload.utils.ts:111`                                                                                                      | `{{trigger.ticket.customData.sf_case_Status}}` works out of the box |
| Assignee mirror chokepoint + per-integration dispatcher                                                                                                                                                                             | `mirror-assignee-to-integration.ts:17-37` → `ChatIntegrationService.syncAssigneeToThirdParty` `chat-integration.service.ts:167-241` (`SALESFORCE_V_2` no-op at `:220`)    | Add the `SALESFORCE_V_2` arm                                        |
| Zendesk v2 assignee sync (the simplest shipped shape: one consume-once Redis key, `changeAssignee` no-op guard as primary safety, `actor: {type:'integration'}`)                                                                    | `backend/src/zendesk-integration-v2/zendesk-assignee-sync.service.ts`                                                                                                     | Copy the shape                                                      |
| Zendesk v2 queue→team inbound: `assignToTeam({shallowAssignment:true, actor:{type:'integration'}, trigger:'integration'})`                                                                                                          | `zendesk-group-mapping.service.ts:225-236`                                                                                                                                | Same call                                                           |
| Per-membership external ids                                                                                                                                                                                                         | `chatbot_users.hubspot_user_id` / `zendesk_user_id` `opencx.ts:1552-1562`                                                                                                 | Add `salesforce_user_id`                                            |
| Queue registry with CRUD (API-only, no dashboard UI)                                                                                                                                                                                | `salesforce_escalation_teams` (`org_id, team_name, salesforce_queue_id, enabled`) `opencx.ts:4132-4141`; `salesforce-escalation-teams.{service,controller}.ts`            | Add nullable `group_id` FK                                          |
| Existing SF echo-guard convention (consume-once Redis key)                                                                                                                                                                          | `salesforce_v2:case:webhooks:ignore:{caseId}` `salesforce-case.service.ts:2593-2610`                                                                                      | Same key style for owner echo                                       |
| Per-key lock util                                                                                                                                                                                                                   | `backend/src/utils/redis-lock.ts` (`acquire/release/waitForRelease`)                                                                                                      | Serialize refreshes per case                                        |
| Existing SDK writes                                                                                                                                                                                                                 | `SalesforceCaseSDK.updateCase` `salesforce-case-sdk.ts:583-596`, `updateCaseOwner` `:606-613`, `describeUpdateableCaseFields` `:1089-1110`, `getCurrentUser` `:1112-1121` | No new SDK surface except `getUserById` / `findActiveUsersByEmail`  |
| Webhook contract: `POST /backend/salesforce-case/webhook/:token?` with `?caseId=&type=` (token in `x-opencx-token`)                                                                                                                 | `salesforce-case.controller.ts:80-157`; event enum `dto/salesforce-case-webhook.dto.ts:3-10`                                                                              | Add ONE new `type` value, same shape                                |

## 4. Assessment of the open PRs

| PR                                | Verdict                                               | Keep                                                                                                                                                                                                                                                                                                                                                          | Drop / why                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| --------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **#2229** case field sync (+5.5k) | Right problem, oversized solution                     | Namespace *replacement* semantics (a field nulled in SF must clear locally — today `serializeSalesforceRecordForCustomData` drops nulls so stale values linger `salesforce-case.service.ts:152-169`); diff includes removed keys; per-case lock                                                                                                               | New JSON body contract `{event_type:'case_changed', caseIds[]}` + Apex `Queueable`+`Finalizer` (customers already have the query-param contract; one more `type=` is additive), sObject-collections bulk path, 200-batch endpoint, custom `replace-salesforce-case-custom-data.ts` repo helper (the contact repo's `keysToClear` idiom does the same inside the existing merge), the whole "For Each Matching Session" bundle, no workflow trigger emitted (only a socket event)                                                |
| **#2223** agent parity (+1.9k)    | Right shape, over-guarded                             | `chatbot_users.salesforce_user_id` + partial unique index; email auto-link persisted NULL-only; outbound `SALESFORCE_V_2` arm in `syncAssigneeToThirdParty`; inbound owner→assignee on `owner_changed` gated on `sync_assignee_from_3rd_party`; AI ↔ integration user; Queue (`00G`) → team; relaxing the `HANDED_OFF` early-return only when sync-from is on | 150-line Lua guard with legacy/shadow keys (HubSpot/Zendesk use one consume-once `SET … EX 15` key each); `mirrorAssigneeToIntegration` param threaded through `changeAssignee`/`assignSessionToAi` (Zendesk pattern arms a consume-once key instead — no signature change); teammate identity/avatar attribution on inbound comments/emails; queue mapping by `team_name == groups.name` string match (a nullable FK is explicit and cheaper to reason about); `LastModifiedById` SOQL widening; CaseComment Apex trigger docs |
| **#2227** queue scan (+5k)        | Different feature (read-only bulk read for workflows) | —                                                                                                                                                                                                                                                                                                                                                             | Not sync. Leave as its own PR; not part of this consolidation                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |

## 5. Design

Four pieces, one PR, four commits (§9). Every hook point is inside a `SALESFORCE_V_2`-gated arm or inside `backend/src/salesforce-case-integration/`.

### 5A. `ticket-updated` workflow trigger (generic, fired from the session custom-data seam)

**Definition** — `backend/src/workflow/definitions/triggers/ticket-updated.trigger.ts`, `WorkflowTriggerEnum.TICKET_UPDATED = 'ticket-updated'`, registered in `triggers/index.ts`. Payload:

```
ticket:         ticketPayload            // current snapshot; customData = chat_sessions.custom_data
changedFields:  Text[]                   // custom_data KEYS that changed, e.g. ["sf_case_Status"]
previousValues: AnyObject                // { key: old value } for the changed keys (absent key = was unset)
```

No `trigger_configuration` (like `contact-updated`); authors gate with an if-block on `changedFields contains "sf_case_Status"` (array `contains` exists in the condition evaluator) and/or `previousValues.sf_case_Status`. Description states: fires when a ticket's attributes change (integration sync, workflow "Update Session Attributes", API); status/assignee/team/tag changes have their own triggers; a workflow that writes attributes to the same ticket re-triggers itself → gate on Changed Fields (same wording as `contact-updated.trigger.ts:36-38`).

**Fire site — exactly one:** inside `chatSession_repo_mergeCustomData` (`merge-custom-data.ts`), mirroring `consumer/repo/merge-custom-data.ts:55-92`:

1. read prior `custom_data` (one `SELECT` — the contact repo already pays this),
2. merge (`COALESCE(custom_data,'{}') || $set`, then `- $keysToClear::text[]` when `keysToClear` is non-empty — new optional param, same idiom as the contact repo),
3. `changedFields = keys where JSON(prior[k]) !== JSON(merged[k])` ∪ cleared keys; if empty → no fire,
4. `workflowTrigger_service_triggerByType({ orgId, triggerPayload: ticketUpdatedTrigger.$trigger({ ticket: await getTicketPayload({sessionId}), changedFields, previousValues }) })`, wrapped in try/catch + `console.error` — never fails the write (mirrors `emit-contact-updated.ts`).

Because the Salesforce refresh (5B) writes through this same function, it needs no fire site of its own. Loop termination = the diff: a workflow that writes the same value again produces `changedFields = []` → no fire.

Dashboard: trigger types are SDK-generated only (`dashboard/packages/sdk/src/schema.ts`), so `pnpm gensdk` is the only dashboard change.

### 5B. Inbound freshness — `type=case_updated` webhook

* **DTO/enum:** add `case_updated` to `SalesforceCaseEventType` (`dto/salesforce-case-webhook.dto.ts:3-10`); `mapApexTypeToEventType` passes it through (`salesforce-case.controller.ts:36-58`). Same query-param contract as every existing event.
* **Dispatch:** `case 'case_updated'` in `dispatchWebhook` (`salesforce-case.service.ts:244-330`) → `handleCaseUpdated(orgId, caseId)` fire-and-forget in prod, awaited otherwise (identical to `owner_changed`).
* **Handler `handleCaseUpdated`:** status-blind session lookup by `(copilot_id, salesforce_case_id)`; unknown case → log + return; acquire `RedisLock` `salesforce_v2:case:fields-refresh:{orgId}:{caseId}` (`waitForRelease` if held — a later webhook must not be dropped, it may carry the newest state); call the existing `syncSalesforceFieldsToCustomData({ orgId, sessionId, caseId, notifyOnFieldChange: true })`; release.
* **Stale-key clearing (the one behavioural change to the existing refresh):** in `syncSalesforceFieldsToCustomData`, compute `keysToClear = priorKeys.filter(k => k.startsWith('sf_case_') && !(k in caseCustomData))` and pass it to `chatSession_repo_mergeCustomData`. Applies to every caller (new\_case/new\_reply/…): a Case field cleared in Salesforce now clears locally instead of lingering. Same for `sf_contact_*` on the contact merge (already supported by the contact repo's `keysToClear`).
* **`notifyAllowedFieldsChanged`:** iterate the union of before/after keys so a removed whitelisted key is reported as `{old, new: null}` (cherry-pick of #2229's small change at its `:3036-3050`).
* Emits nothing else — the `ticket-updated` fire happens inside the merge (5A); the `salesforce_fields_updated` chat\_history row keeps working unchanged.
* **Docs (`docs/integrations/salesforce/email-cases.mdx`):** one new Apex method `sendCaseUpdatedEvents(List<Id>)` (clone of `sendCaseOwnerChangedEvents` with `&type=case_updated`) and trigger **7. Case trigger (any field change)** — `after update`, fires for every record in `Trigger.new` (no field filter; the backend diff dedupes). Documented as optional-but-recommended; existing 6 triggers unchanged; existing customers keep working with zero changes. Add the trigger to the Apex test class. Note the ceiling: each Case update = one `@future` callout, including echoes of OpenCX's own writes (harmless: no diff → no trigger).

### 5C. Outbound write-back — attribute → Case field

New `SalesforceCaseService.syncCustomDataToCase({ orgId, sessionId, changed })` (in `salesforce-case.service.ts`, next to `syncSalesforceFieldsToCustomData`):

1. session must have `salesforce_case_id`; org must be `salesforce_v2` (callers already gate on ticketing system, re-check anyway).
2. `describeUpdateableCaseFields(orgId)` → set of updateable API names (existing SDK method, `salesforce-case-sdk.ts:1089-1110`).
3. For each `[key, value]` in `changed`: `field = key.startsWith('sf_case_') ? key.slice(8) : key`; keep only if `field` is updateable and `field !== 'OwnerId'` (owner is owned by 5D) — one rule for both bags. Coerce boolean/number/currency exactly as `update-salesforce-case.action.ts:184-219` does (reuse that mapping inline).
4. Nothing left → return. Else ONE `SalesforceCaseSDK.updateCase(orgId, caseId, fields)`; on error `console.error` (never throws into the caller — mirrors HubSpot `sync-custom-data-with-ticketing-system.ts:190-203`).
5. **No echo guard needed for fields:** the SF `case_updated` echo re-fetches the Case; local `custom_data` already holds the value (merged before the push) → `changedFields = []` → nothing fires. (`// ponytail:` a concurrent local edit between push and echo could be overwritten by the echo snapshot; self-heals on the next change.)

Wire-up (both mirror the existing HubSpot arms, keyed on `chatSessionTags_service_getTicketingSystem(orgId) === 'salesforce_v2'`, so v1 never enters):

* `chatSession_repo_mergeCustomData` reflect branch (`merge-custom-data.ts:93-109`): `else if (ticketingSystem === 'salesforce_v2') await SalesforceCaseService.syncCustomDataToCase({…, changed: <only the changedFields subset>})` — lazy import like the HubSpot one. Callers reaching this with `reflectOnIntegration` default-true on an SF org: the workflow "Update Session Attributes" action (`merge-ticket-custom-data.action.ts:108`). Every SF-internal merge passes `reflectOnIntegration:false` (`salesforce-case.service.ts:2664`, `:2911`) → no self-push.
* `chatSession_service_updateCustomData` (`update-custom-data.ts:59-80`): add the `salesforce_v2` arm using the same `changed` diff the HubSpot arm computes → inbox agent edits push (bare `Priority` or `sf_case_Priority` both resolve).

### 5D. Agent mapping, assignee sync, queue → team

**Migrations**

1. `chatbot_users.salesforce_user_id text NULL` + `CREATE UNIQUE INDEX CONCURRENTLY … ON chatbot_users (chatbot_id, salesforce_user_id) WHERE salesforce_user_id IS NOT NULL` (`noTransaction = true`) — the shape #2223 already wrote (`20260815111500`, `20260815111501`); cherry-pick.
2. `salesforce_escalation_teams.group_id uuid NULL REFERENCES groups(id) ON DELETE SET NULL` + `COMMENT ON COLUMN` ("OpenCX team that owns Cases assigned to this Queue; NULL = escalation-only, no inbound routing"). Expose `group_id` in `salesforce-escalation-team.dto.ts` (`satisfies` binding keeps it honest) and pass through in `create`/`update`.

**`SalesforceAgentMappingService`** (new file `salesforce-agent-mapping.service.ts`, trimmed from #2223 — keep `resolveOpenCxUserId(sfUser)`, `resolveSalesforceUserId(openCxUserId)`; drop `resolveIdentity`/avatar):

* SF User → member: stored `salesforce_user_id`, else case-insensitive email match on `chatbot_users ⋈ users`, persist NULL-only (`WHERE salesforce_user_id IS NULL`, tolerate `23505`), never re-link a member already linked to a different SF id.
* Member → SF User: stored, else `SELECT Id,Name,Email FROM User WHERE IsActive=true AND Email='…' LIMIT 2` (SOQL value escaped via `SalesforceCrmUtils.escapeSoqlValue`), exactly one hit → persist.
* Two SDK additions: `getUserById`, `findActiveUsersByEmail`.

**`SalesforceAssigneeSyncService`** (new file, Zendesk shape):

* Gate: `ticketing_system === salesforce_v2` + the flag for the direction.
* **Outbound** `syncAssigneeWithSalesforce({orgId, sessionId, openCxAssigneeId})` — called from a new `case TicketingSystemEnum.SALESFORCE_V_2:` in `syncAssigneeToThirdParty` (`chat-integration.service.ts:217-232`, lazy import like HubSpot's). Requires linked case. Consume-once inbound-armed key `salesforce_v2:case:assignee-sync:ignore:{caseId}` → skip if present. Resolve target: `AI_COPILOT_USER_ID` → `getCurrentUser().Id` (the AI's seat, same idea as HubSpot's `default_user_id`); `null` → `meta.salesforce_previous_owner_id` if present else no-op (OwnerId is required in SF); user → mapped SF user, unmapped → skip with log. If target equals current owner → skip. Arm `salesforce_v2:case:owner-echo:{caseId} = ownerId EX 15` then `updateCaseOwner`; on failure delete the key.
* **Inbound** — inside `handleOwnerChanged` (`salesforce-case.service.ts:2413-2479`):
  1. Read `OwnerId`. If `owner-echo` key equals it → `DEL` + return (our own write bouncing back).
  2. Existing behaviour untouched when `sync_assignee_from_3rd_party` is off (early return on `HANDED_OFF`, handoff + "stepping back" comment on a non-integration owner, `assignee_id = null`).
  3. When the flag is on: skip the `HANDED_OFF` early-return (an A→B reassignment must sync); on the first non-integration owner still do the handoff write + comment, but keep a human `assignee_id` when the new owner is a Queue (`nullif(assignee_id, 555)`, from #2223); then `syncAssigneeFromSalesforce`: owner == integration user → `assignSessionToAi` (`actor:{type:'integration'}`); owner matches `/^00G/` → `SalesforceEscalationTeamsService.resolveGroupId(orgId, ownerId)` (15/18-char tolerant, `enabled`, `group_id IS NOT NULL`, exactly one) → `assignToTeam({shallowAssignment:true, actor:{type:'integration'}, trigger:'integration'})` if `inbox_id` differs; owner is a User → map → `changeAssignee(actor:{type:'integration'})` if it differs; unmapped → leave as is. Before any `changeAssignee`/`assignSessionToAi` arm `salesforce_v2:case:assignee-sync:ignore:{caseId}` so the mirror-back is consumed and skipped (`changeAssignee`'s own `assignee_id === agentId` no-op guard is the primary safety, `change-assignee.ts:44`).
  4. Existing `syncSalesforceFieldsToCustomData` call at the end stays.

No change to `changeAssignee` / `assignSessionToAi` signatures.

## 6. Echo / loop matrix

| Scenario                                                     | Path                                                                                                                         | Terminates because                                                                                                                                          |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SF agent edits Priority                                      | `case_updated` → refresh (diff: `sf_case_Priority`) → `ticket-updated`                                                       | Workflow that writes another SF field via `update-salesforce-case` re-syncs local first (`update-salesforce-case.action.ts:231-245`) → its echo has no diff |
| Workflow "Update Session Attributes" sets `sf_case_Priority` | merge (diff → `ticket-updated`) → reflect → `updateCase` → SF echo `case_updated` → refresh: no diff                         | Value already local                                                                                                                                         |
| Inbox agent edits `Priority` (editable bag)                  | push → SF echo → refresh `custom_data.sf_case_Priority` (diff → ONE `ticket-updated`, legitimately: the Case changed) → stop | Second echo has no diff                                                                                                                                     |
| OpenCX reassigns to human B                                  | outbound arms `owner-echo=B_sf` → `updateCaseOwner` → `owner_changed` echo consumed → no handoff comment / no re-mirror      | Consume-once key                                                                                                                                            |
| SF agent reassigns Case to B                                 | `owner_changed` → arm `assignee-sync:ignore` → `changeAssignee(B)` → mirror → outbound consumes → skip                       | Consume-once key + `changeAssignee` no-op guard                                                                                                             |
| AI autofill writes fields                                    | `updateCase` → echo → refresh: no diff (autofill already re-synced `salesforce-case-ai-fields.service.ts:541-554`)           | —                                                                                                                                                           |
| A `ticket-updated` workflow writes the same value back       | merge → `changedFields = []`                                                                                                 | Diff is load-bearing (same contract as `contact-updated`)                                                                                                   |

## 7. Non-breaking / v1 safety checklist

* New code lives in `backend/src/salesforce-case-integration/**`, the `SALESFORCE_V_2` arm of `syncAssigneeToThirdParty`, and `salesforce_v2`-gated `else if` arms in the two custom-data write functions. V1 (`salesforce`) hits none of them: `chat-integration.service.ts:219` keeps v1 in the no-op list; `merge-custom-data.ts` / `update-custom-data.ts` branch on `'salesforce_v2'` only.
* V1 webhook is a different controller (`/backend/salesforce-case-management/webhook`, `salesforce-integration/salesforce.controller.ts:271-324`) — the new `case_updated` type is invisible to it. MoneyGram's caseId-only contract routes through `handleCaseIdWebhook` (`salesforce-case.service.ts:356-367`), untouched.
* No change to `SalesforceService.getConnection` / token / `verifyWebhookToken` (`salesforce-integration/salesforce.service.ts:915, 1344, 1361`), to `salesforce_integration_settings` columns, to `chat_sessions.salesforce_case_id` or its unique index.
* The shared `append-note-to-ticket.ts:128-129` fall-through (v1 → V2 `addCaseCommentWithEchoGuard`) and `resolve.service.ts:364-365` are not touched.
* New behaviour is opt-in: `case_updated` requires the customer to add trigger 7; assignee sync requires the org flags (already false by default); queue routing requires `group_id` to be set; write-back only fires for keys that resolve to an updateable Case field on a `salesforce_v2` org.
* The only change to an existing path is stale `sf_case_*`/`sf_contact_*` keys now being cleared on refresh — a correctness fix, covered by tests.

## 8. Test plan (one scenario per file, local fake-Salesforce HTTP server + real Postgres/Redis, as in `salesforce-owner-and-assignee-stay-in-sync.spec.ts`; no mocks/spies)

`backend/src/salesforce-case-integration/__tests__/`

1. `case-updated-webhook-refreshes-fields-and-fires-ticket-updated.spec.ts` — `?type=case_updated` → `sf_case_*` refreshed, `salesforce_fields_updated` row for whitelisted keys, ONE `ticket-updated` run with `changedFields`/`previousValues`.
2. `case-updated-webhook-without-diff-fires-nothing.spec.ts` — echo of an unchanged Case → no run, no chat\_history row.
3. `case-updated-clears-field-nulled-in-salesforce.spec.ts` — key removed locally; `changedFields` includes it; whitelisted diff reports `new: null`.
4. `case-updated-unknown-case-and-v1-org-are-ignored.spec.ts` — no session / `ticketing_system = salesforce` → no SF read, no writes.
5. `case-updated-concurrent-webhooks-serialize-per-case.spec.ts` — two overlapping webhooks → newest snapshot wins.
6. `custom-data-merge-pushes-sf-case-keys-to-case.spec.ts` — merge action with `sf_case_Priority` → one PATCH with `{Priority}`; non-updateable / `OwnerId` / unknown keys skipped; v1 org → no PATCH.
7. `editable-custom-data-edit-pushes-to-case.spec.ts` — inbox edit `Priority` → PATCH; unchanged key → no PATCH.
8. `owner-changed-assigns-mapped-teammate-when-sync-from-on.spec.ts` — email auto-link persisted, `changeAssignee` with integration actor, no mirror-back PATCH.
9. `owner-changed-integration-user-assigns-ai.spec.ts` — owner back to integration user → `assignee_id = 555`, `ai_closure_type` cleared.
10. `owner-changed-queue-owner-routes-to-mapped-team.spec.ts` — `00G…` with `group_id` → `inbox_id` set, human assignee preserved; unmapped/disabled/ambiguous → untouched.
11. `owner-changed-legacy-behavior-when-sync-from-off.spec.ts` — flag off → exactly today's behaviour (handoff, null assignee, comment).
12. `assignee-change-writes-case-owner-when-sync-to-on.spec.ts` — human/AI/null cases; unmapped skipped; flag off → no PATCH.
13. `assignee-echo-guard-suppresses-own-owner-write.spec.ts` — outbound PATCH then `owner_changed` echo → no handoff, no comment.
14. `agent-mapping-email-match-persists-and-isolates-orgs.spec.ts` — NULL-only, cross-org isolation, `23505` tolerated.
15. `escalation-teams-group-id-crud.spec.ts` — DTO/CRUD round-trip, `ON DELETE SET NULL`.

`backend/src/workflow/definitions/triggers/ticket-updated.trigger.spec.ts` — payload shape, registry, `changedFields`/`previousValues`, no fire on empty diff (mirrors `ticket-reopened.trigger.spec.ts`).

`backend/src/chat-session/repo/merge-custom-data.spec.ts` (new scenarios) — `keysToClear` removes keys; diff → fire; no-diff → no fire; reflect false → no push.

## 9. Delivery — one PR, four commits (each green on its own)

1. `feat(workflows): add ticket-updated trigger, fired from session custom-data merges` — trigger + enum + registry + `keysToClear`/diff/fire in `merge-custom-data.ts` + `pnpm gensdk` + tests.
2. `feat(salesforce): refresh v2 Case fields on case_updated webhook` — DTO/dispatch/handler/lock, stale-key clearing, diff of removed keys, docs (Apex method + trigger 7 + test class), tests.
3. `feat(salesforce): push session attribute changes to the v2 Case` — `syncCustomDataToCase` + the two reflect arms + tests.
4. `feat(salesforce): sync Case owner with session assignee and map queues to teams` — migrations + codegen, mapping + assignee-sync services, `owner_changed` changes, `syncAssigneeToThirdParty` arm, escalation-team `group_id`, docs section, tests.

Then: close #2229 and #2223 with a pointer to the new PR; open a separate PR for "For Each Matching Session" (already-decided semantics); #2227 stays independent.

Estimated size ≈ 700–800 src lines + tests (vs 12k across the three open PRs).

## 10. Follow-ups (not in this PR)

* Poll safety-net for orgs that cannot deploy Apex: `SELECT … FROM Case WHERE SystemModstamp > :cursor` over linked open sessions, mirroring `salesforce-crm-poll-sync.worker.ts` cadence/cursor idioms.
* Manual agent-mapping endpoint + roster cache + dashboard picker (Zendesk `PUT /agents/mapping` shape); dashboard team picker for `salesforce_escalation_teams.group_id`.
* Teammate identity/avatar on inbound SF comments/emails (the dropped half of #2223).
* `case_closed` → run through the shared close path (fires `ticket-resolved`) + refresh fields.
* Expose `editable_custom_data` in the ticket payload and fire `ticket-updated` on inbox edits.
* Optional Apex `Queueable` retry wrapper for callouts, in the production checklist.

## 11. Decisions to confirm

1. Trigger name: `ticket-updated` ("Ticket Updated") vs `ticket-attributes-updated`. Recommendation: `ticket-updated`, description makes the attribute scope explicit.
2. `previousValues` on the payload — keep (cheap, enables "from X to Y") or drop for strict YAGNI. Recommendation: keep.
3. Queue → team storage: `salesforce_escalation_teams.group_id` (recommended, reuses the queue registry + CRUD) vs new `groups.salesforce_queue_id`.
4. `case_updated` refreshes Case + Contact + related objects (existing method, most current data) vs Case-only (#2229). Recommendation: reuse the existing method; add a `caseOnly` flag later only if API budget bites.
