# Formula Context SSoT — Session-Bound `$User` / `$Profile` / `$UserRole`

Status: **normative** contract for formula merge-field user context.
Scope: `nexuscrm-kernel/pkg/formula` and every formula consumer (validation rules, flow
formulas, approval criteria, action expressions).

Root `CLAUDE.md` carries the one-line rule; this document is the full contract.
It matches Salesforce v63's session-bound `$Profile` / `$UserRole` semantics, so
any deviation is an explicit Salesforce-Boundary delta.

## Why session-bound

`$User`, `$Profile`, and `$UserRole` formula globals — and the underlying
ProfileId / ProfileName / UserRoleID / RoleName fields on every `UserSession` —
are bound to the authenticated session projection minted at login. That
projection is the SSoT for the **values exposed to formulas**. Formula-context
construction never replaces some of those values with a later database read.
This guarantees that validation rules, flow formulas, approval criteria, and
action expressions all see byte-identical merge-field values within a single
save transaction.

This value-source rule is not an authorization rule. Strong authorizing
admission re-reads the durable session subject and current User/Profile/Role ID
assignment, rejects a JWT whose IDs have drifted, and admits the current
Permission State floor. A security-sensitive command may fence and recompute
mutable permissions again inside its transaction. Those checks decide whether
the request may execute; they do not rewrite the admitted formula projection or
mix current IDs with historical names.

## Construction (read path)

`nexuscrm-kernel/pkg/formula.BuildFormulaUserContext(*UserSession)` is the single read
path for formula `__User` / `__Profile` / `__UserRole` projections. All in-tree
consumers call this helper:

- validation rules — `nexuscrm-records/persistence_service.go`
- flow formulas — `nexuscrm-automation/executor_helpers.go`
- approval criteria — `nexuscrm-automation/approval_service.go`
- action expressions — `nexuscrm-automation/action_service.go`

New formula consumers MUST use it. DB lookups inside formula-context
construction are forbidden — they would create an unannotated
stricter-than-Salesforce delta.

## Invalidation (write path)

Admin writes that mutate session-bound facts trigger session revocation in
`records/persistence_service.go:Update`. The next request from the affected user
fails JWT validation (revoked session) → user re-logs in → new JWT carries
fresh, atomically-consistent IDs and names.

| Write event | Revocation hook | API |
|-------------|-----------------|-----|
| `User.ProfileId` or `User.UserRoleID` change (reassignment) | per-user | `SessionRepository.RevokeAllSessionsForUser(userID)` |
| `Profile.Name` change (rename) | per-profile (batch JOIN with User) | `RevokeAllSessionsByProfileID(profileID)` |
| `UserRole.Name` change (rename) | per-role (batch JOIN with User) | `RevokeAllSessionsByUserRoleID(roleID)` |

## No mid-session DB refresh

Authorizing admission may compare the session's User/Profile/Role IDs with
durable current IDs and reject drift. It MUST NOT refresh ProfileId,
UserRoleID, or names in the admitted `UserSession`. Mutable system/custom
permissions are loaded and fenced through the Permission State authority,
separately from formula identity. The previous "refresh an ID from DB but keep
the session name" model is forbidden because that partial refresh lets formula
globals observe an identity that never existed within a single save.

## Future change

If a future feature genuinely requires live-DB Profile/Role data inside a
formula context, that is a new, explicit Salesforce-Boundary delta — document it
here and add a separate construction surface. Do NOT modify
`BuildFormulaUserContext` to do DB reads, and do NOT re-introduce mid-session
refresh of session-bound IDs in middleware.
