Defining Sagas
How this executes. sagas: is macro-expanded by the compiler into ordinary event-triggered core rules — no separate Temporal-style saga execution engine runs at runtime. See Implementation Notes below.
A saga is a named state machine bound to a single entity type. Each saga tracks one linear or branching process on that entity and persists its state in the entity snapshot under a _tmp/ path.
Two meanings of “saga”
| Layer | What it is | Where you author it |
|---|---|---|
A — DSL sagas: | Compiler macro → merge rules on _tmp/ + optional from: guards | *.sagas.causet |
| B — Runtime reliability | 2PC / multi-entity abort compensation via reliability.compensating_intent | Action metadata + runtime coordinator |
This page is primarily about Layer A. Runtime reliability covers Layer B so the names don’t get conflated.
Saga DSL syntax
sagas:
<saga_name>:
state: <entity_type>
state_path: _tmp/<path>
steps:
- name: <step_name>
set: { <field>: <value> }
- name: <step_name>
on: <EVENT_TYPE>
from: [<prior_step>, ...] # optional composition guard
when: { expr: "..." } # optional; AND-ed with from
set: { <field>: <value> }
end: true # optional: marks terminal stepFields
| Field | Required | Description |
|---|---|---|
state | Yes | Entity type this saga is bound to |
state_path | Yes | Path in entity snapshot where saga state is written. Must begin with _tmp/ |
steps[].name | Yes | Step id — stamped as current_step when the step is entered |
steps[].on | No | Event type that triggers this step. Omit for the initial (idle) step |
steps[].from | No | List of prior step names; transition only fires if current_step is one of them |
steps[].when | No | Extra condition on entity/event state. AND-ed with any from: guard |
steps[].set | Yes* | Map of fields to merge when entering this step (*idle may use set as defaults) |
steps[].end | No | If true, documents a terminal step — see Implementation Notes |
The _tmp/ prefix
The _tmp/ prefix designates scratch space in the entity snapshot. These fields are:
- Written and read by saga machinery
- Available to rules via
entity._tmp.<field> - Not intended for long-term domain state
state_path is auto-declared by the compiler as an object field on the saga’s state — you do not need to add it under fields: yourself (you may still declare extra _tmp/ scalars if you want them).
current_step and from: composition
Every transition merges current_step: <step.name> into the saga object. That field is the stable handle for composition guards and preflight checks.
| Style | Behavior |
|---|---|
No from: | Choreography — the step’s rule fires whenever its on: event commits, regardless of prior step |
With from: | Orchestration-lite — compiler emits when.expr requiring entity.<state_path>.current_step ∈ from |
Examples of generated guards:
from | Approx. when.expr |
|---|---|
[idle] | entity._tmp.order_saga.current_step == "idle" |
[idle, placed] | contains(["idle", "placed"], entity._tmp.order_saga.current_step) |
Unknown names in from: are lint errors at compile time.
Full example: order lifecycle
sagas:
order_lifecycle:
state: order
state_path: _tmp/order_saga
steps:
- name: idle
set: { status: idle }
- name: placed
on: ORDER_PLACED
from: [idle]
set: { status: placed }
- name: confirmed
on: ORDER_CONFIRMED
from: [placed]
set: { status: confirmed }
end: true
- name: cancelled
on: ORDER_CANCELLED
from: [idle, placed]
set: { status: cancelled }
end: trueAfter ORDER_PLACED commits, the entity snapshot includes:
{
"_tmp": {
"order_saga": {
"status": "placed",
"current_step": "placed"
}
}
}Inspect via CLI:
causet inspect entity order_abc123 --fork main --stream orderMultiple terminal paths
You can define any number of terminal steps:
steps:
- name: complete
on: PURCHASE_COMPLETED
from: [payment_captured]
set: { outcome: "success" }
end: true
- name: expired
on: PURCHASE_EXPIRED
from: [pending]
set: { outcome: "expired" }
end: true
- name: cancelled
on: PURCHASE_CANCELLED
from: [idle, pending]
set: { outcome: "cancelled" }
end: truePrefer from: (and action preflight) so terminal steps are not re-entered accidentally. end: true alone is not a hard lock — see Implementation Notes.
Integrating sagas with actions
Actions emit events that advance the saga. Gate the action with preflight on current_step (or your own step field) so clients get a clear reject instead of a silent no-op when from: blocks the merge.
actions:
CONFIRM_ORDER:
state: order
entity_id_expr: intent.order_id
input:
order_id: { type: string, required: true }
preflight:
rules:
- name: must_be_placed
when: { expr: "entity._tmp.order_saga.current_step != \"placed\"" }
then:
- op: reject
code: WRONG_SAGA_STATE
core:
rules:
- name: mark_confirmed
when: {}
then:
- op: set
path: /status
value: "confirmed"
side_effects:
rules:
- name: emit_confirmed
then:
- op: emit
event_type: ORDER_CONFIRMED
payload:
order_id: intent.order_idUse dot notation in expr (entity._tmp.order_saga.current_step) and slash notation for mutation paths (/_tmp/order_saga/...).
Saga visibility via projections
projections:
order_saga_status:
source_events:
- ORDER_PLACED
- ORDER_CONFIRMED
- ORDER_CANCELLED
target:
table: order_saga_status
primary_key: [order_id]
fields:
order_id: TEXT
current_step: TEXT
status: TEXT
updated_at: BIGINT
derive:
order_id: event.entity_id
current_step: entity._tmp.order_saga.current_step
status: entity._tmp.order_saga.status
updated_at: event.ts
mutations:
ORDER_PLACED: { op: upsert }
ORDER_CONFIRMED: { op: upsert }
ORDER_CANCELLED: { op: upsert }Runtime reliability (compensation)
DSL sagas: do not automatically undo prior steps when a process fails. Model domain rollback with explicit actions (reset intents, compensating events) on the same entity.
Separately, the runtime reliability layer can compensate multi-entity work that was prepared under strong/2PC-style coordination. Declare a compensating action on the forward action:
actions:
RESERVE_STOCK:
state: inventory
reliability:
compensating_intent: RELEASE_STOCKWhen a coordinated prepare/commit path aborts, the runtime may submit the compensating intent for participants that already prepared. That machinery (SagaCoordinator / compensation engine) is not the same as the sagas: DSL macro — it does not read your _tmp/ step machine.
For declarative multi-party prepare/commit in the product DSL, prefer Commit envelopes.
Implementation notes
Each step with on: lowers to a generated rule named saga_<saga_name>_<step_name>:
stream: <state>_streammode: corewhen: { event_type: <on>, expr: <from-guard && step.when> }then: [{ op: merge, path: /<state_path>, value: <set + current_step> }]
It runs through the same rule engine as hand-written core rules.
| Detail | Behavior |
|---|---|
from: | Preferred way to enforce step order; unknown step names fail the saga linter |
No from: | Any matching event fires — add when: or action preflight if order matters |
end: true | Informational for authors/docs; not a hard runtime lock. Prevent re-entry with from: / when: / preflight |
Idle step (no on:) | Does not generate a rule; its set: seeds defaults for state_path |
state_path auto-declare | Compiler injects the object field on the entity state |
Related
- Workflows overview
- Commit envelopes — multi-party prepare/commit in DSL
- Destinations — export committed events that advance sagas downstream