What happens inside AILA when a request arrives, traced end-to-end through every layer. Read this before writing a module, debugging a scan, or changing platform code.
A user types scan raspi. Here is every component it touches, in order.
User: "scan raspi"
|
+-- [1] CLI / API entry
| aila task "scan raspi" (CLI: direct platform.handle)
| POST /analyze {targets: [...]} (API: TaskQueue -> ARQ -> platform.handle)
|
+-- [2] Platform orchestrator
| Creates RunState + WorkflowRunRecord
| Builds EventEmitter with 4 destinations (audit DB, run history, progress callback, Redis stream)
|
+-- [3] LLM Router (two-tier)
| Tier 1: DecisionCache (keyed on query + profile hash, TTL-based)
| Tier 2: LLM call with all module CapabilityProfiles as candidates
| Output: RouteDecision(action_id="vulnerability.analyze_fleet", confidence=0.87)
|
+-- [4] Module dispatch
| PlatformRuntime.require_module("vulnerability") -> VulnerabilityRuntime
| VulnerabilityRuntime.handle(ModuleRequest) invoked
|
+-- [5] Two-level workflow dispatch
| analyze_fleet @platform_task(definition=VULNERABILITY_DISPATCHER_V1)
| Dispatcher: routing -> operation_selection -> __succeeded__
| Output: selected_definition_id = "full_analysis"
|
+-- [6] Inner workflow execution (DurableStateMachine)
| VULNERABILITY_FULL_ANALYSIS_V1:
| inventory -> advisory -> intel -> scoring -> report -> persist -> response_emit
| Each state: handler(state_input, services) -> StateResult(next_state, output)
| Cursor persisted to workflow_state_cursor between every transition
|
+-- [7] State handlers (module domain code)
| inventory: SSH into raspi, collect 1617 packages
| advisory: Query OSV + Debian feeds for 413 advisories
| intel: Fetch EPSS + KEV + NVD for 364 CVEs
| scoring: Score 366 findings (LLM or cache)
| report: Generate summary narrative
| persist: Write FindingRecords + ReportArtifact to DB
| response_emit: Build PlatformResponse with module_payload
|
+-- [8] Response assembly
| PlatformResponse returned up through orchestrator
| WorkflowRunRecord finalized (status, route_json, summary_json, report_path)
| Emitter writes final audit event
|
+-- [9] Output
CLI: JSON printed to stdout
API: TaskRecord updated to DONE, result available via GET /tasks/{id}
Two paths into the platform. Both converge at AILAPlatform.handle().
aila task "scan raspi"
-> cli.py::task()
-> asyncio.run(AILAPlatform().handle(query="scan raspi"))
Direct call. No task queue. No TaskRecord. The scan runs in the CLI process and blocks until complete. Used for development and one-off queries.
POST /analyze {"query_text": "scan raspi", "targets": ["raspi"]}
-> scans.py::submit_scan()
-> TaskQueue.submit(track="vulnerability", fn=run_platform_handle, kwargs={...})
-> TaskRecord created (status=QUEUED)
-> ARQ enqueues to Redis (arq:queue:vulnerability)
-> 202 Accepted returned immediately with task_id
Worker picks up job:
-> on_job_start hook: TaskRecord -> RUNNING
-> run_platform_handle(ctx, query="scan raspi", module_payload={...})
-> platform.handle(query=..., run_id=task_id)
-> on_job_end hook: TaskRecord -> DONE (or DEAD_LETTER on failure)
The API path decouples submission from execution. The caller polls GET /tasks/{task_id} for status. Long-running scans (minutes) use this path. ARQ runs four queues (default | vulnerability | forensics | vr), selected via the track= argument on TaskQueue.submit().
- Computes SHA-256 dedup hash of
{fn, kwargs}. Returns existing handle if an identical active task exists. - Validates module boundary (the function must belong to the declared module).
- Creates
TaskRecordin DB (status QUEUED or WAITING if dependencies). - Validates dependency DAG for cycles.
- Enqueues to ARQ via Redis
ZADD. - Returns
TaskHandle(task_id). - If Redis is unreachable,
submit()deletes the ghostTaskRecordand raisesWorkerUnreachableError(HTTP 503). There is no in-process fallback execution (D-19, Phase 178).
AILAPlatform.handle() in platform/runtime/orchestrator.py. The nerve center.
async def handle(self, query, module_payload, module_options, ...):
run_state = RunState(run_id=..., query=query)
async with async_session_scope() as session:
emitter = build_emitter(session, run_state, progress_callback)
# Route
route = await self.router.route(session, query)
run_state.route = route
# Dispatch
response = await _dispatch_module_request(
runtime, session, route.action_id, run_id, run_state,
execution_context, module_payload, module_options
)
# Finalize
await _finalize_run(session, run_record, run_state, "completed", response)
return responseEffective middleware order (outer to inner): _prometheus_request_middleware -> _reject_oversized_requests -> _catch_unhandled_exceptions -> CORSMiddleware -> IdempotencyMiddleware -> CorrelationIdMiddleware -> route handler. Starlette applies middleware LIFO, so the last-added wrapper runs first; see src/aila/api/app.py.
What it owns:
- RunState lifecycle (creation through finalization)
- WorkflowRunRecord persistence (route_json, summary_json, short_memory_json)
- EventEmitter construction and fan-out wiring
- Error handling: any exception -> status="failed" finalization, event emitted, then re-raised
What it does NOT own:
- Module domain logic (delegated to module runtime)
- Workflow state machine (delegated to DurableStateMachine)
- LLM calls (delegated to AilaLLMClient)
ModuleRouter in platform/routing/router.py. Decides which module handles a query.
Tier 1 -- Cache. DecisionCache stores previous routing decisions keyed on (query, module_profiles_hash). If a cached decision exists and hasn't expired, it's returned without an LLM call.
Tier 2 -- Model. Sends a structured JSON prompt to AilaLLMClient listing every registered module's CapabilityProfile:
{
"candidates": [
{
"module_id": "vulnerability",
"action_id": "vulnerability.analyze_fleet",
"description": "Analyze registered systems over SSH...",
"tools": ["registry.systems", "ssh.command", ...],
"examples": ["scan my fleet", "check for CVEs on arch-vm", ...]
},
...
],
"query": "scan raspi"
}The model returns RoutingSelection(module_id, action_id, confidence, rationale). If confidence < minimum_confidence (default 0.2), the query is marked unroutable.
Each module defines them in capabilities.py:
MODULE_DESCRIPTION = "Analyze registered systems over SSH..."
MODULE_TOOLS = ("vulnerability.scan", "vulnerability.score")
MODULE_EXAMPLES = ("scan my fleet", "check for CVEs on arch-vm")The module's capability_profiles() method wraps these into ModuleCapabilityProfile objects. The platform's ModuleRegistry collects all profiles at boot and hands them to the router.
Every LLM call in the platform goes through AilaLLMClient.chat(), which runs a fixed-order middleware pipeline:
classify -> [API call] -> validate -> gate -> verify -> seal
| Step | What it does | Fail mode |
|---|---|---|
| classify | Content classification. Can block the call before it reaches the API. | Configurable: transparent (log only) or restrictive (block) |
| API call | OpenAI-compatible chat/completions. Temperature stripped for models in the rejection list. |
Hard failure -> LLMTransientError (retriable) |
| validate | Response shape validation. Checks the response is parseable. | Fail-open (log + continue) or fail-closed (reject) |
| gate | Post-response gating. Can replace or block the response. | Configurable per task_type |
| verify | Evidence verification. Checks claims against stored data. | Fail-open |
| seal | Cryptographic audit seal. SHA-256 hash of (prompt + response + model + timestamp) stored as AuditSealRecord. |
Fail-open |
Pipeline behavior is per-task_type. Each module names its task types (e.g., forensics_freeflow, vulnerability_scoring). The LLMConfigProvider resolves:
env var AILA_PLATFORM_LLM_PIPELINE_{STEP}_RESTRICTED_BEHAVIOR_{TASK_TYPE}
-> ConfigRegistry platform.llm_pipeline_{step}_restricted_behavior_{task_type}
-> default: "transparent"
Values: transparent (log classification, don't block), restrictive (block on classification match).
Every LLM call records token usage in CostRecord:
prompt_tokens,completion_tokens,total_tokensestimated_cost_usd(derived from model pricing table)task_type,run_id,model_id
Accessible via GET /admin/cost (Cost Intelligence page).
EventEmitter in platform/events/emitter.py. Fan-out delivery to 4 destinations per request.
emitter.emit(PlatformEvent(stage="inventory", action="start", message="..."))
|
+-- audit_db: INSERT INTO workflowauditrecord
+-- run_history: Append to RunState.events (in-memory, serialized at finalization)
+-- progress: SSE stream via Redis XADD (consumed by frontend EventSource)
+-- redis_stream: Publish to Redis Stream keyed by task:{run_id}:progress
(consumed by frontend SSE)
ThreadSafeEventEmitter wraps emit() in a drain queue with a non-blocking lock. Parallel SSH workers, DAG stages, and scoring threads all call emit() safely without external locking. The drain loop is synchronous; async destinations receive events in drain order without blocking fast sync destinations.
@dataclass(frozen=True, slots=True)
class PlatformEvent:
stage: str # "routing", "inventory", "scoring", ...
action: str # "start", "complete", "fail", "progress"
key: str # dedup key for SSE reconnection
message: str # human-readable summary
details: dict = field(default_factory=dict) # structured payload
run_id: str = ""
current: int | None = None # progress: items completed
total: int | None = None # progress: total items
progress_message: str | None = None # progress: status textEach module's runtime.py implements ModuleRuntime.handle(). This is the entry point the orchestrator calls after routing.
For simple modules (hello_world), handle() runs the workflow directly and returns a PlatformResponse.
For complex modules (vulnerability, forensics), handle() calls the module's @platform_task-decorated entry point with a synthetic ARQ context, which drives the DurableStateMachine through the full workflow definition.
ModuleRequest(
session: Session, # active DB session (do not create new ones)
run_id: str, # unique run identifier
action_id: str, # "vulnerability.analyze_fleet"
run_state: RunState, # contains RouteDecision + events
execution_context: ModuleExecutionContext(
memory_store: PermanentMemoryStore,
report_artifact_store: ReportArtifactStore,
progress_callback: Callable,
emitter: EventEmitter,
task_queue: TaskQueue | None,
),
payload: dict, # module-specific input
options: dict, # module-specific options
)Everything in ModuleRequest that gets passed as a kwarg to a @platform_task function must be JSON-serializable. Pydantic models must be .model_dump(mode="json"). The workflow engine validates this at entry via json.dumps(initial_input, default=None) (src/aila/platform/workflows/engine.py:112) and crashes with a clear TypeError if violated.
DurableStateMachine in platform/workflows/engine.py. Drives workflow definitions through their state graph with crash recovery.
See docs/WORKFLOW_GUIDE.md for the full handler contract, StateSpec configuration, envelope pattern, and do/don't rules.
-
Cursor persistence. After every state transition, the engine writes
(run_id, current_state, state_input, version)toworkflow_state_cursor. If the worker crashes mid-flight, the next ARQ retry loads the cursor and resumes from the last committed state. -
Optimistic locking. The cursor UPDATE guards
WHERE version = :loaded_version. If two workers race on the same run_id, one sees 0 rows affected and raisesWorkflowConflictError, which ARQ retries with backoff. -
Audit trail. Every transition writes
enteredandexitedrows toworkflowauditrecordwith timing, output snapshot, and error details. The timeline page renders these. -
Per-state retry. Each
StateSpecdeclaresmax_retriesandretriable_on. The engine retries transient failures (network, DB, LLM) within the state before transitioning toon_failureor__crashed__.
CyberReasoningEngine in platform/services/reasoning.py. Multi-turn LLM reasoning protocol used inside workflow state handlers (currently by forensics and vr).
See docs/WORKFLOW_GUIDE.md (Cyber Reasoning Engine section) for the turn loop, case state merging, evidence graphs, domain profiles, and operator steering.
Workflow state handler (e.g., state_freeflow)
|
+-- CyberReasoningEngine.decide_next_turn() # LLM call
| +-- AilaLLMClient.chat() # goes through pipeline
| +-- classify -> call -> validate -> gate -> verify -> seal
|
+-- CyberReasoningEngine.absorb() # merge decision into case state
|
+-- Module executes action # script_execute, tool_run
| +-- SSHCommandTool.forward() # SSH into target
| +-- ScriptTool.execute() # run analysis script
|
+-- ReasoningGraphService.save_snapshot() # persist evidence graph to DB
|
+-- Loop until submit or max_turns
The engine owns the protocol (prompt framing, JSON extraction, hypothesis tracking, evidence graph construction). The module owns the domain (which tools to run, how to interpret output, when to stop).
Key tables and who owns them:
| Table | Owner | Purpose |
|---|---|---|
workflowrunrecord |
Platform | One row per handle() call. Status, route, summary, report path. Writes use session.merge() (not add()) because orchestrator and engine may both create the row for the same run_id; see src/aila/platform/runtime/orchestrator.py:304, 309. |
workflow_state_cursor |
Platform | Durable state machine cursor. FK to workflowrunrecord. |
workflowauditrecord |
Platform | Audit trail: every state transition with timing and output. |
taskrecord |
Platform | Task queue: status, fn_path, kwargs, depends_on, heartbeat. |
auditsealrecord |
Platform | LLM audit seals: SHA-256 hash of prompt + response. |
costrecord |
Platform | LLM cost tracking: tokens, model, estimated USD. |
configentryrecord |
Platform | Runtime config: namespace + key + value (env var override). |
managedsystemrecord |
Platform | SSH targets: name, host, username, port, credentials. |
latestfindingrecord |
Vulnerability | Materialized findings: one row per (host, package, CVE). |
reportartifactrecord |
Platform | Report blobs: JSON artifacts keyed by run_id + artifact_type. |
seedversionrecord |
Platform | Per-module seed idempotency guard. |
reasoning_graph_snapshot |
Platform | Evidence graph snapshots per investigation turn. |
Used for three things:
- ARQ task queue. Sorted sets keyed by
arq:queue:{track}. Workers poll with ZRANGEBYSCORE. - SSE progress streams. Redis Streams keyed by
aila:progress:{run_id}. Frontend EventSource reads via XREAD. - Worker heartbeat. Workers write
aila:heartbeat:{worker_id}with TTL. Health endpoint checks key existence.
A cron-driven sweep in src/aila/platform/tasks/cursor_reaper.py issues an ORM delete(WorkflowStateCursor) every minute for reserved-terminal cursors (__crashed__, __failed__, __cancelled__, __succeeded__) whose run_id no longer has an active TaskRecord. Function name sweep_orphan_crashed_cursors is kept for backwards compat.
Large outputs (finding rows, summary narratives) are stored as ReportArtifactRecord blobs, not inline in the run record. The report_path field on WorkflowRunRecord points to the primary artifact ID.
Modules contribute UI via ModuleFrontendSpec:
interface ModuleFrontendSpec {
moduleId: string;
nav?: NavContribution[]; // sidebar entries
routes?: RouteContribution[]; // page routes
panels?: PanelContribution[]; // injected panels (system detail, finding detail)
widgets?: WidgetContribution[]; // dashboard widgets
}The platform shell (AppShell, AppSidebar, router.tsx) discovers all module specs at boot via loadModuleFrontendSpecs() and merges their contributions into the app structure. No platform code changes needed to add a module's UI.
All module UI uses the platform design system:
- CSS variables:
var(--color-base),var(--color-surface),var(--color-accent),var(--color-text),var(--color-border) - Tailwind tokens:
bg-base,bg-surface,bg-elevated,text-text,text-text-muted,text-accent,border-border - Components:
AilaCard,AilaBadge,EmptyState,PageFrame, shadcn
No custom CSS files. No hardcoded hex colors. No Tailwind v4 arbitrary values (they don't generate CSS).
+-----------+
| CLI/API | <-- entry
+-----+-----+
|
+-----v-----+
| TaskQueue | <-- API path only (submit -> ARQ -> worker)
+-----+-----+
|
+-----v------+
| Orchestrator| <-- RunState, emitter, finalization
+-----+------+
|
+----+----v----+----+
| |
+-----v-----+ +--------v--------+
| LLM Router| | Module Dispatch |
| (2-tier) | | (by action_id) |
+-----+------+ +--------+--------+
| |
+-----v------+ +--------v--------+
| LLM Client | | Module Runtime |
| (pipeline) | | handle() |
+--------------+ +--------+--------+
|
+--------v--------+
| Durable State |
| Machine (engine) |
+--------+--------+
|
+------------------+------------------+
| | |
+-----v-----+ +------v------+ +-------v-------+
| State | | Reasoning | | Services |
| Handlers | | Engine | | (emitter, |
| (module) | | (platform) | | session, |
+-----+------+ +------+------+ | tools) |
| | +---------------+
+--------+---------+
|
+--------v--------+
| PostgreSQL |
| + Redis |
+-----------------+
Each arrow is a function call you can grep for:
platform.handle()-> orchestrator entryrouter.route()-> LLM routingmodule_runtime.handle()-> module dispatchDurableStateMachine.execute()-> workflow enginehandler(state_input, services)-> state executionengine.decide_next_turn()-> reasoning LLM callemitter.emit()-> fan-out to audit/SSE/historysession.exec()-> databasearq.enqueue_job()-> Redis task queue