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”

LayerWhat it isWhere you author it
A — DSL sagas:Compiler macro → merge rules on _tmp/ + optional from: guards*.sagas.causet
B — Runtime reliability2PC / multi-entity abort compensation via reliability.compensating_intentAction 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 step

Fields

FieldRequiredDescription
stateYesEntity type this saga is bound to
state_pathYesPath in entity snapshot where saga state is written. Must begin with _tmp/
steps[].nameYesStep id — stamped as current_step when the step is entered
steps[].onNoEvent type that triggers this step. Omit for the initial (idle) step
steps[].fromNoList of prior step names; transition only fires if current_step is one of them
steps[].whenNoExtra condition on entity/event state. AND-ed with any from: guard
steps[].setYes*Map of fields to merge when entering this step (*idle may use set as defaults)
steps[].endNoIf 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.

StyleBehavior
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_stepfrom

Examples of generated guards:

fromApprox. 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: true

After 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 order

Multiple 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: true

Prefer 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_id

Use 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_STOCK

When 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>_stream
  • mode: core
  • when: { 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.

DetailBehavior
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: trueInformational 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-declareCompiler injects the object field on the entity state