# Salesforce v63 Public API Contract

This document is the authoritative reference for how NexusCRM preserves Salesforce v63.0 wire-shape parity in its public HTTP surface. It is binding for any code under `backend/internal/interfaces/rest/`, any Salesforce-compatible CRM dialect/app-pack endpoint, and any UI API endpoint, and is cited from [`CLAUDE.md`](../../CLAUDE.md) and [`AGENTS.md`](../../AGENTS.md).

If you are about to change a response shape, an error envelope, a pagination locator, or a permission gate on a public endpoint, **read the [Verification Checklist](#verification-checklist) first.** The [Common Pitfalls](#common-pitfalls) catalog records the cases where we got it wrong and how to avoid repeating them.

---

## Why this document exists

In a recent batch of contributions (May 2026, issues #199–#214), the Codex review gate **blocked 10 of 12 PRs** on a single systemic theme: locally-correct code changes that broke Salesforce v63.0 public API parity. The findings spanned ten distinct surfaces — folder shares and children, query pagination, relevant items, the error envelope, Bulk v2, Composite, QuickActions, the `_System_StandardAction` admin path, batch record-access FLS, and formula validation.

Each PR re-derived the contract from the local code and each got it wrong in plausible-looking ways: replacing the analytics folder envelope with the SOQL `records`/`done`/`nextRecordsUrl` shape; emitting `apierr` legacy codes (`InvalidRequest`, `ResourceNotFound`) inside a v63-shaped error array; switching a user-facing batch endpoint from `RetrieveMany` to `GetManyRaw` and leaking record existence; validating active formula fields with the frozen-formula syntax gate instead of the formula-view runtime. The shared root cause was the absence of a consolidated reference for what the contract requires and a checklist for verifying any change preserves it.

[`CLAUDE.md`](../../CLAUDE.md) already states "Salesforce API v63.0 is the external product oracle" but stops at the assertion. This document is the next layer: the operational contract that a contributor is expected to follow.

---

## Authority hierarchy

When you need to know what a public endpoint should return, consult sources in this order. Higher-numbered sources are reference material, not a license to deviate from lower-numbered ones.

1. **Primary — developer.salesforce.com v63.0 documentation.** This is the binding spec. Every public response shape, error code, pagination locator, and permission gate must match what Salesforce documents for v63.0. Cite the docs URL in any code comment, commit message, or PR description that introduces or changes a response shape. Section starting points:

   - REST API Developer Guide — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/`
   - UI API Developer Guide — `https://developer.salesforce.com/docs/atlas.en-us.254.0.uiapi.meta/uiapi/`
   - Tooling API — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_tooling.meta/api_tooling/`
   - Bulk API 2.0 — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_asynch.meta/api_asynch/`
   - Composite Resources — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/resources_composite.htm`
   - Analytics Folders — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_analytics.meta/api_analytics/`
   - Actions — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_action.meta/api_action/`
   - OAuth / SOAP-Login — `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/intro_oauth_and_connected_apps.htm`

2. **Secondary — typed envelopes already in this codebase.** Several Salesforce-shaped response and error structures have already been modeled correctly in Go, often after a prior parity bug was caught. Reuse the current owner for the surface you are changing; do not invent parallel shapes.

   - `github.com/nexuscrm/dialect-sf-crm` — the CRM dialect owns the Salesforce-compatible v63 route partition and wire projections for moved CRM surfaces. Describe DTOs and projections live in `describe_dtos.go`, `describe.go`, and `describe_layouts.go`; the v63 error codec for dialect-owned routes lives under `wire/`.
   - `backend/pkg/apierr` — the remaining backend REST residues reuse `SFError`, `SFErrorResponse`, `SFGenericInternalErrorResponse`, `SFErrorWithFields`, and the `SFCode*` constants. The constants encode v63 wire codes (`INVALID_REQUEST`, `NOT_FOUND`, `INSUFFICIENT_ACCESS`, etc.); the legacy `apierr.Error.Code` values (`InvalidRequest`, `ResourceNotFound`) are *not* the wire codes and must be mapped before being emitted in a v63-shape array.
   - `backend/internal/interfaces/rest/helpers.go` — `RespondAppError` is the remaining backend REST bridge into `apierr` / `GlobalErrorHandler` for deliberate L0 residues such as backend metadata authoring shims. CRM dialect and AppService routes should use the dialect-owned `wire` codec rather than adding new backend REST helpers.
   - `backend/internal/bootstrap/api_contracts.json` — structured JSON definitions for query/sub-query envelopes.

3. **Tertiary — this document's [Common Pitfalls](#common-pitfalls) catalog.** Cases the codebase had to fix after the fact. Each entry names the surface, the failure pattern, the case-study PR number, and the correct shape.

When the codebase intentionally diverges from Salesforce v63 — usually for a documented metadata-source-of-truth reason — record the carve-out under [Documented Carve-outs](#documented-carve-outs) below with rationale and scope. An undocumented divergence is a parity bug, not a feature.

---

## Surfaces NexusCRM exposes

The table below maps each Salesforce-modeled surface to the implementation that owns it and the v63.0 docs section that binds it. New handlers added to the codebase should be appended to this list in the same PR.

| Surface | Salesforce v63.0 reference | Implemented at |
|---|---|---|
| SObject CRUD / describe / listviews | `api_rest/dome_sobject_basic_information.htm`, `dome_sobject_describe.htm` | `github.com/nexuscrm/dialect-sf-crm` route partition: `records.go`, `describe.go`, `describe_dtos.go`, `describe_layouts.go` |
| Query (SOQL) + queryMore | `api_rest/dome_query.htm`, `resources_query_more_results.htm` | `github.com/nexuscrm/dialect-sf-crm` route partition: `query.go`, `query_projection.go`, `query_errors.go`; SOQL expression substrate in `github.com/nexuscrm/dialect-sf-crm-soql` |
| Search (SOSL) + scope / parameterized / layout | `api_rest/dome_search.htm`, `dome_search_scope_order.htm` | `github.com/nexuscrm/dialect-sf-crm` route partition: `search.go` |
| Tooling API | `api_tooling/` | CRM dialect-owned routes in `github.com/nexuscrm/dialect-sf-crm`: `tooling_query.go`, `metadata_authoring*.go`, `flow_tooling.go`; deliberate backend REST residues remain in `backend/internal/interfaces/rest/tooling_*` where they delegate to neutral metadata services |
| Composite (composite, batch, collections, tree) | `api_rest/resources_composite*.htm` | `github.com/nexuscrm/dialect-sf-crm` route partition: `composite.go`, `composite_collections.go`, `tree.go` |
| Bulk API 2.0 (ingest + query) | `api_asynch/` | Bulk query is dialect-owned in `github.com/nexuscrm/dialect-sf-crm/bulk_query.go`; Bulk ingest HTTP/JSON/CSV projection is L1-owned in `nexuscrm-app-packs/appservices/sfcrm/bulk_ingest.go` and calls the neutral L0 Runtime service implemented by `backend/internal/runtimeapi/bulk_ingest_server.go` |
| Analytics Folders (folders + shares + children) | `api_analytics/analytics_api_folders*.htm` | `github.com/nexuscrm/dialect-sf-crm/folders.go` |
| UI API (records, layouts, batch record access, MRU) | `uiapi/` | `github.com/nexuscrm/dialect-sf-crm/ui_api.go`, `ui_apps.go`; the NexusCRM extension `/services/data/{version}/ui-api/related-list-candidates/{objectApiName}` is owned by `nexuscrm-app-packs/appservices/sfcrm/related_list_candidates.go`, using effective Catalog presentation facts and the existing Runtime object-list-fields read |
| Actions / QuickActions / Standard Actions | `api_action/` | `github.com/nexuscrm/dialect-sf-crm`: `quickactions.go`, `standard_actions.go`, `standard_actions_legacy.go`, `flow_actions.go` |
| OAuth / authentication | `api_rest/intro_oauth_and_connected_apps.htm` | `rest/oauth/oauth_handler.go` |
| Approval / Dashboard / Analytics endpoints | `api_rest/`, `api_analytics/` | `github.com/nexuscrm/dialect-sf-crm`: `approvals.go`, `dashboard_routes.go`, report/analytics adapters |

Internal-only routes that have no Salesforce equivalent (admin tooling specific to NexusCRM operation) are out of scope for this document, but should declare that fact in a top-of-file comment so it's clear which contract binds them.

---

## Verification checklist

Before opening or updating a PR that touches any of the surfaces above, satisfy each item below. The checklist is the bar. Codex review will block PRs that fail items 1, 3, 4, or 5.

### 1. Cite the Salesforce v63.0 docs URL for every shape you change

If you are adding, removing, renaming, or restructuring any field in a public response, link to the v63.0 docs URL that documents the canonical shape — in the commit message, the PR description, and (when the change is non-obvious) a code comment. If the existing handler does not reference a docs URL, find one on `developer.salesforce.com` *before* changing the shape.

When the docs URL appears to disagree with what an existing handler emits, that is a parity bug already in the tree, not a license to invent a third shape. Either fix the bug to match v63 in the same PR, or open a tracking issue and leave the existing shape alone.

### 2. Reuse existing typed envelopes; don't invent parallel shapes

Before defining a new response struct or writing a new `gin.H{...}` envelope, search the codebase for an existing one. The most common reusable pieces:

- Errors → CRM dialect-owned routes use `github.com/nexuscrm/dialect-sf-crm/wire`; deliberate backend REST residues use `backend/pkg/apierr`. Both owners return the v63 array shape; do not construct `[]gin.H{{"errorCode": ..., "message": ...}}` inline.
- Describe results → `describeSObjectResult` and siblings in the CRM dialect's `describe_dtos.go`, assembled by `describe.go` / `describe_layouts.go`.
- SOQL responses → the `done` / `totalSize` / `records` / `nextRecordsUrl` envelope in the CRM dialect query projection (`github.com/nexuscrm/dialect-sf-crm/query.go` and `query_projection.go`) is the canonical SOQL shape; do not reuse it for non-SOQL endpoints (folders, MRU, etc. — see Pitfalls).

Inventing a parallel struct creates a fork that future contributors will copy from, multiplying the divergence. If the existing envelope is wrong in a small way, fix it in place rather than working around it.

### 3. Map `apierr.Error` codes to `SFCode*` constants before emitting v63-shape responses

`apierr.Error.Code` is a *legacy* identifier (`InvalidRequest`, `ResourceNotFound`, `Forbidden`, `InternalServerError`). The Salesforce v63 wire codes are different (`INVALID_REQUEST`, `NOT_FOUND`, `FORBIDDEN`, `INTERNAL_ERROR`). Emitting the legacy codes inside a v63-shape error array breaks any client that keys off `errorCode`.

For dialect-owned CRM routes, go through `github.com/nexuscrm/dialect-sf-crm/wire` (`SFErrorResponse`, `SFGenericInternalErrorResponse`, `SFErrorFromDomainForVersion`, or the route-specific helpers) so the app-pack owns the Salesforce wire grammar. For deliberate backend REST residues, go through `apierr.SFErrorResponse(SFCode*, ...)`, `apierr.SFErrorFromDomainForVersion`, or `RespondAppError` / `GlobalErrorHandler`; do not construct v63 arrays inline.

### 4. Never leak internal error detail in 5xx responses

For `InternalError` and any other status ≥ 500, the public body must be a generic `INTERNAL_ERROR` message. The wrapped cause — driver names, query strings, file paths, secrets in messages, stack frames — must be **logged**, not returned to the client.

The boundary helpers must enforce this rule. CRM dialect-owned routes should return `wire.SFGenericInternalErrorResponse()` or an equivalent fixed `INTERNAL_ERROR` envelope from `github.com/nexuscrm/dialect-sf-crm/wire`. Deliberate backend REST residues should use `apierr.SFGenericInternalErrorResponse()` or delegate through `RespondAppError` / `GlobalErrorHandler` (`backend/internal/interfaces/middleware/error_handler.go`) so public status >= 500 responses become the fixed generic body while preserving full causes in server logs. Handler-local 5xx responses that bypass those helpers must do the same.

This is also why the legacy → wire-code mapping (item 3) is load-bearing: a missing mapping can cause an internal error to surface with both an unwrapped cause and a misleading `errorCode`, doubling the leak.

### 5. FLS / record-access endpoints must use `RetrieveMany`, not `GetManyRaw`

`GetManyRaw` is a metadata-layer primitive that bypasses row-level security; it is documented as not for use on user-facing paths. `RetrieveMany` enforces FLS. Any handler reachable from a user request — including batch and composite endpoints — must use `RetrieveMany`.

A common-looking optimization is to fetch all requested records via `GetManyRaw` and then return per-ID `{accessible: true|false}` entries based on a separate permission check. **This leaks record existence**: the caller learns that an ID exists in the database even when they have no read access. The only correct shape for inaccessible records is to omit them or return `not-found` — the same response a non-existent ID would produce.

---

## Common pitfalls

Every entry below is a real divergence Codex caught on a NexusCRM PR. The catalog grows as new patterns are caught.

### Search: return projection driving predicate executability

**Wrong:** Treating `fields:["Id"]` or an Id-only SOSL `RETURNING` clause as a
reason to build an Id-only search, or blindly emitting every metadata search
field as `LOWER(<readTarget>.<field>) LIKE ?` without checking whether the
selected read target actually exposes that column.

**Right:** SOSL and `parameterizedSearch` choose predicate fields from search
scope plus object metadata; the return projection only controls selected output
fields. Every generated predicate and selected output field must use the same
read-target namespace: base/readSource targets use real physical columns,
formula-view targets use logical projected aliases, and virtual compounds must
have expressible members.

Generated search-field validation is a repository-local classifier for Nexus
metadata/read-target drift, not a Salesforce-authored public error contract. It
exists only so multi-object search can skip one broken object when at least one
healthy sibling result survives. Single-object searches, all-skipped
multi-object searches, malformed `readSource`, non-search predicate SQL
failures, RLS failures, and unknown columns outside the generated search
predicate remain fail-closed execution errors and are sanitized at the REST
boundary.

**Source:** issue #1977 (Codex PRD/implementation, 2026-06-06). No Salesforce
v63 wire error is claimed for Nexus-internal generated predicate drift without
sandbox evidence.

### Folder children: SOQL envelope replacing folder collection

**Wrong:** Returning `{ "records": [...], "done": true, "nextRecordsUrl": "..." }` from `/folders/{id}/children`. That is the SOQL Query envelope.

**Right:** Salesforce models the Analytics folder children operation as `{ "folders": [...], "totalSize": int, "url": str }`. The collection key is `folders`, not `records`; pagination is via `url`, not `nextRecordsUrl`.

**Reference:** `developer.salesforce.com/docs/atlas.en-us.254.0.api_analytics.meta/api_analytics/analytics_api_folders_children.htm`

**Source:** PR #244 (Codex BLOCK, 2026-05-05).

### Folder shares: Nexus-only field names instead of the v63 share representation

**Wrong:** Returning `userId` / `userType` / `userAccessLevel` and a bare-array (or `records`-keyed) collection from `/folders/{id}/shares`.

**Right:** Salesforce documents the resource with `shareId`, `accessType`, `shareType`, `sharedWithId`, `sharedWithLabel`, and `url`, both for the collection and for a single share. Same representation in both contexts.

**Reference:** `developer.salesforce.com/docs/atlas.en-us.254.0.api_analytics.meta/api_analytics/analytics_api_folders_shares.htm`

**Source:** PR #244 (Codex BLOCK, 2026-05-05).

### queryMore: requiring clients to resend the SOQL string

**Wrong:** Building `nextRecordsUrl` as `/services/data/vXX.X/query/<locator>?q=<original SOQL>` and rejecting `QueryMore` calls that omit the `q` parameter.

**Right:** Salesforce's Query More resource is `/services/data/vXX.X/query/<queryLocator>`. The locator alone is the handle; clients pass it back as-is. The original SOQL is not a re-input.

**Reference:** `developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/resources_query_more_results.htm`

**Source:** PR #240 (Codex BLOCK, 2026-05-05).

### queryMore: treating SOQL LIMIT as the batch size

**Wrong:** Contract tests that require `SELECT Id FROM Account LIMIT 2` to return `done=false` and a `nextRecordsUrl` whenever more than two Account rows exist.

**Right:** SOQL `LIMIT` caps the total result set. Salesforce queryMore pagination is driven by the server batch size / `Sforce-Query-Options`, and any current LIMIT/OFFSET cursor smoke test must be labeled non-contract until native batch-size behavior is implemented.

**Reference:** `developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/resources_query_more_results.htm`

**Source:** PR #243 (Codex BLOCK, 2026-05-09).

### queryMore: paging GROUP BY aggregate queries

**Wrong:** Creating or honoring a `nextRecordsUrl` for `SELECT ..., COUNT(...) FROM ... GROUP BY ...`, including versioned locators that replay the original aggregate SOQL and fetch later aggregate windows.

**Right:** Salesforce REST SOQL does not support query locator pagination for queries that include `GROUP BY`. A grouped aggregate query must fit in one REST batch, optionally by using a SOQL `LIMIT`; otherwise return the Salesforce aggregate-query error instead of minting a locator.

**Reference:** `resources.docs.salesforce.com/latest/latest/en-us/sfdc/pdf/salesforce_soql_sosl.pdf`

**Source:** PR #1652 withdrawal / replacement fix (Codex, 2026-05-31).

### Relevant Items: hydrated records instead of grouped IDs

**Wrong:** Returning a flat list with `objectApiName`, `label`, and fully hydrated `records` (each with `attributes` / `Id` / `Name`).

**Right:** Salesforce groups by object: each entry has `apiName`, `key`, `label`, `lastUpdatedId`, and a `recordIds` array. Hydration is the caller's job.

**Reference:** `developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/dome_get_relevant_items.htm`

**Source:** PR #240 (Codex BLOCK, 2026-05-05).

### Error envelope: legacy `apierr` codes inside v63 array

**Wrong:** `{ "errorCode": "InvalidRequest", "message": "..." }` (or `ResourceNotFound`, `Forbidden`, `InternalServerError`) inside the v63 error array. Clients that key on the v63 wire codes (`INVALID_REQUEST`, `NOT_FOUND`, `FORBIDDEN`, `INTERNAL_ERROR`) won't recognize these.

**Right:** Map the legacy code to its `SFCode*` constant before serializing. CRM dialect-owned routes must use the dialect `wire` codec; deliberate backend REST residues must use `apierr.SFErrorResponse(...)`, `apierr.SFErrorFromDomainForVersion`, or `RespondAppError` / `GlobalErrorHandler`. Extend the single mapping path for the owning boundary when new error types are introduced; do not add a route-local parallel mapping table.

**Source:** PR #239 (Codex BLOCK, 2026-05-05).

### Error envelope: leaking wrapped causes in 500 responses

**Wrong:** Returning `err.Error()` as the public message for any `httpStatuser` error, including `pkg/errors.InternalError`. `InternalError.Error()` includes the wrapped cause — DB driver names, query fragments, file paths — which the public client should never see.

**Right:** For status ≥ 500, return a generic `INTERNAL_ERROR` message. Log the full error server-side. CRM dialect-owned routes must use the dialect `wire` codec's fixed internal-error envelope; deliberate backend REST residues must use `apierr` or `RespondAppError` / `GlobalErrorHandler` so sanitization stays on the owning boundary. Handler-local 5xx branches that bypass those helpers must build the same fixed generic body and log the underlying error themselves.

**Source:** PR #239 (Codex BLOCK, 2026-05-05).

### Formula validation: wrong validator engine

**Wrong:** Validating every persisted `_System_Field.Formula` through a
validator whose function surface does not match the runtime for that formula
mode. Historically, active formula fields were checked with the frozen
`pkg/formula` syntax gate even when the active formula-view DSL was the runtime
source of truth. A greenfield install could fail readiness with
`InvalidFormula` even though the metadata was correct for the active formula
runtime.

**Right:** Validate with the same engine the runtime evaluates against for the
actual formula context and mode. Active formula-field projections validate
through the formula-view DSL compiler; frozen formula snapshots validate
through the `pkg/formula` runtime syntax gate used by the write-time snapshot
engine. If a separate validator is needed for performance, prove its function
surface is a superset of that context's runtime, with a contract test.

**Source:** PR #233 (Codex BLOCK, 2026-05-05).

### `_System_StandardAction` admin path: removed without replacement

**Wrong:** Adding `_System_StandardAction` to `toolingOnlyTables` (blocking the normal admin `/sobjects/_System_StandardAction` create/update/delete) without providing an equivalent Tooling/Admin route for that table. Standard-action visibility (`IsActive`) becomes editable only via pack/code/DB writes.

**Right:** When tightening a public admin path, ship the replacement route in the same PR — and update any related discovery endpoint that documents how admins toggle visibility. Don't leave the metadata-driven self-service hole open. The existing `QuickActionDefinition` endpoint writes `_System_Action`, not `_System_StandardAction`, so it is *not* a valid replacement.

**Source:** PR #238 (Codex BLOCK, 2026-05-05).

### Invocable Actions: parallel discovery path that bypasses existing guards

**Wrong:** Building a new public `GET /actions` handler that reads `_System_Action` and `FlowDefinition` directly to enumerate actions and flows. That bypasses the permission/guard filtering on `/actions/standard` and the RunFlow + protected-flow filtering on `/actions/custom/flow`, exposing implementation metadata that the documented endpoints intentionally hide. It also advertises object-scoped quick actions as standard action URLs, creating a second, less restrictive source of truth for action discovery.

**Right:** The v63 Invocable Actions root at `/services/data/v63.0/actions` returns a URL object — `{ "standard": "/services/data/v63.0/actions/standard", "custom": "/services/data/v63.0/actions/custom" }`. Discovery flows through those collection URLs, each of which applies its own guards. Do not introduce a parallel discovery path that reads `_System_Action` / `FlowDefinition` directly. Quick actions are a separate surface (next pitfall), not a member of `/actions`.

**Reference:** `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/resources_actions_invocable_get.htm`

**Source:** PR #249 (Codex BLOCK, 2026-05-05).

### Quick Actions: renaming the Salesforce-compatible identifier field

**Wrong:** Changing `actionEnumOrId` to `actionEnumId` on `/quickActions/:actionName/describe` and `/sobjects/:sObject/quickActions/:actionName/describe`, and having the frontend consume the Nexus-only field name. That regresses the Salesforce-compatible quick action contract.

**Right:** Preserve `actionEnumOrId` as the wire field on both quick-action describe endpoints. The Quick Actions surface lives at `/services/data/v63.0/quickActions/` and `/services/data/v63.0/sobjects/{sObject}/quickActions/`, and the documented identifier key is `actionEnumOrId`. Do not invent a renamed parallel field even when it looks cleaner.

**Reference:** `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/quickactions.htm`

**Source:** PR #249 (Codex BLOCK, 2026-05-05).

### Batch record access: FLS leak via existence-by-ID entries

**Wrong:** `GetBatchRecordAccess` switching from `RetrieveMany` to `GetManyRaw`, then returning per-ID `HasReadAccess: false` entries for inaccessible existing records. The caller distinguishes `existing-but-inaccessible` from `non-existent` — that is record existence leakage.

**Right:** Use `RetrieveMany`. Inaccessible existing records and non-existent IDs must be indistinguishable in the response — either both omitted or both returned as not-found.

**Source:** PR #241 (Codex BLOCK, 2026-05-05).

### Composite: removing the documented 25-subrequest cap

**Wrong:** Dropping the server-side 25-subrequest limit on `POST /composite` and processing arbitrary-length requests in chunks. That changes a documented Salesforce contract and lets a single external request fan out into an unbounded number of internal router dispatches — both a wire-shape regression and a resource-exhaustion risk.

**Right:** Keep the hard 25-subrequest cap on `/composite`. Callers that need more must chunk client-side or use a separate bulk / async API.

**Reference:** `https://developer.salesforce.com/docs/atlas.en-us.254.0.api_rest.meta/api_rest/resources_composite_composite.htm`

**Source:** PR #247 (Codex BLOCK, 2026-05-05).

### Composite / batch: double-encoding sub-request URLs

**Wrong:** Rewriting every sub-request URL by parsing into `url.URL.Path`, escaping each segment, and re-assigning the escaped text back to `Path`. Go stores `URL.Path` decoded and `URL.String()` escapes it again on the way out, so already-valid encoded values (external IDs with spaces or percent escapes) get double-encoded before internal dispatch. The same anti-pattern was present in both `composite_handler.go` and `composite_batch_handler.go`.

**Right:** Treat sub-request paths as already-encoded inputs. If you need to validate or normalize, use `url.URL.RawPath` (which holds the encoded form) or operate on the raw string before parsing into a `URL`. Round-tripping decoded `Path` is the bug.

**Source:** PR #247 (Codex BLOCK, 2026-05-05).

---

## Documented carve-outs

NexusCRM may intentionally diverge from Salesforce v63 when the divergence is metadata-driven and explicitly justified. Each carve-out below names the surface, the rationale, and the scope. An undocumented divergence is a parity bug.

### Standard Actions: no-DML create preview

**Surface:** `POST /services/data/v63.0/actions/standard/{name}/preview`
served by `backend/internal/interfaces/rest/standard_action_dispatcher.go`.

**Carve-out:** NexusCRM exposes a Nexus-specific sibling of the standard action
execute endpoint that returns a no-DML preview for one registry-backed,
Flow-owned create action input.

**Rationale:** Salesforce v63 standard actions define action execution, but do
not expose a server-authoritative create-form preview for metadata-driven
Flow-owned copy/inherit-on-create. NexusCRM needs this endpoint so create UIs
can render the parent and child create images resolved by the same action
schema, input guards, field defaults, Flow `recordCreate` pipeline, field
mappings, and read projection that final execution will use. Keeping this
server-side avoids frontend-only mapping rules and preserves the metadata-driven
L0/L1 boundary described by issue #979.

**Request shape:** singular only.

```json
{ "input": { "sourceRecordId": "..." } }
```

Batch preview is intentionally not part of this contract until the no-DML
runtime has isolated synthetic-record state for every input.

**Response shape:** one object with `actionName`, `isSuccess`, optional
`errors`, optional `parentPreview`, and optional `childPreview`. Preview fields
must be projected through the caller's normal object read and field visibility
checks before serialization. Preview uses `200` with `isSuccess=false` for
Flow/action validation failures, `400 INVALID_REQUEST` for unsupported preview
shapes, and generic `500 INTERNAL_ERROR` responses for infrastructure failures.
Internal error detail must be logged server-side and must not be returned to
the client.

**Scope:** Registry-backed standard actions only. Legacy Go-backed standard
actions do not have a preview contract. The endpoint must publish/execute only
Flow shapes that the no-DML runtime can preview faithfully; unsupported
lookup-after-create and synthetic-read-overlay shapes remain fail-closed until
their follow-up substrate has projection/provenance parity.

---

### UI API Layout: generic record-state presentation rules

**Surface:** UI API Layout response served via
`dialect-sf-crm/ui_api.go::transformLayout`

**Field added:** `Layout.recordStates[]` — generic, application-neutral
record-state presentation rules (banner, optional quick links, per-state
action overrides, and a detail-level read-only override).

**Rationale:** Salesforce v63 UI API Layout has no equivalent contract for
record-state banners, state-scoped read-only override, or per-state action
suppression keyed by stable action identifiers. Without this NexusCRM
extension, app packs (CRM, Jira-like, ITSM, ERP, etc.) cannot express
state-based presentation through layout metadata, forcing application-shaped
branches into L0 platform code in violation of the L0/L1 boundary.

**Reuse check:** `RecordType` is identity, not presentation.
`ApprovalProcess` lifecycle is workflow, not detail-host suppression.
Validation rules block writes, not presentation. Per-action overrides on
`ActionConfig` are not per-state.

**Scope:** Layout response only. Other UI API endpoints (describe, recent
items, etc.) are unchanged. The field is omitted from the wire response
when the layout has no record-state rules (`omitempty`).

---

### UI API Layout: per-mode authored field behavior

**Surface:** UI API Layout response served via
`dialect-sf-crm/ui_api.go::transformLayout`

**Divergence:** a layout section may carry `fieldBehaviors`, a map of field API
name → mode key (`create` / `edit`) → `shown` / `hidden` / `required`. The
projection applies it as **authored ∩ FLS**, with FLS as the floor: authoring
can only narrow what survives field-level security, never widen it. `hidden` is
realized as the item's **absence** from the response; `required` sets the item's
existing `required` flag and only ever widens.

**Rationale:** Salesforce layout items carry a single per-item
`behavior ∈ {Required, Edit, Readonly}`, so a v63 org cannot express "shown on
the detail page, absent from the create form" — the case NexusCRM#3506 exists to
solve. Reusing the existing `required` flag rather than inventing a field keeps
the wire shape identical to v63; only the value can differ per mode, and the
layout response is already fetched per mode, so no client parses anything new.

**Reuse check:** SF's own per-item behavior is the closest primitive and is
reused where it fits (`required`). A second `Type='Create'` layout — the other
way an org could approximate this — was rejected: it doubles the drift surface
and needs its own assignment machinery. FLS cannot express it (it is not a
security fact, and it must not be, or hiding a field from a form would revoke
API access to it). Validation rules block writes, not presentation.

**Two divergences worth naming explicitly:**

1. **`required` becomes mode-dependent.** A client reading
   `?type=Create` and `?type=Edit` can see different `required` values for one
   field, which no Salesforce org produces. Shape is unchanged; meaning is
   narrower per mode.
2. **`hidden` overloads absence.** Salesforce uses an item's absence to mean
   *not readable* (FLS), and `editableForNew`/`editableForUpdate: false` for
   present-but-not-writable. Authored `hidden` therefore reuses the signal SF
   reserves for no-read-access, and a client inferring FLS by diffing layout
   items against `describe` would misread an authored-hidden field as
   FLS-hidden. Absence was chosen anyway because the form drops items
   individually and only collapses a row once every item in it is gone: a flag
   would leave half-empty rows on exactly the layouts this feature exists to
   condense.

**Scope:** UI API Layout response only, and only the `create` and `edit` mode
keys. The `detail` key is stored and preserved but **not** honored — the Setup
editor reads the View-mode response and writes its reconstruction back, so an
absent item would be persisted as a deleted field.

`describe/layouts` is deliberately **behavior-blind**: it reports the layout as
authored, so an external client asking what a layout *contains* gets the stored
answer rather than one form's rendering of it (owner ruling on #3506, pinned by
`dialect-sf-crm/describe_layouts_behavior_blind_test.go`). Enforcement is
unchanged: layout-authored `required` is a presentation hint,
`validateRequiredForCreate` reads the schema and never sees a layout, so API and
bulk clients are unaffected — the same position Salesforce takes on page-layout
Required.

---

### Process approvals: non-fatal action failure warnings

**Surface:** `POST /services/data/v63.0/process/approvals/`

**Carve-out:** NexusCRM may include an optional `actionFailures` array on a
successful `ProcessResult`.

**Rationale:** NexusCRM's approval engine can complete the core approval state
while synchronous, metadata-defined lifecycle action paths report user-safe
non-fatal warnings. The standard v63 `ProcessResult` fields remain unchanged;
the extension is emitted only when `success=true` and there are non-fatal action
warnings that an operator or user should see.

**Scope:** This extension is warning-only. It must not replace `errors`, change
HTTP status, or turn a failed core approval operation into a successful result.
It is omitted when no non-fatal action failures are present.
The `reason` field is public UI text and must use stable, user-safe wording.
Transactional, infrastructure, cancellation, and other platform failures must
abort or use a durable retry path rather than being converted into successful
`actionFailures`. Recall FieldUpdate execution uses the durable approval action
replay path; eventual replay failures need a replay-status surface before they
can become user-facing warnings.

---

### Process approvals: implicit Read share to pending approvers

**Surface:** approval engine
(`backend/internal/features/automation/approval_share_reconciler.go`), triggered
by submit/approve/reject/reassign/recall in `approval_service.go`.

**Carve-out:** when a record is pending approval, NexusCRM grants each currently
assigned approver a read-only `_System_RecordShare` row (tagged
`Reason="Approval"`) on the target record, and revokes it when the approval
resolves. Salesforce does **not** do this — submitting for approval locks the
record but grants no read access; an approver who lacks access through the
sharing model (role hierarchy, sharing rules, manual/Apex managed sharing) sees
the approval request but cannot open the record. This is therefore an explicit,
more-generous delta, always on for every approval process.

**Rationale:** approver routing supports data-driven approvers (e.g. a
related-user field) who frequently sit outside the record owner's role
hierarchy, so hierarchy sharing never reaches them. Without an implicit share
such an approver cannot read the record they must act on. The engine grants the
*resolved* approver read access; this is generic substrate behavior with no
object-name branch or tenant payload.

**Lifecycle:** managed by an idempotent reconciler that derives the desired
share set from the instance's open `ProcessInstanceWorkitem` rows; on resolution
the workitems are gone, so the desired set is empty and every approval share is
revoked. Shares are `AccessLevel="Read"` only; the record's approval lock still
governs edits.

**Scope:** read-only, current-step approvers only (the chain is not pre-shared).
Approver routing resolves to user ids, so shares use `ShareWithUserId`; queue/
group routing would extend this to `ShareWithGroupId`.

### Search suggestions: opt-in email field search

**Surface:** `GET /services/data/v63.0/search/suggestions`
served by `backend/internal/interfaces/rest/search_suggestions.go`.

**Carve-out:** NexusCRM accepts an optional `searchFields=EMAIL` query parameter
that broadens prefix matching from the Name display field to Name plus
Email-type fields. When a record's email value prefix-matches the search
term, the response includes that value under its real metadata field API
name (e.g. `Email`, `PersonEmail`); records that matched only by Name
receive no extra fields.

**Rationale:** Salesforce v63 suggestions match Name fields only. In CRM
workflows, users frequently search contacts and leads by email address.
Salesforce exposes email search through SOSL `IN EMAIL FIELDS`, but the fast
prefix `suggestions` endpoint has no equivalent. NexusCRM extends the
endpoint with an explicit opt-in so the autocomplete dropdown can surface
email matches without degrading to the heavier SOSL path.

**Backward compatibility:** the parameter is additive and opt-in. Callers that
omit `searchFields` (or pass a value other than `EMAIL`) get the original
Salesforce-compatible Name-only behavior — identical response shape, no
`Email` field. The first-party global-search suggestions path deliberately
passes `searchFields=EMAIL` for valid suggestions calls, including plain
prefixes such as `jane`, so email prefixes can match without requiring an
`@` intent signal. Field-level security (`filterReadableFieldList`) gates both
the search predicate and the returned email value.

**Scope:** opt-in only. Default Name-only prefix matching and the
`{Id, Name, attributes}` response envelope are unchanged for callers that
do not pass the parameter. The first-party UI activates the extension
unconditionally for global-search suggestions; other callers retain the
default behavior unless they explicitly pass `searchFields=EMAIL`.

---

### DescribeGlobal (ListObjects): sharingModel on the /sobjects response

**Surface:** `GET /services/data/v63.0/sobjects` (DescribeGlobal),
served by the CRM dialect route partition (`github.com/nexuscrm/dialect-sf-crm`;
`DescribeGlobalNeutralHandler` in `describe.go`) when the Salesforce-compatible
CRM pack is active.

**Carve-out:** NexusCRM emits two optional fields on each `DescribeGlobalObject`
entry: `sharingModel` (always present, string) and `sharingParentObject` (present
only when `sharingModel="ControlledByParent"`). Salesforce v63 REST describe
returns `null` for `sharingModel`; the authoritative source in Salesforce's own
tooling is the Tooling API `EntityDefinition.InternalSharingModel`.

**Rationale:** The Profile permission editor (`PermissionEditorModal`) calls
`metadataAPI.getSchemas()`, which resolves to `GET /sobjects`. It uses
`schema.sharingModel === "ControlledByParent"` to project parent-controlled
child objects (e.g. `OpportunityLineItem`) as read-only/inherited rows rather
than independently editable rows. Without this field on the DescribeGlobal
response, the `syncInheritedObjectPermissions` projection function in
`users.ts` is always a no-op (the comparison evaluates to `undefined !==
"ControlledByParent"`), and the inherited-row UI shipped in PR #1678 is dead
code.

**Value source:** `models.ObjectMetadata.SharingModel` — the existing
single SSoT already consumed by all permission and RLS paths. The parent
object is resolved via `MetadataService.GetSharingParentRelationship`,
the same helper already used by `metadata_schema_handler.go:73-77`.
This is a read projection of the existing SSoT, not a new fact owner.

**Backward compatibility:** both fields use `json:"...,omitempty"` so
existing consumers that do not read `sharingModel` are unaffected.

**Scope:** `DescribeGlobal` (`/sobjects`) only. The single-object describe
endpoint (`/sobjects/{name}/describe`, `describeSObjectResult` in the CRM
dialect) is not changed; the permission editor does not call that endpoint for
sharing-model data.

---

### ValidationRule: recordSetBinding cross-record extension (transitional)

**Surface:** `ValidationRule` metadata type
(`shared/pkg/models/metadata.go:729-746`), evaluated by
`backend/internal/features/metadata/validation_record_set_bindings.go` during
record save.

**Status:** Transitional delta, **not** a permanent divergence. The
Salesforce-native terminal state for this need is a before-save record-triggered
Flow (confirmed feasible on the current engine, no L0 work required); the
extension is retained only until the per-consumer L2 migration effort is funded.
Recorded here so the divergence is visible while it exists — it should not be
read as license to add further non-SF capability to SF-modeled primitives when an
SF-native path exists.

**Carve-out:** NexusCRM's `ValidationRule` type includes a `recordSetBindings`
field that adds cross-record query capability to validation rules. Each binding
queries a related record set, applies structured filter conditions (with value
sources from the record being validated, the current user context, or literals),
and injects an existence boolean into the rule's condition formula. Salesforce v63
validation rules are pure formula expressions evaluated against a single record
image plus parent fields reachable by dot notation; they cannot execute SOQL,
aggregate child or unrelated records, or perform existence checks over a record
set.

**Rationale:** Validation rules that require cross-object existence checks
(e.g., "this Order cannot be saved unless a covering Contract exists for the
Order's date window") cannot be expressed by Salesforce validation rules. The
`recordSetBinding` extension fills this gap as a generic L0 platform substrate
available to L1 app packs and L2 tenant validation rules. Current real consumers
are 7 L2 tenant rules on Order; the substrate also supports L1 pack usage via
the source-backed pack system. Of the Salesforce-native alternatives, roll-up
summary fields cannot express the current consumers (their filters are static and
cannot reference the record being validated), but a **before-save record-triggered
Flow can** (confirmed feasible on the current engine). A before-save Flow is
therefore the Salesforce-native terminal state; this extension is transitional,
retained only until the per-consumer L2 migration effort is funded — not because
no SF-native path exists.

**Scope:** The extension is bounded: mode is `exists` only; a 500-row safety cap
prevents unbounded scans; filters are structured arrays (not raw SQL); a
deterministically-false short-circuit avoids unnecessary queries.

**Wire exposure:** The `recordSetBindings` field is a NexusCRM extension to the
standard Salesforce v63 `ValidationRule` payload — Salesforce's wire format has
no equivalent field. Its wire presence differs by endpoint:

- `/services/data/v63.0/metadata/validation-rules`: accepts
  `recordSetBindings` on POST/PATCH and returns it in responses via
  `models.ValidationRule` (`metadata_validation_handler.go`).
- `/services/data/v63.0/tooling/sobjects/ValidationRule`: accepts
  `recordSetBindings` on POST/PATCH (same model), but this shim does not
  return a `ValidationRule` payload — create returns a save-result envelope
  (`id`, `success`, `errors`), update returns `204 No Content`, and there is
  no GET route for this sObject (owned by the `dialect-sf-crm` Tooling
  ValidationRule handlers).
- `/services/data/v63.0/tooling/query`: Tooling read access is through the
  generic query alias `ValidationRule` → `_System_ValidationRule`, which can
  project the stored `RecordSetBindings` column when selected.

See the [design decision document](issue-1801-recordsetbinding-extension.md)
for the full architectural analysis.

---

## Process notes

- **Scope.** This document binds `/services/data/*` and UI API handlers under `backend/internal/interfaces/rest/`. Internal admin routes that have no Salesforce equivalent are out of scope but should mark themselves as such in a top-of-file comment.
- **New surfaces.** When a new handler is added under `backend/internal/interfaces/rest/`, add a row to the [Surfaces](#surfaces-nexuscrm-exposes) table in the same PR.
- **New pitfalls.** When Codex catches a new class of divergence, add a [Common Pitfalls](#common-pitfalls) entry citing the PR. The catalog is curatorial: it should grow with the number of distinct failure modes seen, not with every single PR.
- **Versioning.** This document is versioned with the codebase. When the target Salesforce API version is bumped, every URL in this file and every comment that hard-codes `v63.0` in handler code must be reviewed.
