Save order of execution
The save order of execution is the platform contract that governs everything that happens between “a record is submitted” and “the change is durable.” In CAOS it is a fixed, linear, single-pass sequence over one Postgres transaction — shape the incoming record, let bounded before-write automation adjust the triggering row, validate, then one transactional write that persists the row and maintains storage-enforced roll-ups and tests the lifecycle gate, author-defined effects after, one atomic commit, then post-commit async. It spans both capability tiers: the pure tier (formulas, roll-ups, validators, calc functions) runs at shape/validate/write time; the effectful tier (record-triggered automation) runs in the before-write adjust phase, the after-save effects phase, and post-commit. The defining property is that no phase re-enters an earlier phase — there are no back-edges and no nested re-entry.
The model
Section titled “The model”A CAOS save is a state machine with a single forward path. Each phase runs exactly once per saved record, in a fixed order the author cannot reorder and the platform never repeats within a save. The sequence is the same for insert and update; only the loaded prior state differs.
-
SHAPE — derive and normalize the incoming record (pure, no side-effect writes). The fully-typed incoming values are merged over the loaded prior state, then author-defined derivations run: formula fields recompute,
effective = coalesce(override, computed)resolves for every overridable value, calc functions evaluate, defaults fill nulls. This is a pure transform of the in-memory record — it may not write other records. Multi-stage derivation (“derive B from A, then C from B”) runs as a declared dependency order within this one pass, not by re-entering the pipeline. Aborts the save: a shape-time type error or a calc function raising. Cannot abort: nothing here reaches outward, so there is no partial external effect to unwind. -
ADJUST — bounded before-write automation on the triggering row (effectful, bounded). Record-triggered automation declared
before_writeruns here. It may set fields on the triggering row only — no writes to other records, no outward calls. The kernel gathers every matching before-write rule’s field assignments, resolves them to a fixpoint, and applies the result once. ADJUST runs before VALIDATE so validation sees the adjusted values — a rule cannot be dodged by an adjustment applied after it. Being effectful, it is the first phase of the effectful tier, but its blast radius is confined to the one row being saved. Aborts the save: an adjust body that raises. Cannot abort: it reaches no other record, so there is no external effect to unwind. -
VALIDATE — system + author validation, once, over the fully-shaped and adjusted record. Field-level system checks (type, required, length, format), validation rules, and uniqueness/duplicate gates run against the final values. Because SHAPE and ADJUST have already produced every derived and adjusted value, there is no later step that can mutate a field after this pass — a value a rule would reject cannot subsequently slip through. Aborts the save: any failed validation or duplicate-block rule; the transaction never opens. Cannot abort: n/a — this is the last purely in-memory gate.
-
WRITE — the transactional write, as one unit. A single database operation opens the transaction and does three things atomically: (a) persists the row (it now has an identity and is visible to later phases in this transaction); (b) maintains storage-enforced roll-ups — parent and grandparent aggregates are recomputed by the database (trigger-maintained columns or incremental materialized aggregates), without the parent re-entering this application pipeline; (c) tests the lifecycle gate and all storage constraints — foreign keys, unique indexes, check constraints, RLS, and the lifecycle/path transition guard. Aborts the save: a constraint violation, RLS denial, or a blocked lifecycle transition rolls the transaction back. Cannot abort by accident: roll-up maintenance does not run author logic on the parent, so it cannot fire an unexpected parent-side validation or automation.
-
EFFECTS — author-defined after-write automation, one pass, in declared order. Record-triggered automation declared
after_writeruns after the row is written but before commit, still inside the transaction. Effects run once, in the order the author declared, and may write other records or enqueue outbound work; each related-record write runs its own bounded lifecycle. There is no re-fire of an earlier phase and no re-run of validation. Aborts the save: an effect that raises rolls back the entire transaction — including the write and all roll-up maintenance (atomic all-or-nothing). Cannot abort: an effect cannot cause SHAPE, ADJUST, or VALIDATE to run again. -
COMMIT — atomic Postgres
COMMIT. All DML from phases 4 and 5 becomes durable in one step. Nothing before this point is visible outside the transaction; nothing after it can be rolled back by this save. Aborts: only an infrastructure-level commit failure, which unwinds everything. Cannot abort: once committed, no subsequent phase can retract the record change. -
POST-COMMIT — async only, separate transaction. Side effects that must not block or endanger the save — outbox/queue publish, email, webhooks, downstream jobs — run after commit in their own transaction with a fresh resource budget. Outbound messages are enqueued to a transactional outbox during phase 5, so they are committed atomically with the record and published only if the save succeeded. Aborts: a post-commit failure is retried/dead-lettered by the worker; it cannot roll back the committed record. Cannot abort: the user’s save is already durable and is never reversed by downstream failure.
Authoring
Section titled “Authoring”The save order itself is not authored — it is a fixed platform contract. What authors write are the participants that the platform slots into their fixed phase. Each participant declares which phase it belongs to; the platform orders them, and within a phase honors any author-declared dependency order.
| Participant | Phase | Tier | Authored as |
|---|---|---|---|
| Formula field | SHAPE | pure | Formula field — returns a typed value |
| Overridable value resolution | SHAPE | pure | Overridable value — coalesce(override, computed) |
| Calc function call | SHAPE | pure | Calc function — version-pinned, content-addressed |
| Field default / normalize | SHAPE | pure | A field’s default, on create only and before any derivation; or a derivation expression |
| Before-write automation | ADJUST | effectful | Record-triggered automation — before_write, triggering row only |
| System field check | VALIDATE | pure | Field metadata (type/required/length/format) |
| Validation rule | VALIDATE | pure | Validation rule — boolean, error-on-true |
| Uniqueness / duplicate gate | VALIDATE | pure | Unique constraint / match rule |
| Roll-up summary | WRITE | pure | Roll-up field — storage-maintained aggregate |
| Constraint / RLS / lifecycle gate | WRITE | pure | Constraint + lifecycle transition guard |
| After-save effect | EFFECTS | effectful | Record-triggered automation |
| Outbound message | POST-COMMIT | effectful | Outbox publish declared inside an effect |
The vocabulary an author uses to place work is small and closed:
phase— one ofshape|adjust|validate|effect. These map onto the automation phase names:adjustis automation’sbefore_write,effectis itsafter_write, and the platform-owned post-commit slot is itspost_commit. (write,commit, andpost-commitare platform-owned and not author-selectable as participants; roll-ups and constraints attach to WRITE by being roll-up/constraint metadata, and outbound work attaches to POST-COMMIT by being enqueued from an effect.)tier—pure|effectful, enforced by the compiler from the phase (shape/validatemust bepure;adjust/effectmay beeffectful, withadjustfurther bounded to writes on the triggering row only). Ashapeparticipant that attempts a side-effect write fails to compile.after— an optional list of sibling keys this participant must run after, giving an explicit intra-phase dependency order. A cycle is a compile error.when— an optional pure boolean guard controlling whether the participant runs for this save.
A validation rule is error-on-true: its expr states the invalid condition, so the rule fires its error exactly when the expression evaluates to true. Write the predicate as “the record is bad when…”, not “the record is OK when…”.
Worked example — a discount that is derived, then validated, then announced, all in one save:
// Participants attached to the Invoice object, each declaring its phase.[ { "key": "invoice.derive_discounted_total", "phase": "shape", // SHAPE: pure derivation, runs before the write "tier": "pure", "expr": "line_subtotal * (1 - coalesce(discount_override, computed_discount))" // effective discount = coalesce(override, computed) resolves here, once }, { "key": "invoice.margin_floor", "phase": "validate", // VALIDATE: runs over the SHAPED value "tier": "pure", "after": ["invoice.derive_discounted_total"], "expr": "discounted_total < cost * 1.10", // error-on-TRUE: the expr states the INVALID case "error": "Discounted total is below the 10% margin floor." // Fires when the margin is TOO LOW (the invalid condition). A value SHAPE // produced cannot dodge this check — nothing mutates the field after here. }, { "key": "invoice.notify_sales_ops", "phase": "effect", // EFFECTS: after the write, inside the txn "tier": "effectful", "when": "discounted_total < cost * 1.25", // pure guard "body": "outbox.publish('invoice.deep_discount', { id: record.id })" // Enqueued to the transactional outbox; published in POST-COMMIT only if COMMIT succeeds. }]There is no participant that runs “again after another participant changed a field.” If notify_sales_ops needed a value derived from another derivation, that derivation is another shape participant with an after edge — never a second pass.
Semantics & evaluation
Section titled “Semantics & evaluation”Ordering. Phases run in the fixed order 1–7 above. Within a phase, participants run in an order consistent with their after edges (a topological sort); participants with no declared relationship have an unspecified but stable relative order, and the platform rejects any author logic whose correctness depends on that unspecified order the way an explicit after edge would express it.
Single pass. Every participant runs at most once per save. There is no construct — workflow field update, roll-up parent re-save, or otherwise — that causes a phase to repeat or an earlier phase to re-enter. This is the load-bearing difference from Salesforce and the reason CAOS needs no recursion guards.
Null and blank handling. SHAPE resolves nulls before VALIDATE sees them: an unset override is NULL and yields effective = computed; a formula over a null input follows the formula field null contract. VALIDATE distinguishes “required and null” (fails) from “optional and null” (passes). A roll-up over zero children yields the aggregate’s identity element, not null (e.g. SUM → 0, COUNT → 0), so downstream pure logic never sees a surprise null from an empty parent.
Precision & determinism. Every pure participant (SHAPE, VALIDATE, and the roll-up recompute in WRITE) is deterministic: same inputs and same pinned calc-function versions produce the same output, independent of when or how the save was reached. Because a parent’s roll-up is maintained by the database rather than by re-running the parent’s author logic, a child change reaches the parent aggregate by exactly one deterministic path regardless of whether the parent is also being edited directly.
SHAPE is a dependency graph, not a single expression. Multi-stage derivation (“derive B from A, then C from B”) is a declared directed acyclic graph of derivation participants, each an after-edged shape participant, topologically sorted and evaluated once within the single pass. The DAG is chosen over a single monolithic expression because it keeps each stage individually typed, cached, and diffable, and because it makes the ordering explicit and author-visible rather than implicit in expression nesting. The graph is always static — it is compiled from the participants at deploy time, a cycle is a compile error, and no runtime input can add an edge. This is what lets CAOS serve the pattern Salesforce serves with re-entrancy without ever re-entering the pipeline: the “second pass” a workflow field update would trigger is instead a second node in the same one-pass DAG.
Roll-up maintenance is selected per field by cardinality. Each roll-up declares nothing about its storage strategy; the platform picks one from the parent’s child cardinality and write pattern. Low- and medium-cardinality roll-ups are maintained as a trigger-maintained column on the parent — a plain column the database updates from a row-level trigger on the child, incrementally (+= new − old) so a single child change is O(1), not a re-scan. (A Postgres GENERATED ALWAYS AS … STORED column cannot be used for a roll-up: generated columns may reference only the same row, never child rows.) High-cardinality or contention-prone parents use an incremental materialized aggregate — an aggregate table keyed by parent, updated by the same incremental delta but isolated from the parent’s own row so that many children updating the aggregate do not lock the parent record itself. On-read aggregation (a plain SUM(...) view) is reserved for roll-ups that are rarely read relative to how often children change, where maintaining a stored value costs more than it saves. In every case the value is authoritative at read time and is recomputed inside WRITE, never asynchronously.
Type contracts. Each phase declares the return type it expects: SHAPE participants return the field’s type; VALIDATE participants return boolean (error on true); WRITE roll-ups return the aggregate’s type; EFFECT bodies return void and communicate only through writes/outbox. The compiler enforces both the return type and the tier at author time — an ill-typed or side-effecting pure participant does not deploy.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”Salesforce’s pipeline is shaped by multitenancy: shared infrastructure must be protected from any one tenant’s runaway automation, and years of additive features (triggers, then workflow, then flows, then roll-ups) accreted into a recursive order. CAOS owns the whole Postgres stack, so it can make the order fixed — but it must still bound resource use, because “no limits” is an unshipped problem, not an advantage.
| Concern | Salesforce’s exact limit / behavior (cited) | The platform constraint it guards | CAOS approach | Does CAOS still need an equivalent? |
|---|---|---|---|---|
| Trigger recursion from workflow field updates | before/after update triggers fire “one more time (and only one more time)” on a workflow field update, even on an insert [1] |
Prevents an unbounded automation loop while still letting a field update be “seen” | No re-fire exists; SHAPE runs once, before VALIDATE | No — eliminated by the single-pass design |
| Cross-object roll-up recursion | Parent and grandparent each “goes through save procedure”; recursion is bounded by skipping steps 9–17 on a recursive save [1] | Bounds cascade depth on eagerly-maintained roll-ups | Roll-ups maintained by the DB in WRITE; parent author logic does not re-enter; per-parent child deltas are coalesced and parents locked in canonical (primary-key) order | Replaced, not needed — DB-side coalescing + lock ordering supersede the skip range |
| Synchronous resource budget | One governor set across steps 2–19: SOQL 100, DML 150, CPU 10s per synchronous transaction [1] | Protects the shared multitenant substrate from a single expensive save | One Postgres transaction budget across SHAPE→COMMIT: a statement_timeout CPU ceiling, a per-transaction query/DML count budget, and a fixed cascade-depth cap on queued cross-record writes — all tunable per tenant plan |
Yes — met by the transaction-budget model above |
| Batch size | Automation runs in chunks of up to 200 records per invocation | Bounds per-invocation work | 200 records per invocation by default (matched — a well-understood transaction unit; larger chunks lengthen locks and transaction time), tunable per tenant, executed set-based across the batch | Yes — set-based is table stakes, not an advantage |
| Undefined automation ordering | Order among Process Builder / launched flows at step 13 is explicitly not guaranteed [1] | Lets the platform schedule freely | EFFECTS run in author-declared order | No — determinism is strictly better here |
| Side effect vs. rollback | Post-commit work (email, async Apex) runs after commit in a separate transaction with fresh limits [1] | Keeps slow/outbound work off the save transaction | Transactional outbox enqueued in EFFECTS, published in POST-COMMIT | Parity of intent; the outbox is a better implementation |
How Salesforce does it
Section titled “How Salesforce does it”Salesforce documents a 20-step order of execution for every insert/update/upsert, numbered by the primary doc; the numbering is load-bearing because the doc’s own recursion note refers to “steps 9 through 17.” [1] Paraphrased from the primary source:
- Loads the original record, or initializes it for an upsert. [1]
- Loads new field values over the old, then runs system validation (layout rules for UI edits; a reduced field-definition/format/FK set for API requests). [1]
- Executes before-save record-triggered flows. [1]
- Executes all
beforeApex triggers. [1] - Runs most system validation again plus custom validation rules (max length and data type are not re-checked here). [1]
- Executes duplicate rules — a block action stops the save. [1]
- Saves the record to the database, but does not commit. [1]
- Executes all
afterApex triggers (record is read-only). [1] - Executes assignment rules. [1]
- Executes auto-response rules. [1]
- Executes workflow rules — re-entrancy point (see below). [1]
- Executes escalation rules. [1]
- Executes Process Builder processes and launched flows — order among them not guaranteed. [1]
- Executes after-save record-triggered flows. [1]
- Executes entitlement rules. [1]
- Recalculates roll-up summary on the parent — the parent “goes through save procedure.” [1]
- Recalculates roll-up summary on the grandparent — it too “goes through save procedure.” [1]
- Executes criteria-based sharing evaluation (notably outside the recursion-skip range). [1]
- Commits all DML to the database. [1]
- Executes post-commit logic — email, async Apex (
@future/Queueable), async flow paths — in a separate transaction with fresh governor limits. [1]
During a recursive save, Salesforce “skips steps 9 (assignment rules) through 17 (roll-up summary field in the grandparent record).” [1] Criteria-based sharing (18), commit (19), and post-commit (20) still run.
The two cite-able footguns. These are the structural pain points CAOS is designed to remove:
- A workflow field update re-fires update triggers but does not re-run custom validation. At step 11, if a workflow rule performs a field update, the doc states the record is updated again, system validations run again, and “before update triggers and after update triggers” execute “one more time (and only one more time)” — but “custom validation rules, flows, duplicate rules, processes built with Process Builder, and escalation rules aren’t run again.” [1] The consequence: a value that a custom validation rule (step 5) would have rejected can be written by a step-11 workflow field update and commit anyway, because validation does not re-run over it. It also fires
after updatetriggers during what the user experienced as aninsert, and forces developers to write recursion guards. - Roll-up parent/grandparent recursion re-enters the full save procedure. At steps 16–17 the parent and grandparent each “goes through save procedure” [1] — genuine cross-record recursion. Because roll-ups are maintained eagerly on the parent, a child change re-runs the parent’s triggers, workflow, its own roll-ups, and sharing, all consuming the same transaction’s governor limits. The recursion is bounded only by the skip window (steps 9–17) and, ultimately, a fixed re-entry ceiling of 16 levels deep, after which the platform aborts with a
LimitException. [2] Same record, two behaviors: full logic when saved directly, a reduced pass when reached as a roll-up side effect.
Where CAOS is genuinely better:
- No re-entrancy — a static property. The CAOS sequence has no back-edges: SHAPE runs once, VALIDATE runs once over the final shaped values, EFFECTS run once in declared order. This structurally eliminates the workflow-re-fire bug class and the recursion-guard boilerplate it forces. The mechanism is the fixed phase order plus a compiler that rejects any participant that would require re-entry.
- Validation cannot be defeated by a later mutation. All derivation happens in SHAPE, before the single VALIDATE pass, so there is no step-11 analogue that mutates a validated field. The mechanism is phase ordering: pure derivation strictly precedes validation.
- Roll-ups without pipeline fan-out. Parent aggregates are maintained by the database (trigger-maintained columns or incremental materialized aggregates) inside WRITE, so the parent’s aggregate updates without re-running the parent’s author logic. The mechanism is Postgres storage maintenance, not application re-entry.
- Deterministic effect ordering (vs. step 13’s “no guaranteed order”) and an atomic transactional outbox (vs. post-commit side effects that can be “sent but rolled back”) are parity-plus: the same capability, implemented so it cannot lose or misorder work.
Costs and risks:
- Storage-maintained roll-ups are hard. Concurrent children updating one parent create lock contention or lost-update risk; correctness under partial failure and re-derivation when a roll-up definition changes are non-trivial. This is the single largest engineering cost of the “roll-ups as one WRITE unit” claim.
- “One pass” forbids some real patterns. Salesforce’s ugly re-entrancy does let a field update drive downstream trigger logic. CAOS must serve “derive B from A, then C from B” as an explicit multi-stage SHAPE dependency order — if that DAG is not expressive enough, authors will demand re-entry and the problem returns.
- Cross-record cascades still exist. Removing pipeline re-entry does not remove the write to the parent aggregate. CAOS bounds it rather than leaving it emergent: a set-based save that touches many children of one parent coalesces their contributions into a single aggregate delta applied once, and the kernel acquires parent locks in a canonical order (by parent primary key) so concurrent saves cannot deadlock; high-cardinality hot parents are moved off their own row into an incremental materialized aggregate so the parent record stops being the contention point. This is real Postgres work — it is designed, not free.
- Effect failure aborts the user’s save. Atomicity is a feature, but an author-written effect that throws rolls back the record — the same trade-off Salesforce has, to be surfaced clearly, not hidden.
- Post-commit needs owned infrastructure. The outbox plus a reliable worker (retries, dead-letter, delivery guarantees) is infrastructure Salesforce provides “for free”; on Postgres CAOS owns it.
- Resource governance is still required. Salesforce’s governor limits are hated but they protect the multitenant substrate. CAOS bounds runaway SHAPE/EFFECT logic the same way, expressed in Postgres rather than a bespoke VM: a
statement_timeouton the save transaction caps CPU, a per-transaction query/DML counter caps fan-out, and a fixed cascade-depth ceiling caps queued cross-record writes (the analogue of Salesforce’s 16-level re-entry ceiling, but on a queue rather than a recursive stack). The ceilings are configuration tuned per tenant plan, not hard-coded into the kernel. What is not a design fork is whether the caps exist — they must, or one tenant starves others.
Metadata & deploy representation
Section titled “Metadata & deploy representation”The save order is a platform contract and has no component of its own — but every participant is a metadata component that declares its phase. Each is represented as canonical JSON:
{ "key": "invoice.margin_floor", "label": "Margin floor", "type": "validation_rule", // participant type implies its phase (validate) "body": { "phase": "validate", "tier": "pure", "after": ["invoice.derive_discounted_total"], "expr": "discounted_total < cost * 1.10", "error": "Discounted total is below the 10% margin floor." }}The lifecycle is retrieve → diff → deploy: retrieve pulls every participant attached to an object as canonical JSON; a diff is a text diff over body, so a reordering (an added after edge) or a tier change shows as a reviewable line change; deploy re-compiles the participants, re-runs tier/return-type checks, and rebuilds the object’s static execution order. Because the order is derived from participant declarations rather than a hidden global sequence, a deploy that would introduce a cycle or a tier violation fails at compile time, before it reaches an environment.
Salesforce Metadata API analog. Salesforce has no single “order of execution” metadata type; the order is implicit in the platform and materializes from the union of participating component types — ApexTrigger, Flow (record-triggered before/after variants and Process-Builder-type flows), ValidationRule, DuplicateRule + MatchingRule, AssignmentRule, AutoResponseRule, WorkflowRule + WorkflowFieldUpdate, EscalationRule, Entitlement processes, roll-up CustomField (type Summary), SharingCriteriaRule, and WorkflowAlert. An engineer reconstructs the runtime order by reading the order-of-execution doc and cross-referencing which of those components exist on the object — the order is never itself deployable or diffable. CAOS makes the order a computed, reviewable consequence of a single participant list.
Sources
Section titled “Sources”- Salesforce Apex Developer Guide — “Triggers and Order of Execution” — primary source for the 20-step order, the workflow-field-update re-fire, the roll-up parent/grandparent re-save, the recursive-save skip range (steps 9–17), and post-commit behavior. [1]
- Salesforce Apex Developer Guide — “Execution Governors and Limits” (
apex_gov_limits.htm) — primary source for the 16-level maximum recursive trigger/DML depth after which the platform throws aLimitException. [2] - S2 Labs — “Trigger Order of Execution in Apex” — corroborating secondary summary.
- SalesforceCodex — “Ultimate Guide to Apex Order of Execution” — corroborating secondary summary.