A SOAR playbook is a distributed transaction wearing a flowchart’s clothing. Each step calls a different vendor API, any step can fail or time out, and — unlike a database transaction — you cannot roll the real world back with a single ROLLBACK. This page is part of the broader SOAR playbook & response automation discipline, and it builds the engine that every other response workflow runs on: a durable step state machine that executes a playbook’s steps in order, makes each step idempotent, compensates completed steps when a later one fails, and survives a worker crash without re-quarantining a host or re-paging an analyst. Get orchestration wrong and a half-run playbook leaves an account disabled, a ticket unopened, and no record of which step failed.

Problem Framing

Consider a concrete failure scenario. A credential-theft playbook has five steps: (1) revoke the user’s active sessions in the identity provider, (2) isolate the affected endpoint via EDR, (3) enrich the case with threat-intel context, (4) open a P1 ticket, and (5) notify the on-call responder. During an incident storm the EDR API (step 2) starts returning HTTP 503 intermittently, and one worker is OOM-killed by the platform mid-playbook. Without orchestration discipline, three things go wrong at once: the session revoke (step 1) is re-run when the case is redelivered and the engine has no memory that it already succeeded; the isolate step is retried so aggressively it trips the EDR vendor’s abuse throttle; and the crashed worker’s case is lost, so the ticket and notification never happen and no human ever learns the endpoint was left half-contained.

The job of an orchestration engine is to make that scenario boring: step 1 runs exactly once even across redelivery, step 2 retries with backoff and then compensates cleanly if it cannot confirm success, and the crashed worker’s case is resumed from its last durable checkpoint by another worker. That requires three things working together: a persisted step-level state machine, a deterministic idempotency key per step, and a compensation (saga) handler per reversible step. The rest of this page builds exactly that.

Prerequisites & Environment

The reference implementation targets Python 3.11+ (it relies on asyncio.TaskGroup and modern typing). Dependencies are redis for durable step state and pydantic for the case contract; everything else is standard library.

python3 -m venv .venv
source .venv/bin/activate
pip install "redis>=5.0,<6.0" "pydantic>=2.6,<3.0"

Infrastructure assumptions:

  • A Redis 7 instance reachable from every playbook worker, holding per-case step state so any worker can resume any case. A relational store works equally well; the requirement is durability and atomic per-step updates, not Redis specifically.
  • A decision-grade case stream — the scored, correlated cases produced upstream. Orchestration assumes the case already carries an entity, severity, and confidence; if yours does not, that is a correlation-layer responsibility handled by the alert correlation rule engines.
  • A dead-letter queue for cases whose playbook cannot complete even after compensation, so a stuck case is visible and replayable rather than silently abandoned.

Every integration client (EDR, IdP, ticketing) must expose both an apply and a compensate coroutine, and both must be safe to call more than once.

Architecture Overview

The engine is a single logical stage that walks a playbook’s ordered steps, checkpointing each transition to durable state. Completed reversible steps are pushed onto a compensation stack; if a later step fails terminally, the stack is unwound in reverse before the case is dead-lettered.

Durable playbook step state machine with saga compensation A case loads its durable step state, then executes the next pending step guarded by an idempotency key that turns a retry into a no-op. A successful step checkpoints to state and pushes onto a compensation stack, then advances. A step that fails after retries triggers the compensation handler, which unwinds the stack in reverse order and routes the case to a dead-letter queue as ERR_PLAYBOOK_051. When all steps succeed the case is marked complete. Load durable step state resume from last checkpoint Pending step? Run step (idempotent) idempotency key → no-op on retry Confirmed? Checkpoint + push compensation advance to next step Mark case complete Unwind saga compensate in reverse ERR_PLAYBOOK_051 yes confirmed none left no next step

The pivotal design decision is that state transitions are checkpointed before the engine trusts them. A step is not “done” because the code returned; it is done because the durable store recorded it done and the compensation entry was pushed atomically. That ordering is what lets any worker resume any case: on load, the engine replays completed steps as no-ops (their idempotency keys are already burned) and continues from the first pending step.

Step-by-Step Implementation

Step 1 — Model the playbook and its case contract

A playbook is an ordered list of named steps, each with an apply and a compensate coroutine. The case is the typed object every step reads and writes.

from __future__ import annotations

from dataclasses import dataclass
from typing import Awaitable, Callable, Optional

from pydantic import BaseModel, Field

ActionFn = Callable[["Case"], Awaitable[None]]


class Case(BaseModel):
    """Decision-grade case consumed by the orchestration engine."""

    case_id: str = Field(min_length=1)
    entity_id: str = Field(min_length=1)
    severity: int = Field(ge=0, le=100)
    confidence: float = Field(ge=0.0, le=1.0)
    technique_id: Optional[str] = None      # e.g. "T1078" Valid Accounts


@dataclass(frozen=True)
class Step:
    name: str
    apply: ActionFn
    compensate: Optional[ActionFn] = None   # None => irreversible / read-only

Step 2 — Persist step state durably

The state store records, per case, which steps have completed. Writes are atomic so a crash between “acted” and “recorded” cannot corrupt the sequence. Here a small async wrapper over Redis models it; swap in any durable KV store.

import json
from typing import Protocol


class StepStore(Protocol):
    async def completed(self, case_id: str) -> set[str]: ...
    async def mark(self, case_id: str, step: str) -> None: ...


class RedisStepStore:
    def __init__(self, client) -> None:  # redis.asyncio.Redis
        self._r = client

    async def completed(self, case_id: str) -> set[str]:
        members = await self._r.smembers(f"pb:{case_id}:done")
        return {m.decode() if isinstance(m, bytes) else m for m in members}

    async def mark(self, case_id: str, step: str) -> None:
        # SADD is atomic; a redelivery re-marking a done step is harmless.
        await self._r.sadd(f"pb:{case_id}:done", step)
        await self._r.expire(f"pb:{case_id}:done", 7 * 24 * 3600)

Step 3 — Execute a step with retries and idempotency

Each step runs behind a bounded retry with jittered backoff. Because the store records completion, a resumed case skips already-done steps entirely — the idempotency guarantee is the durable set, not a hope that the vendor API is idempotent.

import asyncio
import logging

logger = logging.getLogger("soc.soar.orchestrator")


async def run_step(step: Step, case: Case, attempts: int = 4) -> None:
    """Run one step to confirmed success or raise after exhausting retries."""
    delay = 0.5
    for attempt in range(1, attempts + 1):
        try:
            await step.apply(case)
            return
        except Exception as exc:  # ERR_ACTION_002 downstream failure
            if attempt == attempts:
                logger.error("step=%s exhausted retries: %s", step.name, exc)
                raise
            # Deterministic jitter without wall-clock randomness sources.
            jitter = (hash((case.case_id, step.name, attempt)) % 100) / 1000
            await asyncio.sleep(delay + jitter)
            delay *= 2

Step 4 — Orchestrate with saga compensation

The engine walks the playbook, skipping completed steps, checkpointing each success, and unwinding the compensation stack in reverse if any step fails terminally.

class PlaybookEngine:
    def __init__(self, store: StepStore) -> None:
        self._store = store

    async def run(self, playbook: list[Step], case: Case) -> str:
        done = await self._store.completed(case.case_id)
        compensations: list[Step] = []
        try:
            for step in playbook:
                if step.name in done:
                    if step.compensate:      # still track for possible rollback
                        compensations.append(step)
                    continue
                await run_step(step, case)
                await self._store.mark(case.case_id, step.name)
                if step.compensate:
                    compensations.append(step)
            logger.info("playbook complete case=%s", case.case_id)
            return "COMPLETE"
        except Exception:
            # ERR_PLAYBOOK_051: unwind completed reversible steps in reverse.
            for step in reversed(compensations):
                try:
                    if step.compensate:
                        await step.compensate(case)
                except Exception as cexc:      # ERR_PLAYBOOK_061
                    logger.error("compensation failed step=%s: %s", step.name, cexc)
            return "COMPENSATED_DLQ"

Schema & Validation Integration

Orchestration inherits the case contract from the response-automation schema model: every step reads ECS-aligned entity fields (entity_id maps to host.id or user.name) and appends to the response namespace rather than mutating it. The Case model is the enforcement point — a payload that lost its entity_id or severity during upstream handling is rejected before a single step runs, because acting on an under-specified case is worse than not acting. That rejection is typed (ERR_PLAYBOOK_001), so a rising rejection rate points straight at correlation-layer drift, which should be triaged through the error categorization frameworks. When a step needs enrichment context, it reads fields written by the case enrichment pipelines rather than fetching inline, keeping each step’s blast radius and latency predictable.

Error Handling & DLQ Routing

Every failure mode produces a stable code so a stuck playbook is queryable and replayable. Codes follow the ERR_CATEGORY_NNN convention.

Code Meaning Recovery action
ERR_PLAYBOOK_001 Case failed the typed contract (missing entity/severity) Reject before execution; fix upstream correlation mapping
ERR_PLAYBOOK_051 A step failed terminally; saga compensation was invoked Case dead-lettered after rollback; replay after fixing the integration
ERR_PLAYBOOK_061 A compensation handler itself failed Page a human immediately; the environment may be in an inconsistent state
ERR_ACTION_002 Downstream integration returned a transient error Retried with backoff inside run_step; no manual action unless retries exhaust
ERR_PLAYBOOK_071 Durable step store unreachable Trip circuit breaker; pause new cases rather than run without checkpointing

The cardinal rule is never run a step you cannot checkpoint. If the state store is unreachable (ERR_PLAYBOOK_071), the engine pauses intake rather than executing side-effecting steps it cannot record — an un-recordable action is an un-auditable action. A failed compensation (ERR_PLAYBOOK_061) is the one condition that always pages a human, because it means the automation could not restore a known-good state on its own.

Performance Tuning

At incident-storm scale the orchestrator is bound by downstream API latency and the state store, not by Python. Tune in this order:

  • Skip cheaply on resume. Completed-step checks must be a single set read per case, not a per-step round-trip; load the done-set once at the top of run.
  • Bound per-integration concurrency with a semaphore so a burst of cases cannot exceed a vendor’s rate limit; pair this with alert routing workflows so only cases that truly need action reach the engine.
  • Batch state writes where the store allows pipelining, but never at the cost of the atomic “acted-then-recorded” ordering.
  • Cap playbook fan-out. A single case that spawns dozens of parallel steps should use asyncio.TaskGroup with a bounded semaphore, not unbounded gather, so one heavy case cannot starve the worker.
  • Target sub-second per-step latency excluding vendor time; if checkpoint writes dominate, the state store is the bottleneck — shard by case-id hash before adding workers.

Verification & Observability

Confirm correct operation with a structured log per step transition, counters per error code, and replayable tests that assert both idempotency and compensation.

async def test_resume_skips_completed_steps() -> None:
    calls: list[str] = []

    async def apply_a(c: Case) -> None: calls.append("a")
    async def apply_b(c: Case) -> None: calls.append("b")

    class FakeStore:
        def __init__(self, done: set[str]) -> None:
            self._done = done
        async def completed(self, case_id: str) -> set[str]:
            return set(self._done)
        async def mark(self, case_id: str, step: str) -> None:
            self._done.add(step)

    case = Case(case_id="C-1", entity_id="HOST-1", severity=80, confidence=0.9)
    pb = [Step("a", apply_a), Step("b", apply_b)]
    engine = PlaybookEngine(FakeStore(done={"a"}))   # 'a' already done
    result = await engine.run(pb, case)

    assert result == "COMPLETE"
    assert calls == ["b"]        # 'a' skipped on resume, only 'b' ran

Operationally, emit metrics for playbook_steps_total{step,status}, playbook_compensations_total, playbook_resume_total (a proxy for worker churn), and step_latency_seconds as a histogram per integration. A climbing playbook_compensations_total with a specific integration in the labels is the canary for a failing vendor API; a climbing ERR_PLAYBOOK_061 is a page-now signal.

Troubleshooting

FAQ

Is a SOAR playbook engine just a workflow engine?

Conceptually yes — it is a durable workflow engine — but with two security-specific constraints a generic workflow tool does not enforce by default: every step’s real-world side effect must be idempotent so retries are safe, and reversible steps must carry a compensation handler so a mid-playbook failure restores a known-good state. You can build on a general durable-execution framework, but these two properties are non-negotiable for response automation and are the focus of this page.

How is idempotency different from just retrying?

Retrying re-invokes a step after a failure; idempotency makes that re-invocation harmless. Without idempotency, a retry of “isolate host” is a second isolate call. Here idempotency comes from the durable completed-step set: a resumed or redelivered case skips any step already recorded done, so the step’s side effect happens exactly once regardless of how many times the case is processed.

What is the saga pattern and why use it here?

The saga pattern models a long-running transaction as a sequence of steps, each with a compensating action that undoes it. Because you cannot two-phase-commit a firewall and an identity provider, a saga is the practical way to keep a multi-integration playbook consistent: if step 4 fails, you compensate steps 3, 2, and 1 in reverse to unwind the partial response rather than leaving it stuck halfway.

Where should playbook state live?

In a durable store external to the worker — Redis, a relational database, or a purpose-built durable-execution backend — never in worker memory. The whole point is that any worker can resume any case after a crash, which is only possible if the completed-step record survives the process. The store’s only hard requirement is atomic per-step writes.