This document defines the required structure and contract for every feature module under src/aila/modules.
Version: 2.1 (updated 2026-04-07) Previous: v1 (2026-04-03)
If a module does not match this standard, runtime discovery fails fast at platform boot.
For a module package src/aila/modules/<module_id>/:
<module_id>/
__init__.py
module.py
runtime.py
capabilities.py
api_router.py # Optional: module HTTP routes for frontend/API surface
workflow.py # Simple modules: single file
workflow/ # Complex state machines: package (alternative to workflow.py)
__init__.py
models.py
orchestrator.py
planning.py
helpers.py
states/
__init__.py
analysis.py
reporting.py
lookup.py
utils/
...
tool_keys.py
contracts/
__init__.py
tools/
__init__.py
services/
__init__.py
reporting/
__init__.py
Workflow Implementation Options: Simple feature modules use workflow.py (single file). Modules with multi-stage state machines may use a workflow/ package instead. If using a package, workflow/__init__.py must re-export AnalysisWorkflow (or the equivalent class) so external callers continue to use from module.workflow import AnalysisWorkflow without knowing the internal structure.
module.py must expose:
def create_module() -> ModuleProtocol:
...Rules:
create_modulemust be zero-argument.- It must return an object implementing
ModuleProtocol. - Returned
module_idmust match folder name<module_id>.
The platform auto-discovers feature modules by scanning
src/aila/modules/ with pkgutil.iter_modules:
- Only sub-packages are considered (a bare
.pyfile undermodules/is ignored). - Packages whose short name starts with
_are skipped (this is how_templateis excluded from the live registry). - The synthetic
PlatformModuleis always first, then feature modules follow in filesystem (alphabetical) order. - Each discovered package is passed through
build_module_factory(), which callsvalidate_module_layout()before returning thecreate_modulecallable.
There is no central manifest to edit. Drop a new package under
src/aila/modules/<your_module>/ that satisfies the required layout and
the platform picks it up at the next boot.
If the module's create_module() raises or fails validation, the
platform logs a WARNING (Module '<id>' failed validation -- disabled)
and continues startup with reduced functionality rather than crashing.
The discovery code catches (AILAError, ValueError) specifically -- bare
except Exception is banned everywhere, including this path.
At registration time, the platform enforces:
module_iduses lowercase letters/digits/underscore and starts with a letter.capability_profiles()is non-empty.- Every profile has matching
profile.module_id == module.module_id. - Every
action_idstarts with<module_id>.. - No duplicate action IDs.
- Every capability profile has a non-empty description.
required_tools()is non-empty.- No empty tool keys and no duplicate tool keys.
- Every tool key is prefixed
<module_id>.<tool_name>(e.g.vulnerability.osv_advisory,hello_world.greet). The prefix prevents collisions across modules in the sharedToolRegistry.
- Module-specific provider clients, services, adapters, scoring logic, and reporting must live inside the module package.
- Cross-module platform utilities (example: shared HTTP client, SSH transport, registry core) stay in top-level platform/shared packages.
- Module data defaults should be stored in module-owned data files and seeded into DB via module migrations.
Each tool file must contain exactly one Tool subclass scoped to a single data source or operation concern. God Object tools (one class with 5+ unrelated actions) are a violation.
- CORRECT:
advisories_osv.pycontainsOSVAdvisoryTool(OSV.dev only) - CORRECT:
intel_nvd.pycontainsNVDIntelTool(NVD only) - WRONG:
advisories.pycontainsVulnerabilityAdvisoryTool(OSV + Arch + Alpine + caches)
Rationale: The LLM agent selects tools by name and description. A tool named "unified_advisory" gives the agent no signal about when to call it. Split by data source or operation, not by domain.
contracts/__init__.py must be a barrel re-export only -- no model definitions inline. Models live in domain submodules (matching.py, scoring.py, reporting.py, analysis.py). The barrel re-exports all public names so external callers use:
from module.contracts import ModelNamewithout knowing the internal submodule.
__all__ must list only public names. Names starting with _ must not appear in any __all__ list. Private functions are still importable by explicit name; __all__ only controls from module import * behaviour and signals the public API.
Models that cross component boundaries MUST live in contracts/. Models internal to one component stay with that component. Never define a contract model in the same file as the class that consumes it.
What "crosses a component boundary" means:
- A payload model that a handler deserializes from an external caller's request
- A result model returned across a package boundary (e.g., from
storage/consumed bymodules/) - A structured response model used as the
response_model=argument in an agent call when the result is passed to another component - Any model imported by more than one package
What may stay in the implementation file:
- A model used exclusively inside one method/class as an internal parsing detail, never exported or imported elsewhere
- A dataclass used only within one file for computation purposes
Examples:
- CORRECT:
AnalyzePayload(the vulnerability module's action input) lives invulnerability/contracts/analysis.py - CORRECT:
LatestReportResult(returned fromstorage/and consumed bymodules/) lives inplatform/contracts/reporting.py - WRONG:
DeleteIntegrationsPayloaddefined inplatform/modules/platform.pyalongside the class that parses it - WRONG:
RoutingSelectiondefined inplatform/routing/router.pyalongside the router that creates it
For modules with more than 5 SQLModel tables, db_models.py must be converted to a db_models/ package with domain submodules:
db_models/
__init__.py # barrel re-export only
operations.py # cache, queue, shared helpers
distribution.py # distribution profiles, inventory, scheduling
findings.py # finding records, remediation, asset tags
db_models/__init__.py is a barrel re-export only. External callers use:
from module.db_models import SomeRecordwithout knowing which submodule defines it. Internal helpers shared between submodules (e.g., _enum_sql_values) live in one submodule and are imported by others -- no duplication.
The platform automatically discovers and registers:
- Tools listed in
required_tools()viacapabilities.py - DB schemas via
schema_registry.push(SCHEMA_REGISTRATION)called insideregister_tools() - Config schemas via
registry.register(spec)called insideregister_tools() - Seed data via
seed_data()inmodule.py - The module itself via
create_module()inmodule.py
The module is responsible for explicitly wiring:
- Agent construction and knowledge namespace assignment
- Service instantiation (e.g., SSH transport, provider clients)
- Injecting module-specific query callables into platform repositories
(e.g.,
ReportRepository(materialized_query=_query_latest_findings))
Wiring happens at register_tools() time or build_runtime() time -- not inside platform infrastructure.
The platform storage/ layer must NOT import from any modules/<module_id>/ package. If a storage component needs module-specific behaviour, the module injects it via:
- A callable parameter on the storage class constructor
- A
register_*method on the storage class
Never via a direct model import crossing the platform-to-module boundary.
# CORRECT -- module owns the coupling to its own DB model
def _query_latest_findings(session, target):
from aila.modules.vulnerability.db_models import LatestFindingRecord
...
# WRONG -- storage layer imports module model directly
from ..modules.vulnerability.db_models import LatestFindingRecord- Copy
src/aila/modules/_template/. - Rename
_templateto your module ID. - Implement contracts, tools, services, workflow, runtime, and module entrypoint.
- Ensure
create_module()returns your module object. - Start the platform; discovery validation will fail fast if structure/contract is wrong.
Every module must implement all four methods of ModuleProtocol.
Zero-argument factory function at module scope (not a class method). Returns a fully initialized instance.
def create_module() -> ModuleProtocol:
return MyModule()The returned module.module_id must match the folder name exactly (e.g., folder vulnerability → module_id = "vulnerability"). Platform discovery enforces this.
Called once at startup. Register all tools, schemas, and config the module
needs. The method is async def so it can await async setup (provider
warm-up, config materialization). Synchronous register_* calls inside
the body stay synchronous; no await is required for them.
async def register_tools(
self,
tool_registry: ToolRegistry,
settings: Settings,
registry: ConfigRegistry | None = None,
schema_registry: SchemaRegistry | None = None,
) -> None:
if schema_registry is not None:
schema_registry.push(MyTableRecord, ...)
if registry is not None:
registry.register(self.module_id, MyConfigSchema)
for spec in iter_tool_specs():
tool_registry.register(spec.key(), spec.factory(settings))Note: the Protocol declares settings: ApplicationSettings; the concrete Settings class in aila.config satisfies the protocol.
See "SchemaRegistry Usage" and "Tool Registration Pattern" sections below.
Called once per startup after register_tools. Seeds default data into
the DB (scoring policies, distribution profiles, lookup tables, etc.).
The session is an AsyncSession; every session.exec(...) and
session.commit() MUST be awaited.
See the dedicated "seed_data() Contract" section below.
Module-owned filter logic over report rows. The module defines which keys are valid, which use exact match, and which use substring match. Platform delegates filtering entirely to this method.
def filter_report_rows(
self,
rows: list[JsonObject],
filters: JsonObject | None = None,
) -> list[JsonObject]:
...- Must return
list(rows)unchanged whenfiltersisNone, empty, or contains only unknown/empty keys. - Filter semantics (exact vs. contains, delimiter splitting) are module-internal -- callers must not assume a specific algorithm.
seed_data(session) is called by the platform after every startup. It must be idempotent.
Rules:
-
Check
SeedVersionRecordfirst. If the module's seed version already matchesSEED_VERSION, return immediately without touching any data.from aila.storage.db_models import SeedVersionRecord existing = (await session.exec( select(SeedVersionRecord).where(SeedVersionRecord.module_id == self.module_id) )).first() if existing is not None and existing.seed_version == SEED_VERSION: return
-
Call
await session.commit()before returning. The session is caller-owned; do not open a new session insideseed_data(). The platform provides a liveAsyncSessionand expects the commit to happen inside this call. -
Bump
SEED_VERSIONto re-trigger seeding. DefineSEED_VERSIONas a module-level constant (e.g.,SEED_VERSION = "1.0"). Changing this value causes the platform to re-seed on the next startup. -
Session ownership. Do not call
session_scope(),async_session_scope(), or open a new engine connection insideseed_data(). The caller owns the session lifetime.
SEED_VERSION = "1.0"
async def seed_data(self, session: AsyncSession) -> None:
from sqlmodel import select
from aila.storage.db_models import SeedVersionRecord
existing = (await session.exec(
select(SeedVersionRecord).where(SeedVersionRecord.module_id == self.module_id)
)).first()
if existing is not None and existing.seed_version == SEED_VERSION:
return
_seed_defaults(session)
if existing is None:
session.add(SeedVersionRecord(module_id=self.module_id, seed_version=SEED_VERSION))
else:
existing.seed_version = SEED_VERSION
session.add(existing)
await session.commit()A module may import from:
aila.platform.*-- modules, routing, runtime, services, tools, contractsaila.storage.*-- DB models, schema registry, config registryaila.config.Settings-- infrastructure settings
A module must not import from another feature module:
# FORBIDDEN
from aila.modules.other_module.anything import SomethingCross-module data sharing must go through platform services or shared contracts.
From CLAUDE.md:
- Every
__init__.pyin a package must define__all__explicitly. - Every non-private module that exposes public functions or classes must define
__all__at module scope. __all__must list every name intended for external use.__all__must not include names that start with an underscore.- Namespace packages with no public re-exports must set
__all__ = []. - Underscore-prefixed modules (e.g.,
_lookup_helpers.py) are private implementation details. They must not define__all__-- the underscore prefix signals non-public surface.
# Public module or package init
__all__ = ["MyService", "MyContract"]
from .my_service import MyService
from .my_contract import MyContract
# Package init with no public exports -- __all__ = []
# _helpers.py -- private module, omit __all__ entirelyDB table classes are registered by modules during register_tools(). The platform calls create_all() after all modules have registered.
async def register_tools(self, tool_registry, settings, registry=None, schema_registry=None):
if schema_registry is not None:
schema_registry.push(
MyTableRecord,
AnotherTableRecord,
)Rules:
- Call
schema_registry.push(TableClass, ...)insideregister_tools()-- not elsewhere. - Do not call
create_all()directly inside a module. The platform calls it after all modules have registered. - Every SQLModel table class must set
__tablename__explicitly. schema_registrymay beNonein some execution contexts (e.g., test fixtures that bypass DB setup) -- always guard withif schema_registry is not None.
Tools are registered by key during register_tools(). Use the TOOL_ALIAS, CAPABILITY, and FACTORY constants pattern with iter_tool_specs():
# In tool_catalog.py
from dataclasses import dataclass
from collections.abc import Callable
from aila.config import Settings
@dataclass(frozen=True)
class ToolSpec:
TOOL_ALIAS: str
CAPABILITY: str
FACTORY: Callable[[Settings], object]
def key(self) -> str:
return self.TOOL_ALIAS
def factory(self, settings: Settings) -> object:
return self.FACTORY(settings)
def iter_tool_specs() -> list[ToolSpec]:
return [
ToolSpec(
TOOL_ALIAS="my_module.my_tool",
CAPABILITY="what this tool does",
FACTORY=lambda s: MyTool(s),
),
]Then in module.py:
for spec in iter_tool_specs():
tool_registry.register(spec.key(), spec.factory(settings))Tool keys must be unique across all registered modules. Platform enforces no duplicate keys at registration.
Every workflow state handler must emit stage lifecycle events via emit_stage_result().
This replaces the three-call pattern (audit_event + run_event + progress_callback) used before v1.4.
Import:
from aila.modules.vulnerability.workflow.helpers import emit_stage_resultUsage in a state handler:
def state_prepare(context: WorkflowExecutionContext) -> WorkflowStage | None:
# ... do work ...
emit_stage_result(
context,
stage=WorkflowStage.PREPARE,
action="prepare",
event_key="module_id.prepare",
message="Preparation complete.",
audit_details={"target_count": len(context.target_names)},
progress_message="Preparing...",
)
return NoneRules:
- Call
emit_stage_result()at the end of each state handler, after all mutations to context are complete. event_keyformat:"<module_id>.<stage_name>"-- must be unique and lowercase.audit_detailsis optional; include meaningful facts (counts, IDs, mode names).progress_messageis optional; used for CLI/SSE progress display.- Do not call
audit_event(),append_run_event(), orprogress_callbackdirectly -- they are destinations wired insideemit_stage_result.
HTTP provider clients (NVD, OSV, EPSS, KEV, Alpine, Arch, GHSA) must extend BaseProviderClient from aila.modules.vulnerability.providers.base.
BaseProviderClient handles:
- Shared
__init__with proxy, timeout, andhttpx.Clientconstruction close()for explicit cleanup__del__safety guard
Subclass pattern:
from aila.modules.vulnerability.providers.base import BaseProviderClient
class MyProviderClient(BaseProviderClient):
def __init__(
self,
base_url: str,
settings: Settings | None = None,
timeout: float = 30.0,
) -> None:
super().__init__(base_url=base_url, settings=settings, timeout=timeout)
self._api_key = self._resolve_api_key()
def _resolve_api_key(self) -> str | None:
...HTTP tools that use a provider client must override __init__ WITHOUT calling super().__init__() from VulnerabilityTool -- HTTP tools do not need init_db().
Every public function, method, and class must have a Google-style docstring.
Required format:
def my_function(name: str, count: int = 0) -> list[str]:
"""One-line summary ending with a period.
Optional extended description explaining WHAT this does and WHY it exists.
Describe behavior, side effects, or constraints that are not obvious.
Args:
name: Description of the parameter.
count: Description with default behavior noted if relevant.
Returns:
Description of the return value and its shape.
Raises:
ValueError: When name is empty.
NotFoundError: When the record does not exist.
"""Rules:
- First line: one-sentence summary, ends with a period.
- Blank line between summary and Args/Returns/Raises sections.
- Args section: one line per parameter. Type annotations are in the function signature -- do NOT duplicate types in the docstring.
- Returns section: what the return value is, its shape, and key semantics. Omit for
None-returning functions. - Raises section: document exceptions that callers must handle. Omit for functions that do not raise.
- Skip trivial
__init__that only assigns fields (no logic) and obvious property getters. - Describe WHAT and WHY -- not the parameter types (those are in type annotations).
- Private functions (
_prefix) may have docstrings but are not required to.
Modules declare HTTP routes via route_specs() on ModuleProtocol. The platform
calls spec.router_factory() at startup to obtain a FastAPI APIRouter and mounts
it at spec.prefix via include_router(prefix=...).
@dataclass(frozen=True, slots=True)
class ModuleRouteSpec:
prefix: str # URL prefix for all routes in this spec
router_factory: Callable[[], Any] # () -> APIRouter
tool_keys: tuple[str, ...] = () # tool keys this module registers
config_namespace: str | None = None # config namespace owned by module
payload_type: str | None = None # discriminated-union payload type name| Field | Type | Required | Description |
|---|---|---|---|
prefix |
str |
Yes | URL prefix for all routes (e.g. "/vulnerability"). Platform mounts the router at this path. |
router_factory |
Callable[[], Any] |
Yes | Zero-argument callable returning a FastAPI APIRouter. Called once at startup. |
tool_keys |
tuple[str, ...] |
No | Tool keys this module registers, surfaced via GET /tools. Use tuple() not list() because the dataclass is frozen. |
config_namespace |
str | None |
No | Config namespace this module owns, surfaced via GET/PUT /config. None if the module has no config. |
payload_type |
str | None |
No | Name of the discriminated-union payload type (optional). |
auth_required |
bool |
No | When False, skip the global require_user_or_api_key dependency. Default True. |
def route_specs(self) -> list[ModuleRouteSpec]:
from my_module.api_router import create_my_router # deferred import
return [
ModuleRouteSpec(
prefix="/my_module",
router_factory=create_my_router,
tool_keys=("my_module.tool_a", "my_module.tool_b"),
config_namespace="my_module",
),
]- Platform calls
spec.router_factory()to obtain theAPIRouterand mounts it atspec.prefix. Modules must NOT embed the prefix in router paths. If the platform mounts at/vulnerability, router paths should be relative:/findings, not/vulnerability/findings. - Return
[](the protocol default) for modules with no HTTP surface. ModuleRouteSpecis a frozen dataclass -- do not subclass it.- Use
tuple()fortool_keys, notlist(). Frozen dataclasses reject mutable defaults. - Import the router factory inside
route_specs()(deferred import) to keep module discovery lightweight. Importing at module level pulls in FastAPI and all route dependencies at discovery time.
Module-owned HTTP routes are public contracts for the browser and other API clients. Treat them as explicit product surfaces, not as storage leaks or CLI payload dumps.
- If a module exposes HTTP routes, keep the router factory in
api_router.py. module.pydeclares the mounted HTTP surface throughroute_specs().- Route paths must remain relative to the mount prefix declared in
ModuleRouteSpec.
- Every route must declare
response_model=...andsummary=.... - Every request body must use an explicit Pydantic model. Do not accept raw
dict[str, Any]bodies for frontend-facing endpoints. - Read routes must not trigger scans, rescoring, or other expensive work implicitly. If work is queued, expose it as an explicit mutation or a
202 Acceptedflow. - List endpoints intended for frontend browsing must be paginated. Standard shape is
total,page,page_size,pages,itemsunless the route is intentionally streaming. - Filters, counts, and facets must be computed server-side. Do not dump the full dataset to the browser and expect the UI to reconstruct truth.
- Router boundaries must map internal names to stable API names. Never leak raw DB column names, filesystem paths, or module-private payload shapes just because they already exist internally.
- Empty, missing, and unavailable are different states. Return them differently: empty result for no data,
404for missing resource,409/503for unavailable or blocked work where appropriate. - Streaming/progress routes must reflect backend state honestly. No invented percentages, no fake completion, no silent downgrade from live state to stale state.
- If an endpoint exposes artifact availability, return frontend-usable contract fields (IDs, formats, or download route inputs), not raw local file paths.
- Use noun-based route groups that match the browser mental model:
/findings,/reports/{run_id},/systems/{id}. - Keep module-specific semantics in the module route prefix, not in hidden query switches or overloaded action names.
- Prefer one route per observable user task. Do not create generic catch-all endpoints that return unrelated shapes depending on hidden flags.
Internal storage fields may differ from the public API, but the translation must happen at the router boundary:
# GOOD: map internal names to stable API names at the HTTP boundary
FindingResponse(
package=row.package_name,
severity=row.criticality,
)
# BAD: return raw storage field names and make the frontend learn internals
{"package_name": row.package_name, "criticality": row.criticality}These methods have default implementations that return empty values. Modules override them when they contribute data to platform endpoints. All three are called by platform API routes; modules that do not participate simply inherit the default.
Signature:
async def system_summary(self, system_id: int, session: AsyncSession) -> dict[str, Any]:When the platform calls it: GET /systems/{id} collects module-contributed dashboard data. The platform iterates all registered modules, calls system_summary() on each, and merges all non-empty dicts into the response.
Expected return type: dict[str, Any] with module-specific data. Return {} if the module has nothing to contribute for this system.
Default behavior: Returns {} (empty dict). No action required from the module.
Example implementation:
async def system_summary(self, system_id: int, session: AsyncSession) -> dict[str, Any]:
from sqlmodel import select
from my_module.db_models import FindingRecord
rows = (await session.exec(
select(FindingRecord).where(FindingRecord.system_id == system_id)
)).all()
if not rows:
return {}
return {
"critical": sum(1 for r in rows if r.criticality == "CRITICAL"),
"high": sum(1 for r in rows if r.criticality == "HIGH"),
}Signature:
async def report_count(self, run_id: str, session: AsyncSession) -> dict[str, Any]:When the platform calls it: GET /reports/{run_id}/count collects semantic count breakdowns. The platform calls this on the module that owns the given run_id. Modules that do not own the run_id should return {} without raising.
Expected return type: dict[str, Any] with domain-specific breakdowns (e.g. severity counts, category totals). Return {} if the module has nothing to report.
Default behavior: Returns {} (empty dict).
Example implementation:
async def report_count(self, run_id: str, session: AsyncSession) -> dict[str, Any]:
from sqlmodel import select
from my_module.db_models import FindingRecord
rows = (await session.exec(select(FindingRecord))).all()
if not rows:
return {}
return {
"total_findings": len(rows),
"critical": sum(1 for r in rows if r.criticality == "CRITICAL"),
}Signature:
def health_checks(self) -> dict[str, object]:When the platform calls it: GET /health collects module-contributed health checks. The health endpoint checks hasattr(module, 'health_checks') before calling, so this method is truly optional on concrete implementations.
Expected return type: dict[str, object] mapping check name to a zero-argument callable returning a ModuleHealthResult-compatible object with a status attribute ('up', 'degraded', or 'down').
Default behavior: Returns {} (empty dict). Platform-level code must never require this method.
Example implementation:
def health_checks(self) -> dict[str, object]:
return {
"llm_api": lambda: self._check_llm_reachability(),
"db_connection": lambda: self._check_db(),
}Each callable should return a ModuleHealthResult(status="up") or equivalent on success, or ModuleHealthResult(status="down", message="...") on failure.
This section walks through building a complete module from the _template scaffold. The example creates a network_scan module that registers tools, contributes HTTP routes, seeds default data, and integrates with platform lifecycle methods.
cp -r src/aila/modules/_template src/aila/modules/network_scanResulting layout:
src/aila/modules/network_scan/
__init__.py
module.py
runtime.py
capabilities.py
workflow.py
tool_keys.py
contracts/
__init__.py
tools/
__init__.py
services/
__init__.py
reporting/
__init__.py
Edit tool_keys.py -- stable identifiers referenced by capabilities.py, required_tools(), and register_tools().
# tool_keys.py
from __future__ import annotations
NETWORK_SCAN_DISCOVER_TOOL = "network_scan.discover"
NETWORK_SCAN_PROBE_TOOL = "network_scan.probe"
__all__ = ["NETWORK_SCAN_DISCOVER_TOOL", "NETWORK_SCAN_PROBE_TOOL"]Edit capabilities.py -- description and examples are embedded in LLM routing prompts.
# capabilities.py
from __future__ import annotations
MODULE_DESCRIPTION = "Network scanner: discovers hosts and probes open ports."
MODULE_TOOLS: list[str] = [
"network_scan.discover",
"network_scan.probe",
]
MODULE_EXAMPLES: list[str] = [
"scan the 10.0.0.0/24 subnet for open ports",
"discover hosts on the management network",
]
__all__ = ["MODULE_DESCRIPTION", "MODULE_EXAMPLES", "MODULE_TOOLS"]Each tool file in tools/ contains exactly one Tool subclass scoped to a single concern.
# tools/__init__.py
from .discover import DiscoverTool
from .probe import ProbeTool
__all__ = ["DiscoverTool", "ProbeTool"]# tools/discover.py
from __future__ import annotations
from aila.config import Settings
__all__ = ["DiscoverTool"]
class DiscoverTool:
"""Discover live hosts on a subnet."""
def __init__(self, settings: Settings) -> None:
self._timeout = settings.ssh_timeout
def execute(self, subnet: str) -> list[str]:
"""Scan subnet and return list of responding hosts.
Args:
subnet: CIDR notation subnet to scan.
Returns:
List of IP address strings that responded.
"""
# Real implementation goes here
return []Cross-component models go in contracts/. Internal-only models stay in the implementation file.
# contracts/__init__.py
from .scan_result import ScanResult
__all__ = ["ScanResult"]# contracts/scan_result.py
from __future__ import annotations
from dataclasses import dataclass
__all__ = ["ScanResult"]
@dataclass(frozen=True)
class ScanResult:
"""Result of a network scan passed between components."""
host: str
open_ports: list[int]
latency_ms: floatEdit module.py -- this is the only file the platform imports directly.
# module.py
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from sqlmodel import Session
from aila.config import Settings
from aila.platform.contracts._common import JsonObject
from aila.platform.modules import (
ModuleCapabilityProfile,
ModuleContext,
ModuleProtocol,
ModuleRouteSpec,
ModuleRuntime,
action_id_for,
)
from aila.platform.runtime import ToolRegistry
from .capabilities import MODULE_DESCRIPTION, MODULE_EXAMPLES, MODULE_TOOLS
from .runtime import NetworkScanRuntime
from .tool_keys import NETWORK_SCAN_DISCOVER_TOOL, NETWORK_SCAN_PROBE_TOOL
from .tools import DiscoverTool, ProbeTool
MODULE_ID = Path(__file__).parent.name # "network_scan" -- must match folder
MODULE_ACTION_ID = action_id_for(MODULE_ID, "run")
SEED_VERSION = "1.0"
class NetworkScanModule(ModuleProtocol):
"""Network scan module implementing ModuleProtocol."""
module_id = MODULE_ID
action_id = MODULE_ACTION_ID
# --- Required lifecycle methods ---
def capability_profiles(self) -> list[ModuleCapabilityProfile]:
"""Advertise this module to the routing agent."""
return [
ModuleCapabilityProfile(
module_id=self.module_id,
action_id=self.action_id,
description=MODULE_DESCRIPTION,
tools=list(MODULE_TOOLS),
examples=list(MODULE_EXAMPLES),
)
]
def required_tools(self) -> list[str]:
"""Return tool keys the platform scopes for this module."""
return list(MODULE_TOOLS)
async def register_tools(
self,
tool_registry: ToolRegistry,
settings: Settings,
registry=None,
schema_registry=None,
) -> None:
"""Register tools and DB schemas.
Args:
tool_registry: Register each tool by key.
settings: Passed to tool constructors.
registry: ConfigRegistry -- register config schema here.
schema_registry: SchemaRegistry -- push DB table classes here.
"""
# Register DB tables (if any)
# if schema_registry is not None:
# schema_registry.push(NetworkScanRecord)
# Register tools
tool_registry.register(NETWORK_SCAN_DISCOVER_TOOL, DiscoverTool(settings))
tool_registry.register(NETWORK_SCAN_PROBE_TOOL, ProbeTool(settings))
def build_runtime(self, context: ModuleContext) -> ModuleRuntime:
"""Construct the runtime that handles incoming requests."""
del context
return NetworkScanRuntime(
module_id=self.module_id,
action_id=self.action_id,
capability_profiles=self.capability_profiles(),
)
# --- Route declaration ---
def route_specs(self) -> list[ModuleRouteSpec]:
"""Declare HTTP routes mounted by the platform.
IMPORTANT: Import the router factory inside this method (deferred import)
to keep module discovery lightweight.
"""
from .api_router import create_network_scan_router # deferred import
return [
ModuleRouteSpec(
prefix="/network_scan",
router_factory=create_network_scan_router,
tool_keys=(NETWORK_SCAN_DISCOVER_TOOL, NETWORK_SCAN_PROBE_TOOL),
config_namespace="network_scan",
),
]
# --- Optional lifecycle methods (defaults inherited if not overridden) ---
async def seed_data(self, session: AsyncSession) -> None:
"""Seed default configuration. Idempotent via SeedVersionRecord."""
from sqlmodel import select
from aila.storage.db_models import SeedVersionRecord
existing = (await session.exec(
select(SeedVersionRecord).where(
SeedVersionRecord.module_id == self.module_id
)
)).first()
if existing is not None and existing.seed_version == SEED_VERSION:
return
# Seed your default data here (policies, lookup tables, etc.)
# session.add(NetworkScanPolicy(name="default", ...))
if existing is None:
session.add(SeedVersionRecord(
module_id=self.module_id, seed_version=SEED_VERSION
))
else:
existing.seed_version = SEED_VERSION
session.add(existing)
await session.commit()
def filter_report_rows(
self,
rows: list[JsonObject],
filters: JsonObject | None = None,
) -> list[JsonObject]:
"""Filter report rows. Return all rows unchanged when filters is empty."""
if not isinstance(filters, dict) or not filters:
return list(rows)
# Apply module-specific filtering logic
return list(rows)
def report_filter_keys(self) -> list[str]:
"""Return valid filter keys for filter_report_rows."""
return ["host", "port"]
async def system_summary(self, system_id: int, session: AsyncSession) -> dict[str, Any]:
"""Contribute data to GET /systems/{id}. Return {} if nothing to add."""
return {}
async def report_count(self, run_id: str, session: AsyncSession) -> dict[str, Any]:
"""Contribute count data to GET /reports/{run_id}/count."""
return {}
def health_checks(self) -> dict[str, object]:
"""Return health checks for GET /health."""
return {}
def create_module() -> ModuleProtocol:
"""Module factory. Zero-argument, returns ModuleProtocol instance."""
return NetworkScanModule()Create api_router.py. Paths are relative -- the platform mounts at the prefix declared in route_specs(). Frontend/API routes are public contracts: use explicit request/response models, do server-side pagination/filtering, and map internal names to stable API fields at the router boundary.
# api_router.py
from __future__ import annotations
from fastapi import APIRouter
from pydantic import Field
from aila.api.schemas.common import APIModel
__all__ = ["create_network_scan_router"]
class NetworkScanStatusResponse(APIModel):
"""Response for GET /network_scan/status."""
module: str = Field(description="Module identifier")
status: str = Field(description="Module operational status")
def create_network_scan_router() -> APIRouter:
"""Create the network_scan module router.
Returns:
A FastAPI APIRouter with module-specific endpoints.
"""
router = APIRouter(tags=["network_scan"])
@router.get("/status", response_model=NetworkScanStatusResponse)
async def network_scan_status() -> NetworkScanStatusResponse:
"""Return network_scan module status."""
return NetworkScanStatusResponse(module="network_scan", status="ok")
return routerStart the platform. Module discovery validates the contract at boot:
python -m compileall src/aila
python -m aila serveIf module_id mismatches the folder name, capability_profiles() is empty, or required_tools() returns an empty list, the platform fails fast with a descriptive error. Successful boot confirms: tool registration, route mounting, seed execution, and health check wiring all passed.
| What | Validated by | When |
|---|---|---|
module_id matches folder name |
Platform module loader | Boot |
capability_profiles() non-empty |
Module registry | Boot |
required_tools() non-empty |
Module registry | Boot |
| No duplicate tool keys | ToolRegistry | Boot |
action_id starts with module_id. |
Module registry | Boot |
| DB tables exist | SQLAlchemy create_all() |
Boot |
seed_data() idempotent |
SeedVersionRecord check | Boot |
| Routes mount at correct prefix | FastAPI include_router | Boot |
| Handler concurrency declarations | validate_handler_concurrency() |
Boot |
| No parallel write conflicts | validate_handler_concurrency() |
Boot |
Every handler function registered in a module's HANDLER_REGISTRY MUST declare two attributes immediately after its definition:
Whether this handler can safely run in parallel with other handlers in the same workflow.
True-- handler only reads context, or writes to fields that no other parallel handler touches.False-- handler mutates shared state and must run sequentially.
When in doubt, use False. Only mark True when you can guarantee no write overlap with other parallel-safe handlers.
The top-level context attributes this handler may mutate during execution.
- Empty list
[]for read-only handlers (e.g., validation-only or emit-only stages). - List context attribute names like
["analysis_state", "scoring_summary"]for handlers that write. - Include every field the handler assigns to, even conditionally.
Attributes are set on the function object after its definition:
async def state_scoring(context: WorkflowExecutionContext) -> WorkflowStage | None:
"""Score vulnerabilities and write summary."""
context.scoring_summary = compute_scores(context.analysis_state)
return WorkflowStage.REPORT
state_scoring.parallel_safe = False
state_scoring.writes_fields = ["scoring_summary"]
async def state_response_emit(context: WorkflowExecutionContext) -> None:
"""Read-only validation before response emission."""
validate_response(context)
state_response_emit.parallel_safe = True
state_response_emit.writes_fields = []At import time, the platform runs validate_handler_concurrency() on every module's handler registry. This validator enforces:
- Every handler has both attributes. Missing
parallel_safeorwrites_fieldsraisesConcurrencyConflictErrorand prevents startup. - No parallel write conflicts. If two handlers both declare
parallel_safe=Trueand list the same field inwrites_fields, the platform raisesConcurrencyConflictError. - Non-parallel handlers are unconstrained. Handlers with
parallel_safe=Falsemay write any field without conflict checks -- they run sequentially by contract.
Call validate_handler_concurrency() alongside existing handler validations. The call converts enum-keyed registries to string-keyed dicts:
from aila.platform.modules.concurrency_validator import validate_handler_concurrency
# After populate_handler_registry() and validate_workflow_handlers()
validate_handler_concurrency(
{stage.value: fn for stage, fn in HANDLER_REGISTRY.items()},
module_id="my_module",
)For simple modules with a single workflow.py, place the call after the import-time stage validation block.
These are concrete mistakes that module authors encounter. Where applicable, the honesty_audit AST rules catch violations automatically.
Importing from another module's internals instead of through platform contracts.
# WRONG -- violates import boundary
from aila.modules.other_module.services import OtherService
# CORRECT -- use platform contracts or services
from aila.platform.contracts import SharedContractThe honesty_audit AST rule import_boundary catches cross-module imports at CI time.
Every public module and __init__.py must define __all__. The honesty_audit rule private_in_all catches underscore names in __all__.
# WRONG -- no __all__ in a public module
def my_function():
...
# CORRECT
__all__ = ["my_function"]
def my_function():
...Calling session.add(MyRecord(...)) without first registering MyRecord via schema_registry.push() in register_tools().
Symptom: sqlalchemy.exc.OperationalError: no such table: my_record
# WRONG -- table class not pushed to schema_registry
async def register_tools(self, tool_registry, settings, registry=None, schema_registry=None):
for spec in iter_tool_specs():
tool_registry.register(spec.key(), spec.factory(settings))
# MyRecord never registered -- will crash at runtime
# CORRECT
async def register_tools(self, tool_registry, settings, registry=None, schema_registry=None):
if schema_registry is not None:
schema_registry.push(MyRecord)
for spec in iter_tool_specs():
tool_registry.register(spec.key(), spec.factory(settings))seed_data() receives a caller-owned session. Do NOT call session_scope() or create a new engine.
Symptom: Locked DB or missing writes (separate session commits are invisible to the caller's transaction).
# WRONG
async def seed_data(self, session):
async with async_session_scope() as new_session: # creates a second connection
new_session.add(MyRecord(...))
await new_session.commit()
# CORRECT
async def seed_data(self, session):
session.add(MyRecord(...))
await session.commit()Defining router paths as /vulnerability/findings when the platform already mounts the router at /vulnerability. Router paths should be relative.
# WRONG -- prefix is duplicated
@router.get("/vulnerability/findings")
async def list_findings(): ...
# CORRECT -- path is relative to the mount prefix
@router.get("/findings")
async def list_findings(): ...ModuleRouteSpec is a frozen dataclass. Using a list for tool_keys raises TypeError at construction if the field's default factory expects a tuple.
# WRONG
ModuleRouteSpec(prefix="/x", router_factory=f, tool_keys=["a", "b"])
# CORRECT
ModuleRouteSpec(prefix="/x", router_factory=f, tool_keys=("a", "b"))Importing the API router at the top of module.py pulls in FastAPI and all route dependencies at discovery time. Use a deferred import inside route_specs() to keep discovery lightweight.
# WRONG -- heavyweight import at module scope
from my_module.api_router import create_my_router
class MyModule:
def route_specs(self):
return [ModuleRouteSpec(prefix="/x", router_factory=create_my_router)]
# CORRECT -- deferred import
class MyModule:
def route_specs(self):
from my_module.api_router import create_my_router
return [ModuleRouteSpec(prefix="/x", router_factory=create_my_router)]module_id must match the folder name exactly. Platform enforces this at registration and will reject the module with a clear error if they differ.
# Folder: src/aila/modules/vulnerability/
# WRONG
module_id = "vuln"
# CORRECT
module_id = "vulnerability"