# Salesforce

Source module: `app/connectors/salesforce.py`. Kind: **facts** only. Display name: "Salesforce".

## Purpose

Reads Case status history and Opportunity stage history through the REST/SOQL API so that resolution-type claims (from Agentforce exports or any vendor) and lead/opportunity outcomes can be checked against the CRM.

## Auth and scopes

| Item | Value |
|---|---|
| Method | Connected app access token, sent as `Authorization: Bearer`, against the org's `instance_url`; API version `v60.0` |
| Credentials stored (encrypted) | `instance_url`, `access_token` |
| Least-privilege scopes | OAuth scope **`api`** (read). Assign a profile or permission set with **read** on `Case`, `CaseHistory`, `Opportunity`, `OpportunityHistory`, `OpportunityContactRole`, and field-level read on `Contact.Email`. No create/edit/delete permissions. |
| Connection test | `GET /services/data/v60.0/limits` (remaining daily API requests recorded in `health`) |
| Non-secret config | none |

## Fields pulled and why

| SOQL | Fields | Used for |
|---|---|---|
| `Case WHERE LastModifiedDate > :since` | `Id`, `Status`, `CreatedDate`, `ClosedDate`, `Origin`, `Contact.Email`, `LastModifiedDate` | Subject id, channel (`Origin`), hashed contact email, watermark |
| `CaseHistory WHERE CaseId = :id AND Field = 'Status'` | `OldValue`, `NewValue`, `CreatedDate` | Status-change and reopen facts |
| `Opportunity WHERE LastModifiedDate > :since` with subqueries `OpportunityHistories` and `OpportunityContactRoles WHERE IsPrimary = true LIMIT 1` | `Id`, `StageName`, `CloseDate`, `LastModifiedDate`; history `StageName`, `CreatedDate`; the primary contact role's `Contact.Email` | Opportunity-stage facts, hashed contact email. Opportunity itself has no Contact lookup; the primary contact role is the customer. |

## What is hashed / never stored

- `Contact.Email` (the Case contact; the Opportunity's primary contact role) → `customer_hid` (HMAC-SHA256, per-tenant key); the email is discarded. An opportunity with no primary contact role carries no `customer_hid`.
- Case subject, description, comments, feed items, contact names/phones, and any other field are neither queried nor stored.
- The access token is stored only as ciphertext.

## Incremental sync and watermark

- Both queries filter on `LastModifiedDate > <watermark>` (ISO-8601, `1970-01-01T00:00:00Z` on first sync). SOQL pagination follows `nextRecordsUrl` until `done`.
- One `CaseHistory` query is issued per modified case.
- Watermark = maximum Case `LastModifiedDate` seen (opportunity timestamps do not advance it).
- Upserts are idempotent; re-syncing a record refreshes its facts.

## Rate-limit handling

Shared `HttpClient` (Retry-After, exponential backoff on `429`/`5xx`, 6 attempts, immediate `reauth` failure on `401`/`403`). Salesforce enforces a daily API request allocation; `test_connection` reports the remaining count so an admin can size backfills.

## Fact types produced

| `fact_type` | Subject / kind | When | Attributes | `raw_ref` |
|---|---|---|---|---|
| `status_change` | `sf_case_{Id}` / `ticket` | Each `CaseHistory` row for `Status` | `from`, `to` (lower-cased), `actor: "agent"`, `customer_hid`, `channel: <Origin>` | `salesforce:casehistory:{Id}:{i}` |
| `reopen` | `sf_case_{Id}` / `ticket` | A status history row moving from `closed` to any other status | `customer_hid`, `channel` | `salesforce:casehistory:{Id}:{i}:reopen` |
| `opportunity_stage` | `sf_opp_{Id}` / `opportunity` | Each `OpportunityHistory` row | `stage: <StageName>`, `customer_hid` | `salesforce:opphistory:{Id}:{i}` |

## Claims produced

None. Salesforce Agentforce usage/claims are not exported by this connector; supply them via CSV upload mapped to `salesforce_agentforce_conversation` (or another contract), using subject ids that match the fact subject ids above or that resolve through identity links.

## Subject id format

`sf_case_{CaseId}` and `sf_opp_{OpportunityId}` (15/18-character Salesforce ids as returned by the API).

## Known limitations

- **No `customer_message`, `human_reply`, `escalation` or `csat_response` facts.** Case comments, email messages and owner changes are not read. The `salesforce_agentforce_conversation` vendor lens (`customer_message_within`) therefore cannot be satisfied from this connector alone; conversation facts must come from a help-desk connector or a CSV upload linked by hashed email.
- Case `Origin` is used as the channel verbatim (e.g. `Web`, `Phone`, `Email`); contract exclusions match case-insensitively.
- Reopen detection only considers the literal `closed` status; orgs with custom closed statuses need a CSV mapping or a contract tweak.
- One history query per case (N+1); large backfills consume API allocation.
- Access tokens expire; the connector does not refresh them. Re-authenticate with a new token when `reauth` is reported, or use a long-lived integration user token.
