WorkflowsDefining Sagas

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 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 name, used for observability and preflight checks
steps[].onNoEvent type that triggers this step. Omit for the initial (idle) step
steps[].whenNoCondition on entity state. Step fires only when truthy
steps[].setYesMap of fields to write when entering this step
steps[].endNoIf 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: true

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

Note: 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_import

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

The 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_STATE

Use 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.ts

This 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 explicit when: 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: true is informational only. It is not enforced by the compiler or runtime. If a terminal step’s event fires again, its set: values merge again. Add a when: 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 — its set: values are instead used as the state_path field’s default value, so new entities start there.
  • state_path is auto-declared. You don’t need to add it to the entity’s fields: yourself — the compiler injects it as an object field on the saga’s state.