# Issue 1801: recordSetBinding — Transitional Bounded NexusCRM Extension

Date: 2026-06-04

Status: accepted design contract for #1801 — **transitional**, no code
implementation in this PR. The Salesforce-native terminal state is a before-save
record-triggered Flow (confirmed feasible today); migration is deferred until the
per-consumer L2 effort is funded.

## Decision

`recordSetBinding` is retained, for now, as a **transitional**, bounded NexusCRM
extension to the Salesforce ValidationRule substrate. It is **not** blessed as a
permanent platform primitive: its Salesforce-native terminal state is a before-save
record-triggered Flow (confirmed feasible — see Future Path), and consumers should
be migrated there when the L2 effort is funded (see Invalidation Conditions). The
original migration proposal — replace with rollup-summary COUNT + single-record
validation rule — was falsified by consumer analysis: all 7 real consumers are
record-relative cross-object range joins that a static-filter rollup cannot express
(Flow, not rollup, is the correct SF-native target).

The extension remains under the following boundary constraints:

- Mode: `exists` only (no aggregate, count, or match modes)
- Row cap: 500 candidate rows per binding evaluation (hard error, not truncate)
- Filter: structured array with operator/valueSource semantics (not raw SQL)
- Current real consumers are L2 tenant validation rules only; the substrate
  is generic L0 and supports L1 app-pack usage (the source-backed pack
  system projects `RecordSetBindings` for installed packs)

## Context

### What recordSetBinding does

A validation rule construct (`ValidationRuleRecordSetBinding` in
`shared/pkg/models/metadata.go:729-746`) that queries a related record set and
injects an existence boolean into the rule's condition formula. It is declared
as "a NexusCRM extension to the Salesforce ValidationRule substrate."

The engine (`backend/internal/features/metadata/validation_record_set_bindings.go`,
1047 lines) resolves filter values from the record being validated, the current
user context, or literals; builds a SQL pushdown filter for conjunctive
conditions; fetches up to 500 candidate rows; and performs an in-memory recheck
to handle dot-path relationship traversals excluded from pushdown.

### Why Salesforce validation rules cannot do this

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. `recordSetBinding` provides cross-record query capability
that Salesforce validation rules do not have.

The Salesforce-native alternatives are:
- **Roll-up summary fields**: precomputed aggregates on a parent, with static
  filters applied at child-save time. Cannot reference fields from the record
  being validated (no record-relative filtering).
- **Before-save record-triggered Flow**: can query related records and block
  saves. This is the modern declarative replacement for cross-record validation
  in Salesforce, but requires L2 design effort to implement per consumer.

## Falsified Migration Evidence

### Consumer census

| Layer | Real consumers | Evidence |
|---|---|---|
| L0 (platform code) | 0 | Engine only interprets; no L0 code declares bindings |
| L1 (app-pack metadata) | 0 checked in | Substrate supports L1 usage (source-backed pack system projects `RecordSetBindings`); no checked-in L1 pack metadata currently declares bindings |
| L2 (tenant metadata) | 7 | All on `Order` object; runtime-only (database) |

### Why rollup-count cannot express these (7 / 7)

Every one of the 7 bindings is a **record-relative cross-object range join**:

```
binding mode = exists, target object = Contract
  AccountId    equals          recordPath(<the Order's account>)
  RecordType   in / equals     literal(<tenant-specific values>)
  StartDate    lessOrEqual     recordPath(<a date field on the Order>)
  EndDate      greaterOrEqual  recordPath(<a date field on the Order>)
```

Semantics: "does a `Contract` exist whose validity window `[StartDate, EndDate]`
covers a date window taken from the `Order` currently being saved?"

A roll-up summary is precomputed when the child (`Contract`) is saved and
aggregated onto the parent (`Account`); its filter is a static SQL fragment
applied to child rows (`RollupConfig.Filter` → `rollup_repository.go`, applied
as `WhereRaw`). At rollup-compute time there is no "Order being validated" in
scope, so the filter cannot reference the Order's per-record date fields.
"Covers this Order's window" is inexpressible as a static-filter rollup count.
None of the 7 has a purely-static filter set, so the migratable subset is zero.

### Consequences

1. The migration has no viable destination for any of the 7 consumers.
2. The "supersedes #1764 / #1765" relationship from the original issue does not
   hold. Since the cross-record scan cannot be removed via rollup, #1764
   (comparator → formula-engine comparison SSoT) and #1765 (NULL-inclusive SQL
   narrowing) remain the live, pragmatic investments.
3. The stated "SF-native expression" is incorrect for this shape. The SF-native
   replacement (if wanted) is a before-save Flow, not a rollup.

## Boundary Constraints

The following invariants define the bounded scope of `recordSetBinding`:

| Invariant | Enforcement | Location |
|---|---|---|
| Mode is `exists` only | Structural validation rejects other modes | `validateRecordSetBindingBasic` |
| 500-row safety cap | Hard error when query returns 500 rows | `validationRecordSetBindingQueryLimit` constant |
| Structured filter only | Filter is `[]ValidationRuleRecordSetFilter` with operator/valueSource | Schema validation |
| No raw SQL in filters | Pushdown uses formula expression, not raw SQL | `recordSetPushdownFilter` |
| Deterministically-false short-circuit | Exact satisfiability check before query (#1747) | `recordSetBindingDeterministicallyFalse` |
| Nil-ordered comparison guard | Drops ordered comparisons against nil from pushdown | `recordSetFilterPushdownExpr` |

## Safety Limits

The engine includes defense-in-depth safety measures:

- **500-row cap**: prevents unbounded scans in the write transaction path.
  Exceeding the cap is a hard error, not a silent truncate.
- **Deterministically-false short-circuit** (#1747): when the filter logic is
  provably unsatisfiable regardless of data, the binding resolves to `false`
  without issuing a query. Capped at 2^20 assignments.
- **Nil-ordered guard** (#1747): ordered comparisons (`<`, `<=`, `>`, `>=`)
  against nil values are dropped from the SQL pushdown to avoid generating
  invalid `field OP NULL` SQL. The over-fetch is corrected by the in-memory
  recheck.
- **Schema path validation**: filter field paths and `recordPath` value sources
  are validated against the candidate and source object schemas at
  metadata-authoring time.

## Relationship with #1764 and #1765

| Issue | Title | Status |
|---|---|---|
| #1764 | Comparator → formula-engine comparison SSoT | Live, independent investment |
| #1765 | NULL-inclusive sound-superset SQL narrowing | Live, independent investment |

These issues invest in making the existing `recordSetBinding` evaluator sound
and bounded. They are **not superseded** by this design decision — the original
"superseded by migration" claim was retracted when the migration was falsified.

## Future Path

A before-save record-triggered Flow is the SF-native replacement for
`recordSetBinding`, and it is **confirmed feasible on the current engine with no
L0 work**: the Flow engine already supports a record-relative `Get Records`
(filter RHS resolves `$Record.<field>` from the in-flight record, with range
operators pushed to SQL), a `Decision`, and a before-save `CustomError` that
aborts the save. Each of the 7 L2 consumers would be re-authored as:

1. A `Get Records` on the target object (e.g., Contract) with `$Record`-relative
   filter conditions matching the binding's structured filters
   (`GetFirstRecordOnly = true` makes it an existence check bounded to one row)
2. A `Decision` checking whether a record was returned
3. A `CustomError` (before-save validation abort) to block the save otherwise

The remaining work is therefore **purely L2** — authoring the 7 Flows and a
refactor-discipline cutover that retires the engine read path once consumers
migrate — not an L0 capability gap. It belongs to a separate future issue. The
net safety profile is comparable (both paths run one capped `QueryWithFilter`);
the win is Salesforce-native parity, which is why this is the terminal state.

## Invalidation Conditions

This decision should be revisited when:

1. A before-save Flow implementation exists and has been validated for ≥ 1 of
   the 7 L2 consumers, OR
2. The rollup engine gains record-relative filtering capability (unlikely — it
   would fundamentally change the rollup computation model), OR
3. The number of L2 consumers grows significantly beyond 7, increasing the
   maintenance surface of the extension.

## Salesforce v63 Carve-out

This extension is recorded as a documented carve-out in the
[Salesforce v63 API Contract](salesforce-v63-api-contract.md). The carve-out
records the surface, rationale, and scope of the divergence from Salesforce v63
validation rule behavior.
