> ## 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

# Salesforce v2 Two-Way Sync Implementation Plan

> **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`.

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Keep a Salesforce v2 Case and its OpenCX session consistent in both directions (fields, owner/assignee, queue/team) and let workflows react via a new `ticket-updated` trigger — in one minimal, v2-only, non-breaking PR.

**Architecture:** Reuse the existing seams: the session custom-data merge repo (`chatSession_repo_mergeCustomData`) becomes the single place that diffs, clears stale keys, fires `ticket-updated`, and reflects to Salesforce; the v2 webhook gains one more `type` (`case_updated`) routed to the existing field snapshot; owner↔assignee mirrors the Zendesk v2 assignee-sync shape; queue→team is a nullable FK on the existing queue registry.

**Tech Stack:** Fastify + Kysely + Zod (backend), vitest with real Postgres/Redis and a per-spec fake Salesforce HTTP server (jsforce hits it), `@opencx/workflows` trigger definitions, Mintlify docs.

Spec: `docs/superpowers/specs/2026-08-16-salesforce-v2-two-way-sync-design.md`.

## Global Constraints

* Only `TicketingSystemEnum.SALESFORCE_V_2` (`'salesforce_v2'`) paths change; v1 (`'salesforce'`) must behave identically (never edit `backend/src/salesforce-integration/**`, MoneyGram code, or the v1 arms listed in the spec §7).
* No `any`, no `as` casts (except `as const`), no non-null `!`; Zod DTOs bound with `satisfies z.ZodType<...>`.
* Existing tests must pass unmodified. One Salesforce scenario per spec file. No mocks/spies — real DB + real jsforce against a local fake Salesforce HTTP server.
* Run each spec with: `NODE_ENV=test ./node_modules/.bin/vitest run --root /Users/aziz/opencx/.claude/worktrees/salesforce-v2-two-way-sync/backend <ABS_SPEC_PATH>` (worktree DB `salesforce_v2_two_way_sync` on the throwaway container `opencx-sfwt-postgres`).
* Type-check: `./node_modules/.bin/tsgo --noEmit -p /Users/aziz/opencx/.claude/worktrees/salesforce-v2-two-way-sync/backend/tsconfig.json`; lint touched files: `./node_modules/.bin/oxlint <files>`; format: `./node_modules/.bin/oxfmt <files>`.
* Commit messages: present-tense imperative, no AI attribution. Stage explicit paths only (never `-A`).

***

### Task 1: `ticket-updated` trigger + session merge diff/clear/fire

**Files:**

* Create: `backend/src/workflow/definitions/triggers/ticket-updated.trigger.ts`
* Modify: `backend/src/workflow/enums/workflow-trigger.enum.ts` (add `TICKET_UPDATED = 'ticket-updated'`)
* Modify: `backend/src/workflow/definitions/triggers/index.ts` (import, register, export)
* Modify: `backend/src/chat-session/repo/merge-custom-data.ts` (prior read, `clearNullish`, diff, fire)
* Test: `backend/src/workflow/definitions/triggers/ticket-updated.trigger.spec.ts`
* Test: `backend/src/chat-session/repo/merge-custom-data.ticket-updated.spec.ts`
* Regenerate: `dashboard/packages/sdk/src/schema.ts` (enum literal only) — Task 6.

**Interfaces:**

* Produces: `ticketUpdatedTrigger` (`$trigger({ ticket, changedFields: string[], previousValues: Record<string, unknown> })`), `WorkflowTriggerEnum.TICKET_UPDATED`.

* Produces: `chatSession_repo_mergeCustomData({ sessionId, orgId, customData, reflectOnIntegration?, clearNullish? })` → `{ data?: { mergedCustomData, changedFields: string[] }, error? }`. `customData` values may be `null` when `clearNullish` is true (a null clears the key).

* [ ] **Step 1: trigger definition**

```ts theme={"dark"}
// backend/src/workflow/definitions/triggers/ticket-updated.trigger.ts
import { createTrigger, Field } from '@opencx/workflows';
import { WorkflowTriggerEnum } from '../../enums/workflow-trigger.enum.ts';
import { ticketPayload } from '../payloads/ticket.payload.ts';

export const ticketUpdatedTrigger = createTrigger(
  WorkflowTriggerEnum.TICKET_UPDATED,
  Field.Object({
    name: 'ticketUpdatedTrigger',
    displayName: 'Ticket Updated',
    description: "Triggered when a ticket's attributes change",
    required: true,
    refable: true,
    relname: 'none',
    properties: {
      ticket: ticketPayload,
      changedFields: Field.Array({
        displayName: 'Changed Fields',
        description:
          'The attribute keys that changed in this update (e.g. "sf_case_Status"). Gate on this to avoid re-triggering when a workflow writes attributes back to the same ticket.',
        required: true,
        refable: true,
        relname: 'none',
        itemField: Field.Text({
          displayName: 'Changed Field',
          description: 'An attribute key that changed',
          required: true,
          refable: true,
          relname: 'none',
        }),
      }),
      previousValues: Field.AnyObject({
        name: 'previousValues',
        displayName: 'Previous Values',
        description:
          'The value each changed attribute had before this update, keyed by attribute. A key that was unset before is absent.',
        required: true,
        refable: true,
        relname: 'none',
      }),
    },
  }),
  null,
  {
    title: 'Ticket Updated',
    description:
      "Triggered when a ticket's attributes change — a Salesforce Case field edited by an agent, a workflow's Update Session Attributes step, or the API. Status, assignee, team and tag changes have their own triggers. A workflow that writes attributes to the same ticket re-triggers itself, so gate the write behind a condition on \"Changed Fields\".",
    iconName: 'ticket',
    env: 'production',
  },
);
```

* [ ] **Step 2: enum + registry** — add `TICKET_UPDATED = 'ticket-updated',` after `TICKET_TEAM_CHANGED`; in `index.ts` import `ticketUpdatedTrigger`, add it after `ticketTeamChangedTrigger` in the registration array and in the export list.

* [ ] **Step 3: merge repo** — replace `backend/src/chat-session/repo/merge-custom-data.ts` with:

```ts theme={"dark"}
import { db } from '#db/db.ts';
import { runCatching } from '#utils/try.ts';
import _ from 'lodash';
import { sql } from 'kysely';
import { type z } from 'zod';
import { WorkflowTriggerPayloadUtils } from '#src/workflow/definitions/payloads/workflow-trigger-payload.utils.ts';
import { ticketUpdatedTrigger } from '#src/workflow/definitions/triggers/ticket-updated.trigger.ts';
import { workflowTrigger_service_triggerByType } from '#src/workflow/services/workflow-trigger/service/trigger-by-type.ts';
import { chatSessionCustomDataSchema } from '../dtos/chat-session.dto.ts';

type CustomDataValue = string | number | boolean;

/**
 * Merge attributes into `chat_sessions.custom_data`, then:
 *   1. fire the Ticket Updated workflow trigger with the keys that actually
 *      moved (an unchanged re-send fires nothing — that diff is what lets a
 *      workflow writing back to the same ticket terminate), and
 *   2. reflect the changed keys onto the org's ticketing system when
 *      `reflectOnIntegration` is on.
 *
 * `clearNullish` mirrors the contact repo: a null value removes the key.
 */
export async function chatSession_repo_mergeCustomData({
  sessionId,
  orgId,
  customData,
  reflectOnIntegration = true,
  clearNullish = false,
}: {
  sessionId: string;
  orgId: string;
  customData: Record<string, CustomDataValue | null | undefined> | z.infer<typeof chatSessionCustomDataSchema>;
  reflectOnIntegration?: boolean;
  clearNullish?: boolean;
}): Promise<{
  data?: { mergedCustomData?: Record<string, any> | null; changedFields: string[] };
  error?: { message: string };
}> {
  const toSet: Record<string, CustomDataValue> = {};
  const keysToClear: string[] = [];
  if (typeof customData === 'object' && customData !== null) {
    for (const key of Object.keys(customData)) {
      const value = customData[key];
      if (value === null || value === undefined) {
        if (clearNullish) keysToClear.push(key);
        continue;
      }
      if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
        toSet[key] = value;
      }
    }
  }
  const validation = chatSessionCustomDataSchema.safeParse(toSet);
  if (validation.error) {
    console.log('[mergeCustomData] Custom data is not valid', { validation, customData });
    return { error: { message: 'Custom data is not valid' } };
  }
  if (!sessionId?.trim()) return { error: { message: 'Session ID is required' } };
  if (!customData || typeof customData !== 'object' || Array.isArray(customData)) {
    return { error: { message: 'Custom data must be a valid object' } };
  }
  const serializedData = runCatching(() => JSON.stringify(toSet));
  if (serializedData.error) {
    return { error: { message: 'Custom data contains non-serializable values' } };
  }

  // Pre-read so the trigger only fires on keys that actually moved.
  const prior = await db
    .selectFrom('chat_sessions')
    .select('custom_data')
    .where('id', '=', sessionId)
    .where('copilot_id', '=', orgId)
    .executeTakeFirst();
  const priorData: Record<string, unknown> =
    prior?.custom_data && typeof prior.custom_data === 'object' && !Array.isArray(prior.custom_data)
      ? { ...prior.custom_data }
      : {};

  const merged = sql`COALESCE(custom_data, '{}'::jsonb) || ${JSON.stringify(toSet)}::jsonb`;
  const result = await db
    .updateTable('chat_sessions')
    .set({
      custom_data:
        keysToClear.length > 0 ? sql`(${merged}) - ${keysToClear}::text[]` : merged,
    })
    .where('id', '=', sessionId)
    .where('copilot_id', '=', orgId)
    .returning(['custom_data'])
    .executeTakeFirst();
  if (!result) {
    return {
      error: {
        message:
          'Failed to update session attributes - session not found or does not belong to the organization',
      },
    };
  }
  const mergedData: Record<string, unknown> =
    result.custom_data && typeof result.custom_data === 'object' && !Array.isArray(result.custom_data)
      ? { ...result.custom_data }
      : {};

  const changedFields = [
    ...Object.keys(toSet).filter((key) => !_.isEqual(priorData[key], mergedData[key])),
    ...keysToClear.filter((key) => key in priorData),
  ];
  const previousValues: Record<string, unknown> = {};
  for (const key of changedFields) if (key in priorData) previousValues[key] = priorData[key];

  if (changedFields.length > 0) {
    try {
      await workflowTrigger_service_triggerByType({
        orgId,
        triggerPayload: ticketUpdatedTrigger.$trigger({
          ticket: await WorkflowTriggerPayloadUtils.getTicketPayload({ sessionId }),
          changedFields,
          previousValues,
        }),
      });
    } catch (e) {
      console.error('Failed to trigger ticket updated workflows', { _e: e, orgId, sessionId, changedFields });
    }
  }

  if (reflectOnIntegration && changedFields.length > 0) {
    // (existing lazy-import block, unchanged for intercom/hubspot; new arm:)
    // else if (ticketingSystem === 'salesforce_v2') {
    //   const { SalesforceCaseService } = await import('#src/salesforce-case-integration/salesforce-case.service.ts');
    //   const changed: Record<string, CustomDataValue> = {};
    //   for (const key of changedFields) { const v = toSet[key]; if (v !== undefined) changed[key] = v; }
    //   await SalesforceCaseService.syncCustomDataToCase({ orgId, sessionId, changed });   // Task 3
    // }
  }

  return { data: { mergedCustomData: mergedData, changedFields } };
}
```

(Keep the exact existing intercom/hubspot reflection code; only wrap the block in `changedFields.length > 0` — a no-op merge has nothing to push. Existing callers pass whole objects and were already re-pushed unchanged data; the diff-gate is strictly less work.)

* [ ] **Step 4: tests** — `ticket-updated.trigger.spec.ts` (pattern: `ticket-reopened.trigger.spec.ts`): (a) `triggerByType` SYNC round-trips `changedFields`/`previousValues`; (b) e2e: `chatSession_repo_mergeCustomData({customData:{foo:'bar'}})` on an org with a `ticket-updated` capture workflow → 1 run, `changedFields=['foo']`, `previousValues={}`, `ticket.customData.foo==='bar'`; (c) same value again → still 1 run; (d) `clearNullish` `{foo:null}` → 2nd run, `changedFields=['foo']`, `previousValues={foo:'bar'}`, key gone. `merge-custom-data.ticket-updated.spec.ts`: return shape `changedFields`, non-clearNullish null ignored, cross-org no-op.
* [ ] **Step 5: run, tsgo, lint, format; commit** `feat(workflows): add ticket-updated trigger, fired from session attribute merges`.

***

### Task 2: `case_updated` webhook → field refresh (+ stale-key clearing)

**Files:**

* Modify: `backend/src/salesforce-case-integration/dto/salesforce-case-webhook.dto.ts` (`'case_updated'` in enum)
* Modify: `backend/src/salesforce-case-integration/salesforce-case.service.ts` (`dispatchWebhook` case; `handleCaseUpdated`; `syncSalesforceFieldsToCustomData` stale-key clearing; `notifyAllowedFieldsChanged` union keys)
* Modify: `backend/src/salesforce-case-integration/salesforce-case.controller.ts` docstring list of canonical names
* Modify: `docs/integrations/salesforce/email-cases.mdx` (Apex method + trigger 7 + test-class method + intro count "seven")
* Test: `backend/src/salesforce-case-integration/__tests__/case-updated-webhook-refreshes-fields-and-fires-ticket-updated.spec.ts`
* Test: `backend/src/salesforce-case-integration/__tests__/case-updated-webhook-without-diff-fires-nothing.spec.ts`
* Test: `backend/src/salesforce-case-integration/__tests__/case-updated-clears-field-nulled-in-salesforce.spec.ts`
* Test: `backend/src/salesforce-case-integration/__tests__/case-updated-unknown-case-and-v1-org-are-ignored.spec.ts`

**Interfaces:** Produces `SalesforceCaseService.handleCaseUpdated(orgId, caseId): Promise<void>`.

* [ ] **Step 1: DTO** — add `'case_updated'` to `SalesforceCaseEventType`.
* [ ] **Step 2: dispatch** — new case in `dispatchWebhook`, same fire-and-forget shape as `owner_changed`, message `'Case updated event received'`.
* [ ] **Step 3: handler**

```ts theme={"dark"}
  // ── Case fields changed externally ───────────────────────────────

  /**
   * `case_updated` — any Case field changed in Salesforce (the generic
   * `after update` trigger). Refreshes the session's `sf_case_*` snapshot so
   * attributes stop going stale between lifecycle events; the merge itself
   * fires the Ticket Updated workflow trigger when anything actually moved.
   * Serialized per Case: a later webhook may carry the newest state, so it
   * waits for an in-flight refresh instead of being dropped.
   */
  static async handleCaseUpdated(orgId: string, caseId: string): Promise<void> {
    const session = await db
      .selectFrom('chat_sessions')
      .select(['id', 'contact_id'])
      .where('salesforce_case_id', '=', caseId)
      .where('copilot_id', '=', orgId)
      .executeTakeFirst();
    if (!session) {
      console.log('[SalesforceCase] case_updated for unknown case, ignoring', { orgId, caseId });
      return;
    }
    const lockKey = `salesforce_v2:case:fields-refresh:${orgId}:${caseId}`;
    let token = await RedisLock.acquire({ key: lockKey, ttlSeconds: CASE_FIELDS_REFRESH_LOCK_TTL_SECONDS });
    if (token === null) {
      await RedisLock.waitForRelease({ key: lockKey, maxWaitMs: CASE_FIELDS_REFRESH_LOCK_TTL_SECONDS * 1000 });
      token = await RedisLock.acquire({ key: lockKey, ttlSeconds: CASE_FIELDS_REFRESH_LOCK_TTL_SECONDS });
      if (token === null) {
        console.warn('[SalesforceCase] case_updated refresh still locked, skipping', { orgId, caseId });
        return;
      }
    }
    try {
      await this.syncSalesforceFieldsToCustomData({ orgId, sessionId: session.id, caseId });
    } finally {
      await RedisLock.release({ key: lockKey, token });
    }
  }
```

(`const CASE_FIELDS_REFRESH_LOCK_TTL_SECONDS = 60;` next to the other consts; import `RedisLock` from `#utils/redis-lock.ts` — check `waitForRelease`'s exact parameter names in `backend/src/utils/redis-lock.ts` before writing.)

* [ ] **Step 4: stale-key clearing in `syncSalesforceFieldsToCustomData`** — always read the prior session bag (drop the `notifyOnFieldChange ?` guard around it), then:

```ts theme={"dark"}
      const staleCaseKeys = Object.keys(
        priorSessionCustomData && typeof priorSessionCustomData === 'object' ? priorSessionCustomData : {},
      ).filter((key) => key.startsWith(SF_CASE_CUSTOM_DATA_PREFIX) && !(key in caseCustomData));
      if (Object.keys(caseCustomData).length > 0 || staleCaseKeys.length > 0) {
        const result = await chatSession_repo_mergeCustomData({
          orgId,
          sessionId,
          customData: { ...caseCustomData, ...Object.fromEntries(staleCaseKeys.map((k) => [k, null])) },
          reflectOnIntegration: false,
          clearNullish: true,
        });
```

Same for `sf_contact_*` on the contact merge (`contacts_repo_mergeCustomData({... clearNullish: true})` with the stale contact keys nulled).

* [ ] **Step 5: `notifyAllowedFieldsChanged`** — iterate `new Set([...Object.keys(filteredBefore ?? {}), ...Object.keys(filteredAfter ?? {})])`; return early only when both are empty.
* [ ] **Step 6: docs** — Apex class: add `sendCaseUpdatedEvents` (clone of `sendCaseOwnerChangedEvents`, `&type=case_updated`). Trigger list: "seven triggers"; add **7. Case trigger (any field change)**:

```apex theme={"dark"}
trigger OpenCxCaseUpdatedTrigger on Case (after update) {
    // Skip OpenCX's own writes (autofill, workflow actions, owner sync) — the
    // integration user is the DML user for those, and OpenCX already holds
    // that state. Set to the exact Username OpenCX authenticated as.
    String INTEGRATION_USERNAME = 'opencx-integration@yourcompany.com';
    if (UserInfo.getUserName() == INTEGRATION_USERNAME) return;
    List<Id> caseIds = new List<Id>();
    for (Case c : Trigger.new) caseIds.add(c.Id);
    if (!caseIds.isEmpty()) {
        OpenSalesforceCaseManagement.sendCaseUpdatedEvents(caseIds);
    }
}
```

Prose: refreshes the session's Salesforce attributes and fires the **Ticket Updated** workflow trigger; keep triggers 4/5 for owner/close semantics. Add `sendCaseUpdatedEvents` to the test class alongside the others.

* [ ] **Step 7: tests** (fake SF server per spec: oauth token, `/id/`, `sobjects/Case/describe` with a small field list, `GET sobjects/Case/{id}` returning `this.records.get(id)`, `PATCH` 204, `query` User/Case). Drive through `SalesforceCaseService.handleWebhook(token, {caseId, event_type:'case_updated'})` where `token = SalesforceService.createWebhookToken(orgId)`. Assert `custom_data.sf_case_*`, `salesforce_fields_updated` rows (whitelist a key first via `AiContextCustomDataFiltersRepo`), workflow runs.
* [ ] **Step 8: run, tsgo, lint, format; commit** `feat(salesforce): refresh v2 Case fields on the case_updated webhook`.

***

### Task 3: session attribute → Case field write-back

**Files:**

* Modify: `backend/src/salesforce-case-integration/salesforce-case.service.ts` (`syncCustomDataToCase`)
* Modify: `backend/src/chat-session/repo/merge-custom-data.ts` (the `salesforce_v2` reflect arm from Task 1's comment)
* Modify: `backend/src/chat-session/service/update-custom-data.ts` (`salesforce_v2` arm)
* Test: `backend/src/salesforce-case-integration/__tests__/custom-data-merge-pushes-sf-case-keys-to-case.spec.ts`
* Test: `backend/src/salesforce-case-integration/__tests__/editable-custom-data-edit-pushes-to-case.spec.ts`

**Interfaces:** Produces `SalesforceCaseService.syncCustomDataToCase({ orgId, sessionId, changed: Record<string, string|number|boolean> }): Promise<void>` (never throws).

* [ ] **Step 1: service**

```ts theme={"dark"}
  /**
   * OpenCX → Salesforce write-back for session attributes. A key that names an
   * updateable Case field — `sf_case_<Field>` (the snapshot namespace) or the
   * bare `<Field>` (an agent-typed attribute) — is written to the Case; every
   * other key is ignored. `OwnerId` is owned by assignee sync and never written
   * here. Best-effort: logs and returns on any failure.
   */
  static async syncCustomDataToCase(args: {
    orgId: string;
    sessionId: string;
    changed: Record<string, string | number | boolean>;
  }): Promise<void> {
    const { orgId, sessionId, changed } = args;
    try {
      const org = await db.selectFrom('chatbots').select('ticketing_system').where('id', '=', orgId).executeTakeFirst();
      if (org?.ticketing_system !== TicketingSystemEnum.SALESFORCE_V_2) return;
      const session = await db.selectFrom('chat_sessions').select('salesforce_case_id')
        .where('id', '=', sessionId).where('copilot_id', '=', orgId).executeTakeFirst();
      const caseId = session?.salesforce_case_id;
      if (!caseId) return;
      const updateable = new Map(
        (await SalesforceCaseSDK.describeUpdateableCaseFields(orgId)).map((f) => [f.name, f.type]),
      );
      const record: Record<string, string | number | boolean> = {};
      for (const [key, value] of Object.entries(changed)) {
        const field = key.startsWith(SF_CASE_CUSTOM_DATA_PREFIX) ? key.slice(SF_CASE_CUSTOM_DATA_PREFIX.length) : key;
        const type = updateable.get(field);
        if (!type || field === 'OwnerId') continue;
        if (type === 'boolean') {
          const lowered = String(value).trim().toLowerCase();
          if (lowered !== 'true' && lowered !== 'false') continue;
          record[field] = lowered === 'true';
        } else if (type === 'int' || type === 'double' || type === 'currency' || type === 'percent') {
          const numeric = typeof value === 'number' ? value : Number(String(value).trim());
          if (String(value).trim() === '' || Number.isNaN(numeric)) continue;
          record[field] = numeric;
        } else {
          record[field] = String(value);
        }
      }
      if (Object.keys(record).length === 0) return;
      await SalesforceCaseSDK.updateCase(orgId, caseId, record);
      // Keep the snapshot current without waiting for the Apex echo (which the
      // documented trigger skips for the integration user's own writes).
      await this.syncSalesforceFieldsToCustomData({ orgId, sessionId, caseId });
    } catch (e) {
      console.error('[SalesforceCase] Failed to push session attributes to the Case', {
        orgId, sessionId, keys: Object.keys(changed), _e: e instanceof Error ? e.message : String(e),
      });
    }
  }
```

* [ ] **Step 2: reflect arms** — merge repo (Task 1 comment → real code); `update-custom-data.ts`: after the hubspot branch add `else if (ticketingSystem === 'salesforce_v2')` computing `changed` exactly like the hubspot branch and calling `SalesforceCaseService.syncCustomDataToCase({ orgId: session.copilot_id, sessionId, changed })` (lazy import — `chat-session/service` must not statically import the SF service).
* [ ] **Step 3: tests** — (a) `merge-ticket-custom-data` action or direct `chatSession_repo_mergeCustomData({customData:{sf_case_Priority:'High', sf_case_Nope:'x', sf_case_OwnerId:'005…'}})` → exactly one `PATCH sobjects/Case/{id}` whose body is `{"Priority":"High"}`; v1 org → no PATCH; `reflectOnIntegration:false` → no PATCH; boolean/number coercion. (b) `chatSession_service_updateCustomData({sessionId, body:{editable_custom_data:{Priority:'Low'}}})` → PATCH `{"Priority":"Low"}`, unchanged re-save → no second PATCH.
* [ ] **Step 4: run, tsgo, lint, format; commit** `feat(salesforce): push session attribute changes to the v2 Case`.

***

### Task 4: agent mapping, assignee sync, queue → team

**Files:**

* Create: `backend/src/db/kysely-migrations/20260816090000_add_salesforce_user_mapping.ts`
* Create: `backend/src/db/kysely-migrations/20260816090001_add_salesforce_user_mapping_index.ts` (`noTransaction`, CONCURRENTLY)
* Create: `backend/src/db/kysely-migrations/20260816090002_add_salesforce_escalation_teams_group_id.ts`
* Modify: `backend/src/db/opencx.ts` (codegen: `ChatbotUsers.salesforce_user_id: string | null`, `SalesforceEscalationTeams.group_id: string | null`)
* Create: `backend/src/salesforce-case-integration/salesforce-agent-mapping.service.ts`
* Create: `backend/src/salesforce-case-integration/salesforce-assignee-sync.service.ts`
* Modify: `backend/src/salesforce-case-integration/salesforce-case-sdk.ts` (`getUserById`, `findActiveUsersByEmail`)
* Modify: `backend/src/salesforce-case-integration/salesforce-case.service.ts` (`handleOwnerChanged`)
* Modify: `backend/src/chat/chat-integration.service.ts` (`SALESFORCE_V_2` arm)
* Modify: `backend/src/salesforce-case-integration/dto/salesforce-escalation-team.dto.ts`, `salesforce-escalation-teams.service.ts` (`group_id` + `resolveGroupIdForQueue`)
* Modify: `docs/integrations/salesforce/email-cases.mdx` (short "Assignee and queue sync" section)
* Tests: `salesforce-agent-mapping-links-by-email-and-isolates-orgs.spec.ts`, `owner-changed-assigns-mapped-teammate-when-sync-from-on.spec.ts`, `owner-changed-integration-user-assigns-ai.spec.ts`, `owner-changed-queue-owner-routes-to-mapped-team.spec.ts`, `owner-changed-legacy-behavior-when-sync-from-off.spec.ts`, `assignee-change-writes-case-owner-when-sync-to-on.spec.ts`, `assignee-echo-guard-suppresses-own-owner-write.spec.ts`, `salesforce-escalation-teams-group-id.spec.ts`

**Interfaces:**

* `SalesforceAgentMappingService.resolveOpenCxUserId({orgId, salesforceUser}) → number|null`, `resolveSalesforceUserId({orgId, openCxUserId}) → string|null`.

* `SalesforceAssigneeSyncService.syncAssigneeWithSalesforce({orgId, sessionId, openCxAssigneeId})`, `.syncAssigneeFromSalesforce({orgId, caseId, salesforceOwnerId, integrationUserId}) → {sessionId|null}`, `.isInboundEnabled(orgId)`, `.consumeOwnerEcho(orgId, caseId, ownerId) → boolean`.

* `SalesforceEscalationTeamsService.resolveGroupIdForQueue(orgId, queueId) → string|null`.

* Redis keys: `salesforce_v2:case:assignee-sync:ignore:{caseId}` (inbound arms / outbound consumes), `salesforce_v2:case:owner-echo:{caseId}` = ownerId (outbound arms / inbound consumes). TTL 15s.

* [ ] **Step 1: migrations** (shapes from spec §5D; `group_id character varying(36) REFERENCES public.groups(id) ON DELETE SET NULL`, index on `(org_id, group_id)`), apply with `NODE_ENV=test ./node_modules/.bin/tsx src/db/migrator.ts up`, then codegen: `DATABASE_URL=postgres://postgres:postgres@localhost:5432/salesforce_v2_two_way_sync ./node_modules/.bin/kysely-codegen --runtime-enums --include-pattern 'public.*' --out-file=src/db/opencx.ts && ./node_modules/.bin/oxfmt src/db/opencx.ts` (verify the diff is exactly the two columns).

* [ ] **Step 2: SDK** — `getUserById(orgId, userId)` (`SELECT Id, Name, Email, Username FROM User WHERE Id = '…' LIMIT 1`), `findActiveUsersByEmail(orgId, email)` (`… WHERE IsActive = true AND Email = '<escaped>' LIMIT 2`, escape via `SalesforceCrmUtils.escapeSoqlValue`).

* [ ] **Step 3: mapping service** — the two resolvers + private `findMappedOpenCxUserId`, persist NULL-only, tolerate `23505` (code from #2223 minus identity/avatar).

* [ ] **Step 4: assignee sync service** — Zendesk shape (`getOrgSyncSettings`, `isInboundEnabled`, `isOutboundEnabled`, `consumeOwnerEcho`, `syncAssigneeWithSalesforce`, `syncAssigneeFromSalesforce`); actor `{ type: 'integration' }`; lazy-import `change-assignee.ts` / `assign-session-to-ai.ts` / `assign-to-team.ts`.

* [ ] **Step 5: `handleOwnerChanged`** — per spec §5D: echo consume first; `HANDED_OFF` early return only when inbound sync is off; handoff write keeps a human assignee for a Queue owner; then `syncAssigneeFromSalesforce`; existing field sync at the end.

* [ ] **Step 6: `syncAssigneeToThirdParty` arm** — `case TicketingSystemEnum.SALESFORCE_V_2:` lazy import + call; remove it from the no-op list.

* [ ] **Step 7: escalation teams `group_id`** — DTO fields (`group_id: z.string().nullable()` on read; `.nullable().optional()` on create/update), service validates the group belongs to the org and is not deleted (else `BadRequestException`), `resolveGroupIdForQueue` (15/18-char tolerant, `enabled`, `group_id IS NOT NULL`, exactly one, group `deleted_at IS NULL`).

* [ ] **Step 8: docs section**; **Step 9: tests**; **Step 10: run, tsgo, lint, format; commit** `feat(salesforce): sync Case owner with session assignee and map queues to teams`.

***

### Task 5: SDK regen, full touched-spec run, PR

* [ ] `cd backend && NODE_ENV=development CI_CHAT_AGENT_VERSION= node --experimental-transform-types scripts/dump-openapi-spec.ts` then regenerate `dashboard/packages/sdk/src/schema.ts` with `openapi-typescript` (from an installed copy) — commit only if the diff is the enum literal(s) + `group_id`/`salesforce_user_id` DTO lines; otherwise hand-edit those lines.
* [ ] Re-run every spec touched or added; run `salesforce-owner-and-assignee-stay-in-sync.spec.ts`, `sync-diff-notifies-ai-context-allowed-field-changes.spec.ts`, `salesforce-case-custom-object-sync.spec.ts`, `webhook-event-type-mapping.spec.ts`, `salesforce-escalation-teams.spec.ts`, `contact-updated`-adjacent specs unchanged.
* [ ] `git log --name-only origin/main..HEAD` sanity (no vendored dirs), push, `gh pr create` with a short imperative body; link the spec; note #2229/#2223 supersession.
