Workflows

Architecture, actors, and enforcement

Who the engine acts as, where a condition can read the caller, and which of the engine’s checks would stop a client that bypasses it.

Workflows attributes every move to an actor, records how the call was made, and checks a series of rules before it commits. This page names which identity does what, where a condition can read the caller, and which of those checks would stop someone who bypasses the engine entirely.

The short answer to the last question: none of them. The Content Lake is the only enforcement point. The rest of this page is what that means in practice.

Actor identity and tokens

Every call into the engine carries one token, and that token does three jobs at once. It identifies the actor the move is attributed to, it performs the engine’s own writes, and it drives any transitions that fire as a consequence. The engine has no identity of its own and no way to act as anything other than the caller.

The engine resolves the actor from the token. There is no parameter that passes one and no way to synthesize one. An actor carries a kind of person, agent, or system, an id that is the account-global user id, and an optional list of role names copied from the token’s roles as its host reports them.

Role names are plain strings, matched exactly. An action’s roles and an assignee’s roles match if any one of them is in the actor’s list. There is no wildcard on the actor side: a "*" in an actor’s roles is only a string, and it matches nothing on its own. To let one role satisfy a gate that does not name it, declare the definition’s roleAliases map, which widens the required side rather than the actor’s.

Work the engine fires for itself runs on the same token. A cascade-fired action, one carrying a when that the engine fires the moment its condition holds, executes on whichever legitimate call drove the cascade. There is no synthetic engine actor behind it. An action can pin who may execute it with roles. If the current token does not satisfy that pin, the trigger stays armed and fires on the next cascade that does. History records the executing token together with a triggered marker, so an automated firing is never read as a caller invoking the action. See Activities and actions for how an action resolves an activity.

An actor stamp records where a move came from. It is not a verified identity: the engine does not cryptographically bind an actor, or a declared execution context, to the write token, and nothing re-checks the stamp when the record is read. The Content Lake’s own document history is the authenticated record of who committed a mutation, and the workflow history sits beside it rather than replacing it. The workflow history explains what the workflow did. Content Lake history proves who wrote what.

A stamp records who acted at the time. It stores no profile data, because profiles change, so a surface that shows a person resolves the stamp to a current project user when it renders. Agent and system actors are never looked up as project users. Studio and App SDK integrations expose native project-user directories for this, and resolveActor is the engine’s version.

Execution context

Alongside the actor, the engine stamps an execution context on every history entry it appends. The actor answers who acted; the execution context answers what they acted through. It holds an inferred runtime that is always present, plus an optional kind and id the host declares once when it calls createEngine.

You configure the declared half once per engine, never per call, and it is never an authorization input. Nothing in the engine gates on it. It exists so someone reading the audit trail can tell a Studio session from a CLI run from an effect drainer. The shipped kind vocabulary is interactive, server, cli, mcp, drainer, script, test, studio, and sdk-app, and the field is a free string rather than a closed set.

The stamp is self-declared, which is exactly why it stays separate from the actor. A server proxy acting with a user’s token shows up as that user, acting through that proxy, instead of forging an identity.

Caller-bound condition variables

A condition can read the caller through five variables: $actor for the identity, $assigned for whether they are an assignee, $can for their advisory grants, $attributes for their org-level user attributes on Enterprise plans, and $params for the arguments of the action being fired. None of these is available everywhere, and a condition that reads one where it holds no value fails closed.

Three sites are caller-bound: the filter on an action a caller fires, an activity’s requirements, and an editable predicate. They bind $actor, $assigned, $can, and $attributes. $params is not bound at those sites. It holds the fired action’s arguments only while that action’s effect bindings and its operations’ where expressions evaluate. A cascade-fired action never binds it.

Cascade gates bind none of the five. A transition’s when, an activity’s filter, and a cascade-fired action’s when and filter are re-evaluated by whatever legitimate call comes along, so they have to give the same answer no matter whose token that is. Deploy rejects a caller read at those sites rather than letting it quietly never match. Gate on instance state instead, such as a field an action wrote, or pin the executing identity with roles. See Conditions for the full variable scope at each site.

The Content Lake’s identity() function is not one of these. It exists only inside a guard’s predicate, which the lake evaluates. It is not available in a workflow condition, which the engine evaluates.

Advisory checks and Content Lake enforcement

Workflows gives you three ways to restrict what happens to content. They work together, but they are not three levels of the same thing: only one of them is enforced by anything other than cooperation.

Mechanism

Evaluated by

Stops a determined client

Engine verdicts: action filters, roles, requirements, editability

The engine, in your process

No

Guards

The engine, in your process

No, not today

Dataset access control and custom roles

The Content Lake

Yes

Engine verdicts are advisory by design. They tell a surface which controls to disable and why, and they fail a call that cannot succeed before it writes anything. Anyone whose token allows a raw Content Lake mutation can skip the engine and every verdict in it. For cooperative teams and internal tools that is useful guidance with honest limits. It is not a security boundary.

Evaluation insights share that status. They describe the engine’s current advisory evaluation, explaining a verdict rather than enforcing it, and they do not replace Content Lake permissions.

Guards sit on the advisory side of that line today. This is the part people most often get wrong. A deployed guard is stored as a temp.system.guard document, and the Content Lake does not evaluate that document type. It is a placeholder for the guard primitive the lake will get later. Until that ships, a deployed guard denies engine-side only, on every project plan, and dataset access control is the only hard gate. See Guards and enforcement for what a guard declaration contains.

Dataset access control is evaluated server-side by the Content Lake on every write, whichever client makes it. It is the only one of the three that holds against a token the engine never sees.

Enforcing rules in the Content Lake

When a rule has to hold regardless of which client writes, put it in the Content Lake as dataset access control or a custom role. Decide this per rule rather than per workflow: a definition can rely on advisory guidance for the parts that coordinate cooperative editors, and on access control for the parts that must not be bypassed.

Do not treat a guard as that boundary yet. A guard becomes lake-enforced when the lake’s guard primitive ships and the temp. prefix drops from the stored document type. Until then, deploying one gives you a workflow-aware surface that previews and explains a hold. That is worth having, but it is not the same guarantee.

One consequence to plan around, while both sides ride the caller’s token: the engine deploys, refreshes, and retracts its own guards with whatever token made the call. So a lake-enforced restriction that an editor’s token cannot satisfy can block the engine’s own housekeeping for that editor, and leave them stuck in a state they can neither advance nor retract. Doing this reliably would need a separate execution identity for the engine’s own writes, and that does not exist today. How early access works tracks what is and is not enforced yet.

Instance transactions

An instance document is a state machine, not ordinary content. Its current stage, resolved fields, history, idempotency records, pending effects, child workflows, and guards must describe one committed state.

Every state-changing verb validates first and writes nothing when rejected. Accepted changes commit the instance and its history and audit trail together, then continue the cascade until no triggered action or transition can fire.

Automatic cascade hops reload and reevaluate after a parent instance revision conflict, up to three attempts. Exhaustion throws ConcurrentCascadeError with kind 'concurrent-cascade'. Earlier commits may already have landed. Reread the instance and call tick after contention subsides to resume progress.

abortInstance throws AbortDeniedError with kind abort-denied before writing when the definition's abort block does not admit the caller. The check applies to every caller, Administrators included. Its reason is the same filter-failed verdict that WorkflowEvaluation.abort reports. See who may abort.

WorkflowErrorKind also includes operation-failed, carried by WorkflowOperationError with the failure's code, message, and optional details. It reports an operation sent to a Workflows service that failed outside the engine; no engine call throws it.

A UI, CLI, or runtime enters through an engine verb. The engine evaluates the request, commits instance state in the Content Lake, refreshes guards, and runs the transition cascade.

Do not patch instance documents through a Studio form, App SDK edit state, or a raw client. Those writes are built for content: they can merge individual paths without the engine’s checks, its history, or its transaction boundary.

Reading is different. A reactive experience may evaluate against an editor’s uncommitted content, but the result is only a preview. Stored instance state still changes only through engine verbs.

Reader-model compatibility

The engine writes model 11 documents. The baseline reader floor is 4. Role-constrained assignment fields require model 8. Singular assignee fields and guards combining draft edits, publication, or direct create/delete actions across more than one of those groups require model 9. Definitions with a required subject, doc.ref, or doc.refs field, or an effect retry policy, require model 10. Definitions with an abort block, and their instances, require model 11. Follow Upgrade Workflows packages before enabling affected writers.

An existing instance with one of these mixed-action guards can acquire reader floor 9 on its first new-engine commit, without redeploying its definition. Upgrade every shared runtime first. The floor never decreases, so downgrading packages does not restore older-reader access.

Runtimes and engine verbs

Nothing runs in the background watching your content and pushing workflows along. @sanity/workflow-engine is a library your process imports. It evaluates rules and commits state when your code calls it, and does nothing at all in between. The runtime is whatever you build around it.

The engine’s work has to be repeatable: the same instance and inputs always produce the same decision. Outside work, such as sending an email, is not repeatable, because it can fail, be slow, or happen twice. So the engine queues that work as an effect and leaves delivery and retries to an effect runtime. Run an external step with an effect builds one.

A runtime calls a small set of verbs. None of them runs unless something calls it.

Verb

Call it when

Who calls it in practice

fireAction

Someone or something acted: an editor approved, a webhook arrived. Only an action without a when condition is caller-fired.

The Studio plugin or your own UI, a webhook receiver, an operator at the CLI, an agent through the MCP server.

tick

Something changed that the engine cannot see: the subject document was edited, a deadline passed. Re-evaluates and advances the instance as far as it can.

A Document Function reacting to a content change, a Scheduled Function on the clock, a reactive session holding the live document.

evaluate

A surface needs to know what an actor can do right now, and why not the rest. A pure read that never writes.

Anything rendering workflow controls.

Every one of those callers is a runtime. A reactive session calls the engine from a UI, a drainer calls it from a server, and a scheduled function calls it on the clock. The engine is the rules they all run. The Workflows introduction compares the surfaces you can build a runtime on.

Product layers and surfaces

The engine owns the domain model and the transaction rules. The store-agnostic React package drives a reactive session over it. The App SDK adapter supplies cross-resource document observation. The Studio adapter uses shared App SDK document stores, and the Studio plugin builds the Workflows interface on top of those layers. Tentative field values reach evaluation only through explicit previewField projections, never through Studio’s uncommitted form buffer.

The CLI, MCP server, workers, and other server-side applications can call the engine directly.

Next steps

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme