Skip to content

Validation rules

A validation rule is a pure boolean predicate authored in the same typed expression language as formulas, roll-ups, and calc functions. It belongs to the pure tier: no side effects, deterministic, statically analyzable. Its single job is to inspect a candidate record (and, where declared, its parents) and decide whether the pending write may proceed. It sits in the fixed save order before the write — a rule that reports the record invalid aborts the transaction for that record before any row is persisted.

A validation rule owns no stored value. It is a predicate evaluated at save time, against the in-flight record after all deterministic shaping has settled and before the transactional write. There is nothing to materialize, nothing to read back, no column behind it.

  • Where it lives: as metadata attached to an object (the predicate body plus its error targeting). It is not a column, not a view, not a trigger the author writes by hand — it is a declarative component the kernel compiles.
  • When it evaluates: in the pre-write phase of the save order. In the fixed CAOS sequence — shape → adjust → validate → write → effects → commit → post-commit — validation is the validate step (phase 3). It runs after computed fields resolve and after bounded before-write automation has adjusted the triggering row, and before the transactional write persists the record, maintains storage-enforced roll-ups, and tests the lifecycle gate.
  • Read/write semantics: read-only. A validator may reference the record’s own fields and (where the path is declared) parent fields; it may not write anything, call an effect, or emit a record. The pure tier is a compiler-enforced subset of the one language — the same syntax as a formula field, with the effectful surface (record emission, outbound calls, cross-record writes) removed by the compiler, not by convention.
  • Tier: pure. The authoring context declares the required return type (boolean) and the tier (pure); the compiler enforces both. A body that reaches for an effectful primitive fails to compile in this context.
  • Where it physically runs: the authoritative evaluation is the kernel’s pre-commit validate step, inside the save transaction and before the write. It is not delegated to application code that a bulk loader or a second service could bypass. The single-row-pure subset (own-columns only) is additionally compiled to a Postgres CHECK constraint as a defense-in-depth backstop enforced against every writer (see How Salesforce does it, point 5). Both enforcement points are generated from the same compiled predicate, so the compiler is the single source of truth and the two tiers cannot diverge.

A validation rule is authored declaratively against an object. The component carries:

  • key — the rule’s stable API name (letters, numbers, underscores; no consecutive or trailing underscore).
  • label — human-facing name.
  • active — boolean; enabled or not. Inactive rules are retained but not evaluated.
  • description — free text.
  • body — the boolean predicate, in the shared expression language.
  • error targeting — a structured list of { field, message, severity } entries the predicate produces on failure (see Semantics and How Salesforce does it for why this is a list, not a single message + display-field pair).

The body is written in the one typed language, restricted to the pure tier — the same TypeScript grammar a formula field uses, under a contract that demands boolean. The vocabulary available to a validator:

  • Operators: && || !, == != < <= > >=, arithmetic, ??, ?., and cond ? a : b. A predicate is an ordinary boolean expression; there are no AND(…) / IF(…) wrapper functions to nest.
  • Null / blank tests: x == null, isBlank(x), isNumber(x). A picklist compares directly to its value (record.status == "Confirmed"); a multi-select uses record.tags.includes("rush"). A named value must be an active value of the value set the field names: deploy checks every expression against that list, and a formula, a roll-up filter or a sharing rule covers is checked against the same list again whenever it is compiled at runtime.
  • Change detection (update-scoped): prior is a second bound record — the pre-edit state — so “did this change” needs no built-in: record.total != prior.total. A new record is prior == null, which also makes the insert case explicit rather than an implicit special rule. prior is in scope only on surfaces whose contract provides it; a formula field has no prior, and the compiler rejects it there.
  • Context: user and org are bound for per-user scoping, and can("validation.bypass_<rule>") is the system-permission test — a rule that declares itself bypassable mints one permission of its own, and ends with && !can("validation.bypass_line_price") so capability holders skip it.
  • Cross-object (parent walk): a reference field is a typed property, so a parent value is record.order.stage — the same one-directional, walk-up capability described in Limits.
  • Data-type operations: methods on the value’s own type (record.name.trim(), record.code.startsWith("PV-"), record.title.length, record.opened_on.addMonths(3)) plus free functions for math and dates (round, abs, today()).
  • Calc functions: a registered, version-pinned pure function may be called from the body with the same call syntax as any standard-library function; reproducibility follows from its purity plus its content-addressed pin.

What the pure tier structurally cannot do, by compiler enforcement (not a config toggle): no queries or aggregates over sibling/child rows, no loops, no local variables, no side effects, no reference to child or unrelated records. Complex cross-row invariants are expressed through a declared, indexed roll-up (see Limits), not by querying inside the predicate.

An order line may not be marked Confirmed without a unit price, and a closed order may not have its total edited:

// rule: order_line.line_is_committable — pure boolean, true = valid
// if confirming, a unit price is required
(record.status != "Confirmed" || record.unit_price != null)
// once the parent order is closed, the total is frozen
&& !(record.total != prior.total && record.order.stage == "Closed")

Written as plain boolean logic, each clause reads as the invariant it asserts. The incumbent’s equivalent nests four wrapper functions around the same two facts; here ISCHANGED(total) is just record.total != prior.total, and the assertion convention (true = valid) needs no IF(…, false, true) padding to express.

On failure the validator returns targeted errors, e.g. { field: "unit_price", message: "Enter a unit price before confirming this line.", severity: "error" }. Because the output is a structured list, a single save may surface multiple field errors in one pass rather than one rule at a time.

Timing in the save order. Validation is the validate step, before the write:

# Save-order phase Validation’s relationship to it
1 Shape: resolve computed fields, apply defaults Runs before validate; validators see settled computed values
2 Adjust: bounded before-write automation on the triggering row Runs before validate; validators see the adjusted values
3 Validate (pure predicates) This step. A failed predicate aborts the write for the record
4 Transactional write: persist row, maintain storage-enforced roll-ups, test lifecycle gate Only reached if every active rule passed
5 Effects (after-write automation) Effectful tier; runs after the write
6 Commit atomically —
7 Post-commit async —

One pass, and why one pass terminates. Validation runs exactly once per save. No fixed-point iteration or bounded re-run is needed, and the guarantee is structural rather than a tuned iteration cap. The shape and adjust steps precede validate and have already settled every deterministic mutation to the row, so the record a validator sees is byte-for-byte the record that will be written. A validator is pure: it cannot itself mutate the row, so evaluating it cannot invalidate a value another rule already read. Effects run after the write, in a later phase, and cannot re-enter the validate step for the same save. There is therefore no mechanism by which a passed record could reach the write in a state a rule would have rejected — the workflow-update re-save hole that forces Salesforce to re-fire triggers without re-running validation does not exist here, and a second validate pass would be provably redundant.

Null / blank handling. Null propagates through comparisons; a comparison against a null operand is not silently true. A rule whose predicate cannot be checked for that reason still refuses the save. The person sees the rule’s own message followed by the fields that were blank, for example Discount may be at most 50%. (This rule could not be checked because discount is blank.), and the error’s details.blankFields lists those fields’ API names. Only fields the evaluation actually read are named, so an operand skipped by && or || is never blamed, and neither is a blank the rule already handled where it read it: compared with == null or !=, passed to isBlank or as the argument of a match such as startsWith or includes, negated with !, used as the test of ?:, or on the left of || or ??; a blank prior.<field> read is named as the previous value and listed in details.blankPriorFields. A result of false shows the authored message alone. So record.discount <= 50 refuses a record with no discount, while record.discount == null || record.discount <= 50 lets it through: both meanings are one guard apart, and the author chooses. Negation is the exception to know: ! treats a blank result as not true, so !(record.discount > 50) passes when the discount is blank. When a negated rule must refuse a blank, say so: record.discount != null && !(record.discount > 50). Author blank checks explicitly with isBlank(x) (empty text or null) or x == null (strict null). A picklist compares directly to its value (record.status == "Confirmed") and a multi-select tests membership with record.tags.includes("rush"); an unset picklist is treated as “no value selected,” not as a match.

Precision & determinism. The pure tier is deterministic: the same record and the same pinned calc-function versions always produce the same result. Numeric comparisons use the field’s declared type and precision; there is no ambient clock or random source inside a validator except through the declared context/time variables, which are supplied as inputs so the evaluation stays reproducible.

Type contract. The authoring context fixes the return type to boolean and the tier to pure; the compiler rejects a body that returns another type or reaches an effectful primitive. A parent reference is only legal along a declared relationship path with a compatible field type at the end of it.

Severity: error blocks, warn and info do not. Each entry in a rule’s error-targeting list carries a severity of error, warn, or info, and severity — not the boolean result alone — decides whether the write proceeds:

Severity Effect on the write Client contract
error Blocks. The save transaction aborts for the record. None — the entry is surfaced and the save fails.
warn Does not block by itself, but the save is refused until acknowledged. The response returns the warn entries in the canonical error envelope with severity: "warn" and retriable: true. The client re-submits the identical save carrying an acknowledged_warnings set (the rule keys it accepts). A resubmission whose acknowledged set covers the returned warns proceeds; unacknowledged warns still refuse.
info Never blocks. Advisory only — surfaced alongside the successful result; no acknowledgement, no resubmission.

A single save resolves all three severities in the one validate pass: error entries abort immediately, and if none are present the accumulated warn and info entries are evaluated against the request’s acknowledged set. Acknowledgement is explicit and per-rule so a client cannot blanket-suppress warnings it has not seen; a new or changed warn re-surfaces because its rule key is absent from the prior acknowledged set. This yields warn-but-allow — a mode Salesforce validation rules cannot express — without ever letting a write slip past a hard error.

Concern CAOS approach Salesforce exact limit The WHY behind the SF limit CAOS still needs an equivalent?
Compiled predicate size Bounded by the kernel’s compile budget; the pure body compiles to a bounded plan 5,000 bytes compiled ([COMPILE]) The formula compiles to bytecode executed synchronously inside the save transaction on shared multitenant infrastructure; the cap bounds per-save CPU/memory so one tenant’s giant formula can’t degrade a shared pod. Compiled size ≠ text length — referencing a formula field inlines its compiled size into yours Yes — any synchronous pre-write predicate needs a compile/complexity bound for the same multitenant-safety reason
Error message length Structured {field, message, severity}; message length is a UI concern, not a hard platform cap 255 characters ([META]) It is stored/rendered as a short UI string — a message, not documentation; the cap forces terse, actionable errors Soft — keep messages terse by convention; no hard byte wall required
Predicate text length Governed by the compile budget above, not a separate text cap 3,900 characters / 4,000 bytes when saved ([FSIZE]) Text-length proxy for compiled size, retained alongside the compiled-byte cap; source text is bounded at 3,900 characters (4,000 bytes) and separately rejected if it compiles past 5,000 bytes No separate text limit needed if the compile budget is the real bound
Active rules per object Bounded by per-save evaluation budget; static analysis flags redundant/contradictory rules to keep the set small Group: 0; Professional: 20; Enterprise & Developer: 100; Unlimited & Performance: 500 ([LIMITS]) Every active rule is evaluated on every save of the object; the cap bounds worst-case per-save work. It is an edition-tiered governor, not a technical wall Yes in spirit — a per-object evaluation budget matters; but a static analyzer (below) makes the count a weaker proxy than actual blast radius

Mechanism. A Salesforce validation rule is an error-condition formula: when the formula returns true, the save is blocked and an error is shown ([META]). There is no pass action, no severity, no warn-but-allow — a hard binary gate. Error placement is controlled by a single errorDisplayField: name a field to attach the error inline, or leave it empty for a Top of Page (record-level) banner ([META]). In the save order, rules run after before-save flows and before-triggers and before duplicate rules and the DB write ([OoE]) — so a before-trigger’s mutation is validated, but a record can pass validation and still be blocked by a duplicate rule immediately after.

Cross-object references are parent-only, one direction: a child rule may reference parent fields through the relationship path, but cannot count or reference child records ([XOBJ]). Parent→child invariants must be pushed into a roll-up field or Apex.

The correctness hole (cited). After a workflow field update, the record is re-saved and update triggers re-fire, but custom validation rules do not re-run ([OoE]). Downstream automation can therefore push a record into exactly the state a validation rule was written to reject, and the rule never re-evaluates. This is a structural gap, not a configuration mistake.

  1. One language, no dialect split. Salesforce splits validation formulas, formula fields, flow conditions, and Apex across four evaluation surfaces. CAOS uses one pure predicate/expression language for validation, computed fields, and visibility — a predicate can be lifted into a computed column or a guard unchanged. Cost/risk: designing and versioning a stable language, and its editor/linter/docs, is real ongoing work.

  2. Deterministic run-order closes the workflow-update hole. Because CAOS runs the pure validate step after all deterministic shaping settles and before the write, in one non-re-entrant pass, no downstream field mutation can commit a record that fails a rule — the very gap Salesforce leaves open. Cost/risk: the single-pass guarantee depends on the shape/effect boundary being honored; any design that let an effect mutate-then-skip-revalidation would reintroduce the hole, so that boundary is a load-bearing invariant of the save order rather than a convention.

  3. Structured, multi-error targeting — no silent degrade. Salesforce downgrades a field-targeted error to Top-of-Page when the field isn’t on the running user’s layout — a silent loss of specificity. CAOS validators return a structured {field, message, severity} list, decoupled from layout presence, surfacing multiple errors per save in one pass, and can carry severity (error / warn / info) — enabling warn-but-allow, which Salesforce cannot express. Cost/risk: warn semantics need a UI acknowledge-and-proceed contract and a richer API error envelope.

  4. Static analyzability. Because predicates are a pure, closed language, CAOS can analyze them at author time: referenced-column / blast-radius graph, always-true / always-false / unreachable detection, contradictory-rule detection, and “which rules touch column X.” Salesforce hands back Tooling-API text to parse by hand, and never surfaces that two rules contradict. Cost/risk: a real analyzer is a compiler-grade investment; partial analysis that looks authoritative but misses cases is worse than none.

  5. CHECK-constraint push-down — for the single-row-pure subset only. A predicate that is pure and single-row — references only the record’s own columns, with no parent walk, no context/user variable, and no change detection (which needs OLD/NEW) — can be compiled to a Postgres CHECK constraint. That invariant is then enforced at the database, against every writer: direct SQL, migrations, other services — not just the app tier. Salesforce cannot enforce a validation rule against a raw DB write; CAOS can make this subset unbypassable.

  6. Bounded cross-object in both directions. Parity keeps parent references; beyond parity, CAOS could support bounded child aggregates (“sum of line items ≤ header cap”) through a declared, indexed roll-up — the case Salesforce forces into Apex. Cost/risk: a child write must then re-validate the parent, a fan-out with locking and performance implications; keep it opt-in and bounded. Unbounded joins in a save path invite lock storms.

Parity, not better — match Salesforce, don’t reinvent: block-on-invalid, active/inactive toggle, bypass-by-capability, uniform enforcement across UI/API/bulk with per-row partial-save under a non-all-or-none mode, and the metadata/deploy representation.

A validation rule is a canonical JSON component:

{
"key": "orderline_confirm_requires_price",
"label": "Confirmed line requires a unit price",
"type": "validation_rule",
"body": {
"object": "OrderLine",
"tier": "pure",
"returns": "boolean",
"active": true,
"predicate": "(record.status != \"Confirmed\" || record.unit_price != null) && !(record.total != prior.total && record.order.stage == \"Closed\")",
"errors": [
{ "field": "unit_price", "message": "Enter a unit price before confirming this line.", "severity": "error" },
{ "field": "total", "message": "This order is closed; the total can't be changed.", "severity": "error" }
]
}
}

The lifecycle is retrieve → diff → deploy: components are retrieved as canonical JSON, diffed textually (the predicate is source, so a change is a readable diff), and deployed; flipping active is a normal deploy that changes enablement. Because the body is a pure closed expression, the diff is also the input to static analysis — a deploy can be gated on “no rule became always-false” or “no new contradiction.”

Salesforce Metadata API analog. The component maps to the ValidationRule metadata type ([META]), packaged under each object (objects/<Object>/validationRules/<Name>.validationRule-meta.xml in source format). Element names: fullName, active, description, errorConditionFormula, errorMessage, errorDisplayField. The same rule is queryable through the Tooling API ValidationRule sObject (Id, ValidationName, EntityDefinition.QualifiedApiName, Active, ErrorMessage, ErrorConditionFormula). CAOS’s structured errors[] array replaces the SF pair of a single errorMessage + single errorDisplayField.

The inline tags [OoE], [META], [XOBJ], [COMPILE], [FSIZE], and [LIMITS] used above resolve to these sources: