Defining Sagas
How this executes. sagas: is macro-expanded by the compiler into ordinary event-triggered core rules — no separate saga execution engine runs at runtime. See Implementation Notes below for what this means in practice.
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.
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>
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 name, used for observability and preflight checks |
steps[].on | No | Event type that triggers this step. Omit for the initial (idle) step |
steps[].when | No | Condition on entity state. Step fires only when truthy |
steps[].set | Yes | Map of fields to write when entering this step |
steps[].end | No | If true, this is a terminal step — saga is complete |
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
You can add any fields under _tmp/ in your entity state: definition to support saga logic:
state:
ticket_import:
entity_key: import_id
fields:
- name: _tmp/saga_step
type: int
default: 0
- name: _tmp/saga_name
type: string
default: ""Full Example: Ticket Import Flow
This saga tracks a ticket import entity through parsing, matching, enrichment, and completion.
sagas:
ticket_import_flow:
state: ticket_import
state_path: _tmp/import_saga
steps:
- name: idle
set: { step: 0 }
- name: parsing
on: TICKET_PARSE_STARTED
set: { step: 1 }
- name: matched
on: TICKET_ARTIST_MATCHED
set: { step: 2 }
- name: enriched
on: TICKET_METADATA_ENRICHED
set: { step: 3 }
- name: complete
on: TICKET_IMPORT_COMPLETE
set: { step: 4 }
end: true
- name: failed
on: TICKET_IMPORT_FAILED
set: { step: -1 }
end: trueThis saga has two terminal paths: complete (step 4) and failed (step -1). Both are marked end: true.
Multiple Terminal Paths
You can define any number of terminal steps. Common patterns:
steps:
- name: complete
on: PURCHASE_COMPLETED
set: { step: 2, outcome: "success" }
end: true
- name: expired
on: PURCHASE_EXPIRED
set: { step: -1, outcome: "expired" }
end: true
- name: cancelled
on: PURCHASE_CANCELLED
set: { step: -2, outcome: "cancelled" }
end: trueNote: Once a saga reaches a terminal step (
end: true), subsequent events on the entity don’t re-enter the saga. If the entity processes another intent that emits a triggering event, the saga stays in its terminal state unless you explicitly reset it via a corrective intent.
Saga State in the Entity Snapshot
After the projection worker processes the TICKET_PARSE_STARTED event, the entity snapshot for that ticket_import entity will contain:
{
"_tmp": {
"import_saga": {
"step": 1
}
}
}You can inspect this via the CLI:
causet inspect entity import_abc123 --fork main --stream ticket_importIntegrating Sagas with Actions
Actions emit events that advance the saga. The action defines the event, the saga defines what that event means for step progression.
actions:
START_TICKET_PARSE:
state: ticket_import
entity_id_expr: intent.import_id
input:
import_id: { type: string, required: true }
preflight:
rules:
- name: must_be_idle
when: { expr: "entity._tmp.import_saga.step != 0" }
then:
- op: reject
code: WRONG_SAGA_STATE
core:
rules:
- name: mark_parsing
when: {}
then:
- op: set
path: /status
value: "parsing"
side_effects:
rules:
- name: emit_parse_started
then:
- op: emit
event_type: TICKET_PARSE_STARTED
payload:
import_id: intent.import_idThe preflight checks _tmp/import_saga/step to ensure the action is only allowed in the correct saga state. This prevents out-of-order transitions.
Reading Saga State in Preflight
The _tmp/import_saga/step path uses slash notation to navigate into the nested saga state object:
preflight:
rules:
- name: must_be_in_step_2
when: { expr: "entity._tmp.import_saga.step != 2" }
then:
- op: reject
code: WRONG_SAGA_STATEUse dot notation (entity._tmp.import_saga.step) in expr conditions, and slash notation (/_tmp/import_saga/step) for path targets in set/add/mutation ops.
Saga Visibility via Projections
To monitor saga step distribution across all entities, project the saga step field:
projections:
ticket_import_saga_status:
source_events:
- TICKET_PARSE_STARTED
- TICKET_ARTIST_MATCHED
- TICKET_METADATA_ENRICHED
- TICKET_IMPORT_COMPLETE
- TICKET_IMPORT_FAILED
target:
table: ticket_import_saga_status
primary_key: [import_id]
fields:
import_id: TEXT
saga_step: INT
saga_outcome: TEXT
updated_at: BIGINT
derive:
import_id: event.entity_id
saga_step: entity._tmp.import_saga.step
saga_outcome: entity._tmp.import_saga.outcome
updated_at: event.tsThis table lets you query how many imports are in each step, how many have failed, and how long entities have been stuck in a given step.
Implementation Notes
Each step with on: lowers to a generated rule named saga_<saga_name>_<step_name> — stream: <state>_stream, mode: core, when: { event_type: <on> }, then: [{ op: merge, path: /<state_path>, value: <set> }]. It runs through the exact same rule engine as every hand-written core rule. Two things fall out of that:
- No built-in step-order guard. A step’s rule fires whenever its
on:event commits on that entity’s stream — regardless of the entity’s current step. If out-of-order events must be rejected, add an explicitwhen:guard (as shown in Integrating Sagas with Actions above), or gate the action that emits the triggering event with a preflight check, same as any other rule. end: trueis informational only. It is not enforced by the compiler or runtime. If a terminal step’s event fires again, itsset:values merge again. Add awhen:guard checking the current step isn’t already terminal if re-entry must be prevented.
Two more mechanical details:
- The idle step (no
on:) never generates a rule. There’s no event to trigger it — itsset:values are instead used as thestate_pathfield’s default value, so new entities start there. state_pathis auto-declared. You don’t need to add it to the entity’sfields:yourself — the compiler injects it as anobjectfield on the saga’sstate.