How state machines work in AILA, with real examples from production modules.
Every module that does multi-step work uses the DurableStateMachine engine. The engine persists state to PostgreSQL (workflow_state_cursor table) between steps so work survives crashes and retries.
WorkflowDefinition
|
+-- definition_id: "vulnerability.full_analysis.v1"
+-- start_state: "inventory"
+-- states:
| "inventory" -> StateSpec(handler, timeout, retries, on_success="advisory")
| "advisory" -> StateSpec(handler, timeout, retries, on_success="intel")
| "intel" -> StateSpec(handler, timeout, retries, on_success="scoring")
| "scoring" -> StateSpec(handler, timeout, retries, on_success="report")
| "report" -> StateSpec(handler, timeout, retries, on_success="persist")
| "persist" -> StateSpec(handler, timeout, retries, on_success="response_emit")
| "response_emit" -> StateSpec(handler, timeout, on_success="__succeeded__")
| "__succeeded__" (auto-registered terminal)
| "__failed__" (auto-registered terminal)
| "__crashed__" (auto-registered terminal)
| "__cancelled__" (auto-registered terminal)
+-- services_factory: async (run_id) -> WorkflowServices
A fifth reserved state, __paused__, exists for engine-level pause / resume
(Phase B). It is NOT a terminal -- the cursor sits at __paused__ while
the prior current_state is preserved in the cursor's archived_state column
(migration 067). Resume swaps archived_state back to current_state.
__paused__ is not a member of RESERVED_TERMINAL_STATES; the main engine
loop skips it. Source: src/aila/platform/workflows/types.py:36-42.
The engine loop:
- Load or create cursor row for
run_id - Call
services_factory(run_id)to build a fresh services bundle - Call
handler(state_input, services)->StateResult(next_state, output) - Persist cursor:
state_input = output,current_state = next_state - Repeat until a terminal state is reached
Every state handler is an async function with this exact signature:
async def state_my_step(
state_input: dict[str, Any],
services: MyModuleWorkflowServices,
) -> StateResult:Parameters:
state_input-- the previous handler'soutputdict (or the initial kwargs on first entry)services-- a module-specific services bundle built fresh per state execution
Return: StateResult(next_state="...", output={...})
-
outputmust be JSON-serializable.StateResultvalidates this at construction time viajson.dumps(). If your output contains a Pydantic model, datetime, UUID, or any non-primitive, serialize it first. The engine will reject it with aValidationErrorotherwise.- Validated by
StateResultmodel_validator atsrc/aila/platform/workflows/types.py:119.
- Validated by
-
state_inputis adict[str, Any]. Not a Pydantic model. Not a dataclass. Every handler receives a raw dict and must parse what it needs from it. -
Handlers must be idempotent. The engine may re-run a handler on retry (crash recovery, ARQ retry, timeout). If the handler already performed a side effect (wrote to DB, made an API call), re-running must not corrupt state.
-
Side effects go through
services. Never create your own DB session, Redis connection, or HTTP client inside a handler. Use the services bundle -- it is scoped to the current execution and rolls back cleanly on cancellation. -
Emit progress via
services.emitter. This writes audit trail rows and feeds SSE streams.
Each state in a WorkflowDefinition is described by a StateSpec:
StateSpec(
handler=_h(state_inventory), # the async handler function
timeout_s=600.0, # asyncio.wait_for wraps the handler
max_retries=2, # retry budget per state (0 = no retries)
retriable_on=(TimeoutError,), # exception types that trigger retry
on_success="advisory", # next state on success (static edge)
on_failure="__crashed__", # next state on exhausted retries (optional)
output_schema=IntelOutput, # Pydantic model to validate output (optional)
)| Field | Default | Purpose |
|---|---|---|
handler |
required | The async function to call |
timeout_s |
300.0 | Handler timeout in seconds |
max_retries |
0 | Number of retries before transitioning to on_failure |
retriable_on |
() |
Tuple of exception types that trigger retry |
on_success |
None |
Static edge to next state (handler can override via next_state) |
on_failure |
None |
State to transition to when retries exhausted (defaults to __crashed__) |
output_schema |
None |
Pydantic model to validate handler output |
terminal |
False |
If True, engine exits without calling handler |
Production handlers use an "envelope" pattern to carry state between stages. The envelope is a dict with a stable outer shape and a serialized snapshot inside:
def _envelope(state_input: dict[str, Any], snapshot: AnalysisStateSnapshot) -> dict[str, Any]:
"""Rebuild state_input with updated snapshot; preserve non-snapshot keys."""
envelope = dict(state_input)
envelope["snapshot"] = snapshot.model_dump(mode="json")
return envelopeEach handler:
- Extracts the snapshot:
snapshot = _extract_snapshot(state_input) - Does its work
- Updates the snapshot
- Returns:
StateResult(next_state="...", output=_envelope(state_input, new_snapshot))
This pattern ensures:
- Non-snapshot keys (e.g.,
execution_mode,team_id) survive across states - The snapshot is always JSON-serializable (
model_dump(mode="json")) - Each handler only modifies the fields it owns
Complex modules use a dispatcher definition that selects a mode-specific inner definition:
Dispatcher (routing -> operation_selection -> __succeeded__)
|
+-- dispatches_to:
"full_analysis" -> VULNERABILITY_FULL_ANALYSIS_V1
"report_summary" -> VULNERABILITY_REPORT_SUMMARY_V1
"report_count" -> VULNERABILITY_REPORT_COUNT_V1
"report_findings" -> VULNERABILITY_REPORT_FINDINGS_V1
"explain_cves" -> VULNERABILITY_EXPLAIN_CVES_V1
The platform's @platform_task wrapper drives both levels:
- Runs the dispatcher definition through the engine
- Reads
selected_definition_idfrom the terminal output - Looks up the inner definition from
dispatches_to - Runs the inner definition through the engine (same
run_id, phase handoff)
Module authors set is_dispatcher=True and populate dispatches_to. They never call DurableStateMachine.execute directly.
inventory -> advisory -> intel -> scoring -> report -> persist -> response_emit -> __succeeded__
| State | What it does | Retry policy |
|---|---|---|
inventory |
SSH into targets, collect package lists | 2 retries on TimeoutError, ConnectionError, OSError |
advisory |
Query OSV + distro feeds for advisories | 2 retries on httpx.HTTPError |
intel |
Fetch EPSS, KEV, NVD enrichment | 2 retries on httpx.HTTPError |
scoring |
LLM-score each finding | 3 retries on LLMTransientError |
report |
Generate summary narrative | 3 retries on LLMTransientError |
persist |
Write findings + report to DB | 2 retries on psycopg.OperationalError |
response_emit |
Build final PlatformResponse | No retries |
intake -> collection -> deep_analysis -> promotion -> resolution -> writeup -> response_emit -> __succeeded__
| State | What it does | Retry policy |
|---|---|---|
intake |
Validate evidence directory, classify files | 2 retries on SSH transient |
collection |
Run collectors (tshark, strings, capa, binwalk) | 3 retries on SSH transient |
deep_analysis |
LLM-driven analysis of collected artifacts | 2 retries on LLMTransientError |
promotion |
Promote findings from analysis to evidence graph | No retries |
resolution |
LLM resolve: connect findings to threat hypotheses | 2 retries on LLMTransientError |
writeup |
Generate investigation report narrative | 2 retries on LLMTransientError |
response_emit |
Build final PlatformResponse | No retries |
PREPARE -> EXECUTE -> RESPONSE_EMIT
Uses the old-style handler registry (pre-engine). Simple modules that don't need durability, retries, or persistence can use this pattern with a workflow.py file instead of the full engine.
# Correct: model_dump before putting into output
return StateResult(
next_state="advisory",
output=_envelope(state_input, snapshot.model_copy(
update={"package_count": count}
)),
)def _envelope(state_input, snapshot):
envelope = dict(state_input)
envelope["snapshot"] = snapshot.model_dump(mode="json")
return envelope"inventory": StateSpec(
handler=_h(state_inventory),
on_success="advisory", # static edge -- enables graph validation
),The engine validates the full graph at definition time. If every state declares on_success, the engine checks:
- Every target state exists
- Every non-terminal state is reachable from
start_state - At least one terminal state is reachable
# Network/transient: retry
_HTTP_TRANSIENT = (httpx.HTTPError,)
# DB/transient: retry
_PERSIST_TRANSIENT = (psycopg.OperationalError, asyncio.TimeoutError)
# LLM/transient: retry
_LLM_TRANSIENT = (LLMTransientError, asyncio.TimeoutError)Define these as module-level tuples. Use the parent exception class so subclasses are automatically covered.
await services.emitter.emit(
stage="inventory",
message=f"Collected {len(inventories)} systems and {package_count} packages.",
)This feeds the SSE stream, audit trail, and task progress UI.
async with services.session_factory() as session:
result = await session.exec(select(MyTable).where(...))The session factory is scoped to the current execution. It rolls back on cancellation or error.
# WRONG: RouteDecision is a BaseModel, not JSON-serializable
terminal = await analyze_fleet(
synthetic_ctx,
route=request.run_state.route, # <-- Pydantic model!
)
# CORRECT: serialize first
terminal = await analyze_fleet(
synthetic_ctx,
route=request.run_state.route.model_dump(mode="json") if request.run_state.route else None,
)Task kwargs become initial_input in the engine. initial_input is stored in a JSONB column. Non-serializable objects crash the INSERT. The engine validates this at execute() entry with json.dumps() and raises a clear TypeError if it fails. Validated at DurableStateMachine.execute() entry via json.dumps(initial_input, default=None) -- see src/aila/platform/workflows/engine.py:112.
# WRONG: datetime is not JSON-serializable
return StateResult(
next_state="persist",
output={"completed_at": datetime.now(UTC)}, # TypeError at construction
)
# CORRECT: convert to string
return StateResult(
next_state="persist",
output={"completed_at": datetime.now(UTC).isoformat()},
)StateResult runs json.dumps(output) at construction time. It will raise ValueError on non-serializable types.
# WRONG: handler manages its own session
async def state_persist(state_input, services):
async with async_session_scope() as session: # <-- don't do this
session.add(MyRecord(...))
await session.commit()Use services.session_factory() instead. The engine owns session lifecycle for cancellation safety.
# WRONG: module calling engine directly
from aila.platform.workflows import DurableStateMachine
result = await DurableStateMachine.execute(run_id, definition, input)Modules declare WorkflowDefinition objects and wire them via @platform_task(definition=...). The platform wrapper drives execution. Modules write pure state handlers and nothing else.
# WRONG: crashes with IntegrityError if row exists
session.add(run_record)
await session.commit()
# CORRECT: merge handles both insert and update
await session.merge(run_record)
await session.commit()The orchestrator and engine may both create WorkflowRunRecord rows for the same run_id. Use merge() or INSERT ON CONFLICT DO NOTHING. Live merge sites in src/aila/platform/runtime/orchestrator.py -- grep for _merge_live_hypotheses to locate.
# WRONG: handler decides next state conditionally
async def state_scoring(state_input, services):
if snapshot.skip_scoring:
return StateResult(next_state="persist", output=...) # skips report
return StateResult(next_state="report", output=...)This hides the graph from static validation. If you need conditional branching, use a dispatcher that selects different definitions, or add an explicit mode_selection state whose job is choosing the path.
# WRONG: hides failures, handler appears to succeed
async def state_intel(state_input, services):
try:
data = await fetch_intel(...)
except httpx.HTTPError:
data = {} # <-- caller never knows this failed
return StateResult(next_state="scoring", output=...)Let retriable exceptions propagate. Declare them in retriable_on on the StateSpec. The engine handles retry logic, backoff, and failure transitions.
-
Define your states as
async def state_*(state_input, services) -> StateResultfunctions inworkflow/states/. -
Create a services class implementing
WorkflowServices.build(run_id)inworkflow/services.py. Bundle emitter, session factory, and any module-specific runtime. -
Wire the definition in
workflow/definitions.py:MY_WORKFLOW_V1 = WorkflowDefinition( definition_id="my_module.my_workflow.v1", start_state="step_one", states={ "step_one": StateSpec( handler=_h(state_step_one), timeout_s=300.0, max_retries=2, retriable_on=(TimeoutError,), on_success="step_two", ), "step_two": StateSpec( handler=_h(state_step_two), timeout_s=60.0, on_success=RESERVED_SUCCEEDED, ), }, services_factory=_build_services, )
-
Register the task in
workflow/task.py:@platform_task( track="my_module", module_id="my_module", definition=MY_WORKFLOW_V1, ) async def run_my_workflow(ctx: TaskContext, **kwargs: Any) -> dict[str, Any]: ... # seed stub -- platform_task drives execution
-
Use two-level dispatch if your module has multiple modes:
MY_DISPATCHER_V1 = WorkflowDefinition( definition_id="my_module.dispatcher.v1", start_state="routing", is_dispatcher=True, dispatches_to={ "full_analysis": MY_FULL_ANALYSIS_V1, "quick_check": MY_QUICK_CHECK_V1, }, states={...}, services_factory=_build_services, )
| Feature | What it catches |
|---|---|
json.dumps() on initial_input |
Non-serializable task kwargs (Pydantic models, etc.) |
StateResult model validator |
Non-serializable handler output |
max_retries + retriable_on |
Transient failures (network, DB, LLM) |
timeout_s via asyncio.wait_for |
Hung handlers |
MAX_STEPS_PER_JOB = 1000 |
Infinite loops in malformed definitions |
| Static graph validation | Unreachable states, missing terminal, edge typos |
Optimistic locking (version column) |
Concurrent worker conflicts |
STATE_NAME_MAX_LEN = 128 |
Storage DoS via crafted state names |
output_schema validation |
Handler returning wrong output shape |
Lives at src/aila/platform/tasks/cursor_reaper.py. ORM delete(WorkflowStateCursor) filtered by current_state == "__crashed__" AND run_id NOT IN (active TaskRecord ids) (queued, running, waiting). Runs every minute from the worker reaper cron.
A __crashed__ cursor is reclaimed only once its TaskRecord has settled to a non-active status (done, failed, cancelled, dead_letter). The crash event itself is preserved on the TaskRecord; only the cursor row is removed.
The per-investigation reaper clocks elapsed time from coalesce(started_at, created_at) rather than created_at.
- SQL site:
src/aila/modules/vr/services/investigation_reaper.py:112 - Python site:
src/aila/modules/vr/services/investigation_emit.py:288
The cap envelope (VR_INVESTIGATION_WALL_CLOCK_HOURS, default 6 hours) no longer fires while an investigation sits queued.
src/aila/modules/<module_id>/workflow/
__init__.py # re-exports: definitions, task entry point
definitions.py # WorkflowDefinition objects (frozen, statically validated)
services.py # WorkflowServices.build() implementation
contracts.py # Pydantic models for output_schema validation
task.py # @platform_task entry point (seed stub)
states/
__init__.py # re-exports all state handler functions
dispatch.py # state_routing, state_operation_selection
analysis.py # state_inventory, state_advisory, ...
reporting.py # state_report, state_response_emit
lookup.py # state_report_lookup (for query modes)
See src/aila/modules/vulnerability/workflow/ for the canonical production reference.
The platform provides CyberReasoningEngine (platform/services/reasoning.py) for modules that need multi-turn LLM reasoning inside a workflow state. The forensics and vr modules use it for bounded investigations; future modules (web_pentest, mobile_reverse) will share the same protocol.
The reasoning engine is not a workflow engine. It runs inside a single workflow state handler. A handler calls the engine in a loop:
async def state_freeflow(state_input, services):
engine = CyberReasoningEngine(services.llm_client)
case_state = ReasoningCaseState()
for turn in range(1, max_turns + 1):
# 1. Engine selects strategy
strategy = engine.select_strategy_family(
question=question,
case_state=case_state,
evidence_listing=evidence,
)
# 2. Engine builds prompt from case model + evidence + operator steering
user_prompt = engine.build_user_prompt(ReasoningPromptContext(
turn=turn,
max_turns=max_turns,
question=question,
case_model=engine.render_case_model(case_state),
evidence_listing=evidence,
artifacts=collected_artifacts,
strategy_family=strategy,
))
# 3. LLM returns a structured decision
decision = await engine.decide_next_turn(
task_type="forensics_freeflow",
system_prompt=system_prompt,
user_prompt=user_prompt,
)
# 4. Engine merges decision into cumulative state
case_state = engine.absorb(case_state, decision)
# 5. Module executes the action (script, tool, submit)
if decision.action == "submit":
error = engine.validate_submission(...)
if error is None:
break # answer accepted
elif decision.action == "script_execute":
result = await run_script(decision.script_content)
elif decision.action == "tool_run":
result = await run_tool(decision.command)| Layer | Owns | Does NOT own |
|---|---|---|
| Reasoning engine (platform) | Prompt framing, JSON extraction, turn-decision validation, case-state merging, evidence graph construction, strategy selection, submission validation | Tool execution, file I/O, SSH commands, domain interpretation |
| Module state handler | Tool execution, evidence collection, domain-specific interpretation, deciding when to stop | Prompt construction, LLM round-trip, hypothesis tracking, answer validation |
The engine owns the protocol. The module owns the domain.
ReasoningTurnDecision
reasoning: str # LLM's chain-of-thought
action: "script_execute" | "tool_run" | "reasoning" | "submit"
contract: ReasoningContract | None # answer type/format (derived once)
hypotheses: [Hypothesis] # live explanatory hypotheses
rejected: [RejectedHypothesis] # disproved hypotheses
observables: {key: value} # facts extracted this turn
script_content: str | None # script to execute (action=script_execute)
command: str | None # tool to run (action=tool_run)
answer: str | None # final answer (action=submit)
confidence: "exact" | "strong" | "medium" | "caveated" | "unknown"
provenance: EvidenceProvenance # citations for the answer
The engine accumulates state across turns via absorb():
- Contract is set once (first turn that derives it) and never overwritten
- Hypotheses are replaced wholesale each turn (LLM re-evaluates all)
- Rejected hypotheses are append-only and deduplicated by
(id, claim) - Observables are merged (new keys added, existing keys updated)
This ensures the LLM cannot "forget" disproved hypotheses or lose observables across turns.
The engine builds a graph snapshot (ReasoningEvidenceGraph) from case state:
Node kinds: contract, hypothesis, rejected_hypothesis, observable, evidence, answer
Edge kinds: depends_on, supports, refutes, corroborates, answered_by
Graphs are persisted via ReasoningGraphService.save_snapshot() (DB table: reasoning_graph_snapshot). The forensics frontend renders these as investigation timelines.
The engine ships with 4 built-in domain profiles:
| Domain | Strategy families |
|---|---|
forensics |
filesystem_triage, persistence_hunt, memory_forensics, network_forensics, malware_static, generic |
vulnerability_research |
vulnerability_research, generic |
web_pentest |
web_pentest, network_forensics, generic |
mobile_reverse |
mobile_reverse, malware_static, generic |
Strategy selection is deterministic (keyword matching on evidence + question). Operator steering can pin a strategy via ReasoningOperatorSteering.pinned_strategy_family.
Operators can influence reasoning without modifying code:
steering = ReasoningOperatorSteering(
confirmed_facts=["the intrusion used a Cobalt Strike beacon"],
disproved_hypotheses=["H2: lateral movement via RDP"],
guidance=["focus on DNS exfiltration channels"],
pinned_strategy_family="network_forensics",
required_artifacts=["pcap_analysis_summary"],
)Confirmed facts and guidance are injected into the prompt. Disproved hypotheses bypass the engine's own rejection logic. Required artifacts enforce that the submission cites specific evidence.
Three gates from commit 2328b4e plus the idempotent draft-review request from commit 8f2d1f5.
Pre-submit gate. A terminal_submit is intercepted by _maybe_reject_submit_when_draft_pending (src/aila/modules/vr/agents/vuln_researcher.py:1311-1403) when an unvoted DRAFT outcome exists for the investigation (excluding drafts proposed by this branch). The submit is converted into a non-terminal observe carrying a SUBMIT BLOCKED - UNVOTED DRAFT OUTCOMES directive, and the original submit payload is preserved on observables under _pending_draft_blocked_submit so the agent can re-submit after voting.
Auto-approve fallback in evaluate_quorum (src/aila/modules/vr/services/outcome_review.py:275-296):
quorum_k == 0(single-branch investigation) flips a DRAFT to APPROVED withtransition_reason="auto_approved_no_siblings".quorum_k > 0with every non-proposing sibling non-active and votes still below quorum flips a DRAFT to APPROVED withtransition_reason="auto_approved_no_active_voters_*".
compute_quorum returns max(2, ceil(N/2)) for N >= 1.
Empty-tool_run STOP threshold. The second consecutive empty or malformed tool_run (src/aila/modules/vr/agents/tool_executor.py:109-155) injects a hard STOP directive listing valid options (tool_run, submit, observe). The first empty command receives a softer hint.
Idempotent draft-review request (commit 8f2d1f5). post_draft_review_request (src/aila/modules/vr/services/outcome_review.py:365-459) builds auto_steering_key = f"draft_review_request:{outcome_id}". Before inserting a new operator-kind message it runs a substring .contains(auto_steering_key) lookup against VRInvestigationMessageRecord.payload_json (lines 432-434); if a match exists no new row is written and the existing message id is returned.
Do:
- Use
engine.validate_submission()before accepting an answer -- it checks for empty answers, missing citations, and unreferenced artifacts - Persist graph snapshots per turn via
ReasoningGraphServicefor auditability - Pass
operator_steeringthrough from the API/CLI layer -- do not drop it - Use
model_dump(mode="json")when storing case state in workflowstate_input
Do not:
- Call the LLM directly from module code -- use
engine.decide_next_turn() - Parse the LLM JSON response yourself -- the engine's
_extract_json_object()handles fences and validation - Modify
case_statedirectly -- useengine.absorb()to merge decisions - Rely on hypothesis ordering -- the LLM re-evaluates all hypotheses each turn