WorkflowsError Handling

Workflow Error Handling

Workflow errors in Causet fall into distinct categories. Each has different behavior and recovery paths.

Preflight Rejection

When an intent’s preflight rules fail, the intent is rejected immediately. No event is committed. No state changes. The saga does not advance.

The caller receives an error response with the code and message from the failing preflight rule:

preflight:
  rules:
    - name: must_be_parsing
      when:
        - path: /_tmp/import_saga/step
          op: eq
          value: 1
      error:
        code: WRONG_SAGA_STATE
        message: "ticket import must be in parsing state to match artist"

If _tmp/import_saga/step is not 1, the intent is rejected with WRONG_SAGA_STATE. The saga stays wherever it was.

Recovery: The caller must submit the correct intent for the current saga state, or submit a corrective intent to advance the saga manually.

Core Rule Failure

If a core rule throws a runtime error (e.g., type mismatch, invalid operation), the intent fails. No event is committed. No state changes.

This is distinct from preflight rejection — it’s an unexpected failure, not a business rule violation. These should be treated as bugs in the rules definition.

Recovery: Fix the rules DSL and redeploy. The intent can be retried once the rule is corrected.

Side Effect Failures

Side effects fire after the event is committed to the ledger. If an op: emit or op: submit fails:

  • The originating event is already committed — it cannot be rolled back
  • The infrastructure layer retries the failed side effect
  • If the submitted intent’s preflight rejects, the error is not propagated back

Warning: This means a committed event and a failed downstream submit can create inconsistency between the originating entity’s state and the downstream entity’s state. Design your preflight rules to handle re-submission gracefully.

Saga Stalling

A saga stalls when its current step’s triggering event never arrives. The entity stays in the current step indefinitely — there is no built-in timeout.

Detection: Project saga state and monitor for entities stuck in non-terminal steps:

projections:
  import_saga_health:
    source_events:
      - TICKET_PARSE_STARTED
      - TICKET_ARTIST_MATCHED
      - TICKET_METADATA_ENRICHED
      - TICKET_IMPORT_COMPLETE
      - TICKET_IMPORT_FAILED
    target:
      table: import_saga_health
      primary_key: [import_id]
    fields:
      import_id: TEXT
      saga_step: INT
      last_updated_at: BIGINT
    derive:
      import_id: event.entity_id
      saga_step: entity._tmp.import_saga.step
      last_updated_at: event.ts
SELECT import_id, saga_step, last_updated_at
FROM import_saga_health
WHERE saga_step BETWEEN 1 AND 3
  AND last_updated_at < extract(epoch from (now() - interval '2 hours')) * 1000
ORDER BY last_updated_at ASC;

Recovery: Submit a corrective intent that emits the expected event to advance the saga.

Failed Terminal States

Model explicit failure paths as terminal saga steps:

sagas:
  ticket_import_flow:
    state: ticket_import
    state_path: _tmp/import_saga
    steps:
      - name: idle
        set: { step: 0, outcome: "" }
      - name: parsing
        on: TICKET_PARSE_STARTED
        set: { step: 1, outcome: "" }
      - name: matched
        on: TICKET_ARTIST_MATCHED
        set: { step: 2, outcome: "" }
      - name: enriched
        on: TICKET_METADATA_ENRICHED
        set: { step: 3, outcome: "" }
      - name: complete
        on: TICKET_IMPORT_COMPLETE
        set: { step: 4, outcome: "success" }
        end: true
      - name: failed
        on: TICKET_IMPORT_FAILED
        set: { step: -1, outcome: "failed" }
        end: true

When TICKET_IMPORT_FAILED fires, the saga transitions to failed with end: true. No further saga transitions occur on this entity.

Corrective Reset Action

To allow recovery from a failed saga, define a reset action that clears state and returns the entity to idle:

actions:
  RESET_TICKET_IMPORT:
    intent_type: RESET_TICKET_IMPORT
    state: ticket_import
    entity_key: import_id
    preflight:
      rules:
        - name: must_be_failed
          when:
            - path: /_tmp/import_saga/step
              op: eq
              value: -1
          error:
            code: NOT_FAILED
            message: "ticket import is not in failed state"
    core:
      rules:
        - name: clear_import_fields
          when: {}
          then:
            - op: set
              path: /status
              value: "idle"
            - op: set
              path: /raw_data
              value: ""
            - op: set
              path: /artist_id
              value: ""
    events:
      - type: TICKET_IMPORT_RESET
        payload:
          import_id: intent.import_id
          reset_by: intent.operator_id

TICKET_IMPORT_RESET does not map to a saga step — the saga remains at step: -1. To re-start the saga, the caller submits TICKET_PARSE_STARTED again after the reset, which advances to step: 1.

Note: The saga step is set by the sagas: machinery when the triggering event fires. The reset action’s core rules clear domain state but do not directly overwrite _tmp/import_saga. The saga advances normally when the next saga-triggering event fires.

If you need to explicitly reset the saga step to 0, emit a custom event that your saga maps to an idle step. This requires that idle be reachable from failed, which requires modeling it as a non-terminal step:

- name: idle
  on: TICKET_IMPORT_RESET
  set: { step: 0, outcome: "" }

Observability: Saga Step Distribution

Track the distribution of saga steps across all entities:

SELECT saga_step, COUNT(*) as entity_count
FROM import_saga_health
GROUP BY saga_step
ORDER BY saga_step;

Alert when the count in a non-terminal step (1, 2, or 3) grows faster than entities reaching terminal steps (4 or -1). A widening gap indicates a systemic stall in the pipeline.

Error Handling Reference

Failure typeEvent committed?State changed?Recovery
Preflight rejectionNoNoSubmit correct intent
Core rule failureNoNoFix rules, retry intent
Side effect emit failureYesYes (core already ran)Infrastructure retry
Side effect submit rejectedYesYes (core already ran)Submit corrective intent
Saga stall (missing event)N/ANo changeSubmit corrective intent
Saga failed terminalYesYesSubmit reset intent