Lifecycles, paths & approvals
A Lifecycle is a single metadata component that names the ordered Stages a record moves through, the Gate guarding each transition, and the Queue or owner a stage routes to. The stages are the statuses — the enumerated values of the record’s status field, not a separate picklist kept in sync with a guidance widget. A Gate is either a predicate (a pure boolean, evaluated inline) or an approval (an asynchronous sign-off whose decision the transition waits on). The stage-transition guard is tested in the WRITE phase of the save order of execution: a predicate gate is a pure check that runs entirely inside the save; an approval gate is checked — not conducted — there, because sign-off is a multi-actor, multi-day process that cannot block a transaction.
The model
Section titled “The model”A Lifecycle fuses four constructs Salesforce keeps in four separate features. Each has a defined place in the data and execution model.
- Lifecycle — the definition. Metadata attached to one object, naming its status field, its ordered stages, and the legal transitions between them. It is not a stored value; it is the compiled state machine the WRITE phase consults.
- Stage — a status value and a node in the state machine. The stage list is the domain of the status column: there is no second picklist, no drift between “the values the field can hold” and “the stages the lifecycle knows.” A stage carries its owner, its entry gate, and its display concerns (key fields, guidance) on the one definition. A retired value (
active: falsein the status field’s value set) is the one exception: records that already hold it keep it, but no record can be moved into it, so it needs no stage. A lifecycle may still declare a stage for it, and with declared transitions that is how a record holding it is given a way out. - Gate — the guard on a transition into a stage. Two kinds, one place in the model:
- A predicate gate is a pure boolean over the record (the same typed pure tier as a validation rule). It is evaluated inline in WRITE: the transition either satisfies the predicate and proceeds, or fails the predicate and rolls the save back. Deterministic, statically analyzable, no external effect.
- An approval gate requires a recorded sign-off decision before the transition may commit. The check is pure and lives in WRITE — “does an approval decision authorizing this transition exist?” The conduct of the approval (routing to approvers, waiting for responses, locking) is effectful and runs outside the save transaction, as record-triggered automation plus durable approval-instance state. WRITE tests the gate; it does not run the sign-off.
- Queue — a named assignment target: an ordered set of members (users/roles) that can own records or receive approval work. A stage owner is a person, a role, or a queue, uniformly.
Where the value lives. The current stage is a stored column on the record (the status field). The lifecycle definition is metadata. An in-flight approval is durable state in an approval-instance table, keyed to the record and the pending transition — this is what an approval gate reads in WRITE and what the async routing writes to.
Tier. The lifecycle definition and every predicate gate are pure (evaluated at save time, deterministic, push-down-able to a Postgres check). The approval conduct is effectful (event-fired automation that reaches multiple actors over time). The two tiers meet at the gate: a pure WRITE-phase check reads a decision that an effectful, asynchronous process produced.
Authoring
Section titled “Authoring”A Lifecycle is authored as one component. Its grammar is a small, closed vocabulary; the stage list doubles as the status field’s value set.
Lifecycle — top level:
key— content address of the component.object— the object it governs.status_field— the column whose values are the stages.stages— the ordered list of stage definitions (below). Order defines the default forward path.transitions— optional explicit legal-move listfrom → to. Omitted means any-to-any (guidance only, Salesforce-Path-like); present means the WRITE guard rejects any move not listed.
Stage — per entry in stages:
name— the status value (this is the picklist value; there is no separate picklist).owner—user|role|queue+ a reference; who holds the record at this stage.entry_gate— the Gate tested before a record may enter this stage (below). Absent = no gate (open transition).key_fields— fields surfaced for this step (render concern, folded in from Path — surfaced by the package-delivered record/path surface, not compiled into the engine).guidance— free-text tips/policy/links for this step (render concern, presented by the same package surface).
Gate — a discriminated union on type:
type: "predicate"— awhenpure boolean expression.true= transition allowed.type: "approval"— an approval configuration:submitters— who may submit for approval (role/group/user/owner/creator sets).approvers— an ordered list of steps; each step has:approver— one of the five approver types:user(a named user),hierarchy_field(a user named by a standard/custom hierarchy field — the manager pattern),related_user_field(a user lookup on the record),queue(route to a queue),adhoc(chosen at submit time). Theadhoctype is retained deliberately: it lets a submitter pick the next approver at runtime, a capability the classic Approval Process has (nextApproverIds) but Flow Approval Orchestration does not, so keeping it closes a real gap in the direction Salesforce is moving. [3][5]routing—unanimous(all listed approvers must approve) |first_response(first response decides) when a step lists multiple approvers in parallel.escalation— optional; the waiting period this step tolerates and where the decision goes when it expires:{ after: <duration>, to: <approver reference>, repeat: <n> }.totakes the same approver types asapprover, plushierarchy_up— one level above the current assignee. Omitted means the step waits indefinitely, which is a legitimate choice for a step whose assignee is the only person who may decide.if_rejected—reject_all(rejection ends it) |back_to_previous(return to prior approver).on_approve/on_reject— transition actions (field writes, notifications, outbound), fired as record-triggered automation.
lock—record(default; freeze the whole row) |fields:[...](freeze only the named columns) |none; what freezes while pending. The lock is enforced by the WRITE-phase permission check, not by mutating the record — see Semantics for how “pending” is read.editability— while locked:admin_only|admin_or_current_approver.allow_recall— may the submitter withdraw a pending request;on_recallactions fire on withdrawal.allow_delegate— may an assigned approver hand a pending item to their delegated approver (an alternate user named on the approver’s own record). The delegate receives the same request and may approve or reject, but cannot reassign or re-delegate — a single hop, matching Salesforce’s delegated-approver behavior. [6]
Worked example — an Invoice lifecycle where entering Sent needs a margin predicate, and entering Approved needs manager sign-off:
{ "key": "invoice.lifecycle", "object": "invoice", "status_field": "status", // the stages ARE this field's values "stages": [ { "name": "Draft", "owner": { "type": "user", "ref": "record.owner" } }, { "name": "Sent", "owner": { "type": "role", "ref": "inside_sales" }, "entry_gate": { "type": "predicate", // pure, evaluated INLINE in WRITE "when": "discounted_total >= cost * 1.10" // fails the margin floor -> transition rolls back with the gate's error }, "key_fields": ["discounted_total", "valid_until"], "guidance": "Confirm lead time and freight before sending." }, { "name": "Approved", "owner": { "type": "queue", "ref": "sales_ops_queue" }, "entry_gate": { "type": "approval", // WRITE checks for a decision; routing runs async "submitters": [{ "type": "role", "ref": "inside_sales" }], "approvers": [ { "approver": { "type": "hierarchy_field", "field": "owner.manager" }, "routing": "first_response", "escalation": { // moves the decision; never makes one "after": "48h", "to": { "type": "hierarchy_up" }, "repeat": 1 }, "if_rejected": "reject_all", "on_approve": "notify(record.owner, 'invoice_approved')" } ], "lock": "record", "editability": "admin_or_current_approver", "allow_recall": true, "on_recall": "set(status, 'Draft')" } } ], "transitions": [ { "from": "Draft", "to": "Sent" }, { "from": "Sent", "to": "Approved" }, { "from": "Sent", "to": "Draft" } ]}The predicate gate on Sent is a pure expression the compiler can push into a Postgres check. The approval gate on Approved declares async sign-off state; the WRITE guard reads its decision, and the routing/locking is effectful automation outside the save.
Semantics & evaluation
Section titled “Semantics & evaluation”Where the guard sits. The stage-transition guard is tested in the WRITE phase of the save order of execution — the same transactional phase that persists the row, maintains storage-enforced roll-ups, and tests constraints and RLS. A predicate gate that fails blocks the transition — a true rollback: the transaction unwinds, the status column never advances, and no partial state persists. An approval gate is different (see below): submitting for approval is itself a successful, committing save that advances the record to a pending-approval sub-state and durably persists the enqueued request — it is not a rolled-back transaction, so the “rolls back / no partial state” property applies to a failed predicate gate, not to approval submission.
Predicate gates — inline and pure. A predicate gate evaluates over the fully-shaped, validated record (SHAPE and VALIDATE have already run). It returns boolean; false blocks the transition. It reaches no other record and has no external effect, so a blocked predicate gate simply aborts the write with the gate’s message — nothing to unwind.
Approval gates — checked, not conducted. An approval gate’s WRITE-phase behavior is a read: it queries the approval-instance state for a committed decision authorizing this record’s move into this stage. Three outcomes: (a) an approving decision exists → the transition into the target stage commits; (b) no decision exists and no request is pending → the direct move into the target stage is not allowed yet, but submitting for approval is itself a successful, committing save: it advances the record to a pending-approval sub-state (or holds it at the pre-approval stage with a pending marker) and durably persists the enqueued approval request, in one commit — the routing, locking, and notifications then run as effectful automation after that commit and across later saves. This is a committing save to a pending state, not an in-transaction EFFECTS enqueue that a rollback could unwind; (c) a request is already pending → the guard rejects a premature direct move into the target stage. The later approve/reject decision arrives as a separate save that advances the stage (approved) or returns it (rejected); each approver’s response is its own committing save that writes the approval-instance state the guard later reads. The save transaction is never held open on a human.
Escalation moves the decision; it never makes one. A step’s escalation period starts when that step becomes the active step and its request is assigned, and it is measured in absolute elapsed time. Not business hours: a lifecycle owns no working calendar, and 48h means the same thing to an approver in every time zone while “two business days” does not. On expiry the assignment moves to to — exclusively. The previous assignee loses the pending item and can no longer act on it, because leaving two people holding one decision silently changes the step’s routing from what the author declared into something else, and a unanimous step that quietly acquires a second acceptable signer is no longer the control it was written to be.
Escalation is loud in three directions at once: the new assignee is notified, with the reason the item arrived and the period that elapsed; the previous assignee is notified that it left them; and the submitter is notified that their request moved and to whom. An escalation nobody was told about is indistinguishable from an approval request that was lost.
repeat bounds the chain — default 1, maximum 5 — and each hop restarts the period. The chain also terminates on resolution failure: when to resolves to nobody, because hierarchy_up has reached the top of the hierarchy or a target queue has no members, escalation stops and the request is marked stalled. Stalled is a visible state on the approvals surface with the elapsed time and the last assignee on it, and it is a state, not an outcome.
The hard rule underneath all of it: escalation never approves and never rejects. There is no on_timeout action, no auto-advance on expiry, and no configuration that converts elapsed time into a decision. The WRITE-phase check reads a recorded decision by a named approver; a period expiring produces neither, and a system that manufactured one would write into the audit trail a sign-off that no person made — which is the one thing an approval trail exists to prevent. A request nobody acts on stays pending until an approver decides, the submitter recalls it, or an administrator cancels it, and all three of those are attributable events with an actor on them.
Mechanically escalation is effectful and asynchronous: it is a scheduled occurrence on the durable queue, materialized when the step is assigned and cancelled when the step resolves. Like every other part of an approval instance it is pinned to the lifecycle version the request was submitted under, so shortening a period on a live lifecycle changes new submissions and leaves in-flight requests on the schedule they started with.
Null and blank handling. A record with a null status enters the lifecycle at the first declared stage (the initial state); no gate guards entry into the initial stage. A transition out of null into any gated stage is guarded normally. An approval gate that finds neither a decision nor a pending request treats “absent” as “not yet approved” — absence never reads as approval.
Determinism. Predicate gates are deterministic: same record, same result, independent of when the save was reached — like every pure participant, they are statically analyzable and reproducible. Approval outcomes are not deterministic (they depend on human decisions and time), which is exactly why the non-deterministic part is kept out of the pure WRITE check and isolated in async state.
Type contracts. A predicate gate’s when must type as boolean. A stage name must be a value of the status_field’s type. An approver reference must resolve to a user (directly, via a hierarchy/related field, or via a queue’s membership). The compiler enforces these at author time; an ill-typed gate or a stage name outside the status domain does not deploy.
Versioning and in-flight instances. A lifecycle is versioned, and every approval instance is pinned to the lifecycle version it was submitted under — the same generation-pinning that governs saves. A pending request references the step topology (indices, gate predicates, approver resolution) of its start version and finishes against that version regardless of later edits; only new submissions bind to the currently active version. This is why a live lifecycle can be edited without Salesforce’s clone-and-swap: adding a stage, changing a gate predicate, or reordering an approver step deploys as a new version while in-flight instances continue against their pinned one. Instances are never force-migrated forward or drained on deploy — migration corrupts the step indices a pending request depends on, so pinning is the only safe rule, and it is designed in rather than retrofitted.
Locking and pending state. While an approval is pending, lock freezes the record (record) or a named field set (fields:[...]) by tightening the WRITE-phase permission check for that instance — it does not stamp a flag onto the record that later automation must respect. Whether a record is “pending” is read from the approval-instance table (a committed marker keyed to the record and the pending transition), never inferred from the status column. Legitimate mid-approval writes — integrations, roll-up maintenance, record-triggered automation touching untouched fields — proceed against a fields:[...] lock without colliding with the sign-off, because the freeze is scoped to exactly the columns declared and the guard’s notion of “pending” lives in separate durable state.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”Salesforce’s caps come from splitting this problem across three subsystems, each with its own bounds. CAOS has one component, but the async approval machinery still needs fan-out and depth bounds — “unlimited” is an unshipped load problem, not an advantage.
| Concern | Salesforce’s exact limit / behavior (cited) | The constraint it guards | CAOS approach | Still need an equivalent? |
|---|---|---|---|---|
| Steps per approval process | 30 steps per process [3] | Each step is a synchronous routing node; caps unbounded routing chains | 50 steps per approval gate — headroom over the incumbent’s 30; a routing node is a cheap metadata row, and a hard bound still prevents an unbounded chain | Yes — a per-gate step bound is prudent |
| Approvers per step | 25 per step [3] | Bounds parallel fan-out (each approver = a work item + notification) | 25 parallel approvers per step — matched, not raised: 25 simultaneous approvers is already at the edge of a manageable “who still needs to sign off” view | Yes — fan-out must be bounded |
| Active policies per object | Multiple allowed; evaluated by list order, first match wins, only one runs per submission [3] | Lets one object carry many policies; order + criteria disambiguate | Multiple gates per transition with explicit priority + “why this fired” tracing | Parity of capability, better transparency |
| Total processes per object | exact cap not confirmed from a fetchable primary | Metadata-volume bound | Lifecycle is one component per object; gate count bounded per transition | Metadata-volume bound still applies |
| Total processes per org | exact cap not confirmed from a fetchable primary | Org-wide metadata bound | Same | Same |
| Post-activation edits | Frozen — cannot add/delete/reorder steps once active | In-flight requests reference step indices; mutation corrupts them | Versioned lifecycles; in-flight instances pinned to their start version, new submissions bind the active version | Better — versioning is built in, so a live lifecycle edits without clone-and-swap |
| Record editability while locked | admin-only or admin + current approver | Prevents mid-approval tampering | lock granularity record | fields:[...]; editability scope |
Yes — a locked-edit escape hatch is required |
| Path key fields per stage | incumbent shows a small handful | Render density on the path bar | 5 highlighted key fields per stage — a scannability guideline, not a hard cap; a wider path bar may show more where the layout allows | Presentational only |
| Path guidance length | incumbent keeps it short | Storage/render bound on guidance text | 4,000 chars of stage guidance — well above the incumbent’s terse limit, room for a real per-stage checklist, while still bounded so the panel stays coaching, not a document | Presentational only |
How Salesforce does it
Section titled “How Salesforce does it”Salesforce splits “move a record through stages” and “get sign-off” across three loosely-coupled features, none of which is a true guarded state machine:
- Path (metadata
PathAssistant) renders a chevron bar driven by one picklist on the object and attaches per-stage key fields and guidance. For Opportunity, Lead, and Quote the driving field is hard-coded toStageName/Status; only one path exists per record type per object. Path is guidance only — it enforces nothing: a user may move the picklist from any value to any value, and Path just re-renders. [1] - Classic Approval Process (metadata
ApprovalProcess) is the sign-off engine: an object-scoped, orderedapprovalStep[]sequence that routes to approvers, locks the record while pending (defaultinitialSubmissionActions), and fires approve/reject/recall actions. Approver types areuser,userHierarchyField(manager/custom hierarchy),relatedUserField,queue, andadhoc; parallel approvers per step areUnanimousvsFirstResponse; reject behavior isRejectRequestvsBackToPrevious; locked-record editability is admin-only or admin + current approver. Multiple active processes per object are allowed — on submit Salesforce evaluates each in list order and runs the first whose entry criteria match (first-match-wins; the rest do not run for that submission). This is not “one process per object.” [2][3][4] - Flow Approval Orchestration / Flow Orchestrator is the newer, strategic path: multi-user, multi-step orchestration on Flow (Stages + interactive/background Steps). Flow Approval Orchestration is GA as of Spring ’25, gaining recall/fault paths (Summer ’25) and debugging + an Approval Designer permission (Winter ’26); Flow Orchestration became a standard Flow type in early 2026 [5]. Salesforce is steering new work here, but no formal end-of-life for the classic Approval Process has been announced. [5]
There is no single Salesforce primitive where the stage is the status and the gate is the approval. An admin composes Path (guidance) + the picklist (status) + a validation rule or Flow (the only real transition guards) + an Approval Process (sign-off + lock), and keeps four metadata types consistent by hand with no referential guarantee that the path’s stages, the validation rule’s stage checks, and the approval’s entry criteria agree.
Where CAOS is genuinely better:
- One primitive instead of three, with no drift. The status domain, the transition guard, and the approval are the same definition — the stage list is the status field’s value set. There is no path-vs-picklist-vs-rule-vs-approval consistency to maintain because there are not four artifacts. The mechanism is a single compiled component whose stage list is authoritative for the column.
- A first-class transition guard in the WRITE phase. Salesforce’s stage field is ungated; Path enforces nothing and the only true guards are validation rules (separate artifacts) or approvals (which lock rather than guard). A CAOS gate tested in WRITE gives a uniform “no record enters Stage N unless predicate P / decision A holds,” enforced by the transaction itself. The mechanism is the fixed save order plus a Postgres-enforced guard.
- Gate = predicate or approval, uniformly. In Salesforce a rule-gate (validation rule/Flow) and a sign-off-gate (Approval Process) are entirely different subsystems — different config, limits, metadata, and failure modes. Modeling both as a gate on a transition is a smaller, cleaner surface.
- Deterministic policy ordering with tracing. Where Salesforce silently runs the first matching process by list order, a CAOS transition carries explicit gate priority and records which gate fired and why.
Where CAOS is only parity (match, do not reinvent): the five approver types (including runtime adhoc); unanimous vs first-response parallel semantics; lock-on-pending with an admin/current-approver escape hatch; recall with actions; reject-to-previous vs reject-to-start; single-hop delegated approvers (a delegate may approve or reject but not reassign or re-delegate, matching Salesforce exactly); per-transition actions; and per-stage key-fields + guidance (Path’s one good idea, folded in as a render concern on the same definition).
Costs and risks:
- The async-approval boundary is the #1 thing to get right. A predicate gate runs inline in WRITE; an approval gate cannot. The WRITE phase may only check for an existing decision and enqueue the request — it must never try to conduct sign-off inside the transaction, or every save deadlocks on human response. The “predicate or approval, in the WRITE phase” claim holds for the check, not the conduct.
- The activation-freeze advantage is bought with versioning, and versioning has a standing cost. Salesforce freezes step topology on activation because in-flight requests reference step indices — correct engineering, not a bug. CAOS instead pins each in-flight instance to its start version, which is what lets a live lifecycle be edited; the price is that the engine carries multiple concurrent lifecycle versions and their pinned instances until the old ones drain. That bookkeeping is designed in from day one — pinning cannot be retrofitted onto instances that never recorded which version they started under.
- Lock granularity is a declared scope, not a global switch. A record-wide lock (
lock: "record", the Salesforce-style default) fights triggers, integrations, and automation that legitimately touch the record mid-approval; a field-level lock (fields:[...]) avoids that but obligates the author to name exactly which columns freeze. Both are supported, the default isrecordfor parity, and the guard reads “pending” from the approval-instance marker rather than the record, so a narrow field lock and legitimate mid-approval writes coexist. - First-match-wins is a footgun to improve, not merely inherit. Copying Salesforce’s “first matching policy runs” without the priority + tracing reproduces the surprise where a mis-ordered policy silently applies the wrong sign-off.
- Position against the moving target. Flow Orchestrator already moves Salesforce toward “stages + steps + approvals as one orchestration.” CAOS’s differentiation is the guard-in-the-save-order and the single fused definition — not a claim that Salesforce cannot orchestrate multi-step approvals. Benchmark against Flow Orchestrator’s current capability, not the legacy Approval Process strawman.
Metadata & deploy representation
Section titled “Metadata & deploy representation”A Lifecycle is one metadata component in canonical JSON — key / label / type / body — with the whole state machine (stages, gates, transitions) in body:
{ "key": "invoice.lifecycle", "label": "Invoice lifecycle", "type": "lifecycle", "body": { "object": "invoice", "status_field": "status", "stages": [ /* stage defs incl. owners, entry_gate, key_fields, guidance */ ], "transitions": [ /* legal from -> to moves */ ] }}The lifecycle is retrieve → diff → deploy: retrieve pulls the one component per object as canonical JSON; a diff is a text diff over body, so an added stage, a changed gate predicate, a new approver step, or a reordered transition each shows as a reviewable line change — the status domain and the guard change together, in one diff, because they are one component. Deploy re-compiles the lifecycle, re-runs type/tier checks (predicate gates must type boolean; stage names must be status-field values; approver references must resolve), rebuilds the object’s transition guard, and stamps a new lifecycle version while pinning in-flight approval instances to the version they started under.
Salesforce Metadata API analog. Salesforce has no single lifecycle type; the same behavior materializes from the union of separate components: PathAssistant (with entityName, fieldName, pathAssistantSteps[] → picklistValueName/fieldNames/info) for guidance; the object’s status CustomField picklist for the stage domain; ValidationRule and/or record-triggered Flow for any real transition guard; and ApprovalProcess (with approvalStep[], allowedSubmitters, entryCriteria, recordEditability, finalApprovalRecordLock, and the NextOwnerType/RoutingType/StepCriteriaNotMetType enums) for sign-off. An engineer reconstructs “the lifecycle” by reading four metadata types and trusting they agree; CAOS makes the whole machine one reviewable, diffable component.
Sources
Section titled “Sources”- PathAssistant — Metadata API Developer Guide — Path structure, hard-coded
entityName/fieldNamefor Opportunity/Lead/Quote,pathAssistantSteps[], one-path-per-record-type. [1] - ApprovalProcess — Metadata API Developer Guide — steps, submitter/approver types,
Unanimous/FirstResponse, reject behaviors, action buckets,recordEditability, record lock. [2] - Process Approvals (POST submit/approve/reject) — REST API Developer Guide —
actionType,contextId,nextApproverIds(runtime adhoc approver),skipEntryCriteria. [3] - Trailhead — Guide to Salesforce Record Approval Processes — record lock as the default initial submission action. [4]
- Salesforce Ben — Spring ’25 Flow Approval Process capabilities — Flow Approval Orchestration GA timeline and gaps; classic-vs-Flow direction. [5]
- SalesforceLWC — Approval Processes end-to-end setup guide — multiple active processes per object, first-match-by-order, 30-steps figure (fetchable secondary).
- Salesforce Help — Considerations for Delegated Approvers — delegate may only approve/reject, cannot reassign or re-delegate (single hop). [6] [3]