Workflow Engine
Audience: Engineers building or debugging multi-step workflows.
What you will accomplish: Understand how workflows run as durable state machines — with gates, idempotency, crash recovery, and compensation — so you can define, test, and operate them.
Prerequisites: Read MCP Surface first to understand tool risk tiers.
Estimated time: ~12 minutes.
The workflow engine (cam.core.orchestrator) turns multi-step operational processes into durable, resumable state machines. A workflow can pause at a human gate for hours or days, survive server restarts, and resume exactly where it left off.
What is a workflow engine?
A workflow engine turns a multi-step operational process (like client intake: extract → create contact → create matter → compute deadlines → draft email → await approval → send) into a durable state machine.
"Durable" means every step's state is persisted to the database. If the server crashes mid-workflow, it picks up exactly where it left off on restart. If a step needs human approval, the workflow pauses (sometimes for days) and resumes when the approver acts.
This is fundamentally different from a script: scripts run start-to-finish or fail; workflows survive failures, wait for humans, and never silently skip a step.
State machine
queued ──► running ──► succeeded
│ ├──► awaiting_approval ──►(approve)──► running
│ │ └──►(reject)──► rejected
│ ├──►(transient, attempts<cap)──► running [retry]
│ └──►(fatal | attempts==cap)──► parked
└── parked ──►(re-drive)──► running
└──►(cancel)──► cancelledStep states: pending → running → succeeded | failed
failedloops torunningon retry or escalates the run toparkedcompensatedmarks a reversed stepskippedmarks a bypassed step
Idempotency — exactly-once external effects
Every step's external effect is keyed on "{run_id}:{step_name}".
Before executing, the engine checks the IdempotencyStore:
- Miss → execute handler,
reserve,recordoutput - Hit → reuse stored output, skip handler (no re-effect)
This gives exactly-once external effect under at-least-once execution. Even if two Celery workers pick up the same run, only one will execute each step.
Gate lifecycle
Human-in-the-loop gates are first-class citizens in the state machine, not afterthoughts.
Step 1: Engine hits a GATE:* step
The run transitions to awaiting_approval. No further steps execute.
Run status → awaiting_approvalStep 2: GateRequest row created
Persistence layer writes a GateRequest with tokens for each approval channel (MCP, web, email).
Tokens issued per channelStep 3: Approver resolves the gate
Approver calls one channel's endpoint with the raw token.
POST /approvals/{token} { "decision": "approve", "comment": "LGTM" }Step 4: resolve_gate() validates
Signature → expiry → single-use (atomic) → authz. Any failure returns 403 and does not advance the run.
Validation chainStep 5: Run resumes or terminates
Approve → run status → running, re-enqueued. Reject → run status → rejected.
Decision recorded in audit log
Gate configuration per workflow:
required_role— who can approve (e.g.,attorney)channels— how the approver is notified (mcp,web,email)ttl_seconds— how long the gate stays open before auto-reject
Retry and compensation
Retry policy
TransientError→ retry with exponential full-jitter backoff, max 5 attemptsFatalErroror attempts exhausted → park immediatelyRateLimitError→ respectRetry-Afterheader, then retry
Compensation
On park, registered compensation hooks run in reverse order (best-effort). If a step created a contact and a matter, and the matter step fails fatally, the compensation hook for create_contact can delete the orphaned contact.
Not all external systems support deletion or rollback. Compensation hooks log their outcome and never crash the park process. Human triage may be required for partially-compensated runs.
Crash recovery
On startup, the engine sweeps all runs:
| Status | Action |
|---|---|
queued | Re-enqueue for execution |
running | Re-enqueue; idempotency prevents re-effect |
awaiting_approval | Left at gate; no work to redo |
parked | Left parked; human triage required |
Because every external effect is idempotent, re-enqueuing a running run is safe even if the previous worker died mid-step.
Workflow definition example
@workflow("intake", version=1)
class IntakeWorkflow:
steps = [
"parse_lead", # extract fields from email/form
"dedupe_contact", # CRM lookup — ambiguous → blocking gap
"create_contact", # write (confirm)
"create_matter", # write (confirm)
"compute_deadlines", # deadline engine
"open_tasks", # checklist
"draft_welcome", # email.draft — no send yet
"GATE:human_review", # pause for approval
"send_welcome", # email.send — post-gate only
]Each step is a handler method on the workflow class. The engine calls them in order, managing state transitions and idempotency automatically.
Workflow registration
Workflows are registered at startup with their service dependencies:
from cam.core.workflows.intake.workflow import register_intake_workflow
from cam.core.services import ServiceContainer
services = ServiceContainer(...) # inject connectors, stores, etc.
register_intake_workflow(services)The deprecated configure_services() shim exists only for backward compatibility and logs a deprecation warning.
Step handler contract
Every step handler receives:
ctx: StepContext— run_id, step_name, inputs, services- Returns:
StepResult— status, output, next_step_hint, compensation_hook
async def create_contact(ctx: StepContext) -> StepResult:
contact = await ctx.services.crm.upsert_contact(ctx.inputs["contact"])
return StepResult(
status=StepStatus.SUCCEEDED,
output={"contact_id": contact.id},
idempotency_key=f"{ctx.run_id}:create_contact",
)Distributed execution
For production multi-worker deployments, the engine uses Redis SETNX distributed locking:
- Before iterating steps, the worker attempts to acquire
cam:run_lock:{run_id} - Only one worker executes a given run at a time
- Lock is released on completion, park, or gate
This enables N Celery workers to process independent runs concurrently without collision.
Testing workflows
The engine includes an InMemoryRunStore for fast, deterministic testing:
from cam.core.orchestrator import WorkflowEngine, InMemoryRunStore
engine = WorkflowEngine(store=InMemoryRunStore())
run = await engine.start("intake", inputs={...})
# Simulate gate approval
await engine.resolve_gate(run.id, token="test-token", decision="approve")
# Assert final state
assert run.status == RunStatus.SUCCEEDEDAll workflow tests run without PostgreSQL or Redis, completing in milliseconds.
Observability
Every workflow step starts an OpenTelemetry span:
- Span name =
workflow.{workflow_name}.step.{step_name} run_idtag on every span- Parent span = the workflow run span
- Error spans capture the full exception chain and ConnectorError taxonomy
Structured logs (structlog) include:
event— step lifecycle (step_start,step_complete,step_retry,gate_open,gate_resolve)run_id,step_name,attemptduration_ms- PII-scrubbed inputs/outputs
Current limitations
| Limitation | Status | Plan |
|---|---|---|
| Postgres-backed RunStore for production | In-memory only | Phase 1 — ~3 days |
| Distributed locking across Celery workers | Redis SETNX planned | Phase 2 — ~3 days |
| End-to-end HTTP tests for sidecar | Not yet written | Phase 2 — ~1 day |
| Multi-tenancy (tenant_id) | Single-tenant | Phase 5 — months 15–24 |
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Using configure_services() instead of DI | Deprecated shim; logs warnings, may break in v2 | Use register_*_workflow(services) with explicit ServiceContainer |
Not handling FatalError in step handlers | Unhandled exception crashes the worker; run may be lost | Let the engine handle it — FatalError parks the run for human triage |
| Forgetting idempotency keys on steps with external effects | Retry double-sends or double-creates | Every external-effect step must use (run_id, step_name) idempotency key |
Testing with Postgres instead of InMemoryRunStore | Tests become slow and flaky | Use InMemoryRunStore() for unit tests; reserve Postgres for integration tests |
Verification
After reading this page, verify your understanding:
- Describe what happens when a transient error occurs on step 3 of a 9-step workflow
- Explain why re-enqueuing a
runningrun after a crash is safe - Name the three gate channels and how single-use atomicity is enforced
- Show how to write a step handler that returns a compensation hook
Next steps
- Client Intake Workflow — End-to-end example with gates
- Status Update Emails — Webhook + sweep dual trigger
- Document Generation — Template → DOCX/PDF pipeline
- Document Routing — Privilege-aware classification and filing
Last updated: 2026-06-01