Systems Engineering · IXN

System Design

A comprehensive account of the platform's architecture: the layered decomposition of the backend, the component model of the frontend, the data pipeline, persistence strategy, and API contract — grounded entirely in the production codebase.

Site Map

The platform is a single-page application with six distinct routes, a floating chat widget accessible from every page, and eight individual vulnerability detail pages branching from the home catalogue.

Solid lines indicate direct navigation. The chat widget (dashed border) is mounted in the root layout and is accessible from every route without navigation.

1. High-Level Architecture

The platform is structured as two independently deployable processes — a React (Vite) frontend and a FastAPI backend — communicating exclusively over an HTTP REST API with a single Server-Sent Events (SSE) channel for streaming chat responses. This separation enforces a strict client/server boundary: all business logic, model access, and persistence reside in the backend; the frontend is a pure rendering and interaction layer with no direct database or LLM access.

External dependencies are confined to two integration points: the model providers (connected through Microsoft Foundry AI or custom HTTP endpoints) (used by the platform model provider and the AI analysis sub-system) and the Garak CLI (invoked as a managed subprocess for red-teaming framework runs). Both integrations are behind abstract interfaces in the domain layer, so neither affects the core business logic.

graph LR subgraph Browser["Browser — React 18 · Vite · React Router v7"] UI["Pages & Components"] SSEClient["SSE Client"] end subgraph Backend["Backend — FastAPI · Python 3.12"] API["API Layer"] --> APP["Application Layer"] --> DOMAIN["Domain Layer"] --> INFRA["Infrastructure Layer"] end DB[("SQLite")] subgraph Ext["External Services"] FOUNDRY["Azure AI Foundry
(GPT · Claude · Llama · Phi · DeepSeek · Mistral)"] EXTHTTP["External HTTP
(user-provided)"] GARAK["Garak CLI"] end UI -->|"REST JSON"| API SSEClient -->|"SSE stream"| API INFRA --> DB INFRA --> FOUNDRY INFRA --> EXTHTTP INFRA --> GARAK

Deployment topology: both processes run on the same host during development and are configured via environment variables (per-model FOUNDRY_*_ENDPOINT and FOUNDRY_*_KEY pairs for each Azure AI Foundry deployment, plus DATABASE_URL and VITE_API_URL). No separate message broker, cache, or authentication service is required.

2. Design Principles & Extensibility

The central architectural decision of this platform is that every major extension point (adding a new LLM provider, a new test runner, or a new mitigation strategy) is defined by a single-method abstract base class in the domain layer. No routing logic, service code, or API layer needs to change. The entire system is open for extension and closed for modification.

Three Core Interfaces

The domain layer defines exactly three abstract base classes. Together they cover every way the platform interacts with external models and applies security controls.

LLM Integration
ModelProvider
class ModelProvider(ABC):

  @abstractmethod
  async def generate(
    self,
    request: ModelRequest
  ) -> ModelResponse:
    """Execute a model call."""
    pass
Test Execution
TestRunner
class TestRunner(ABC):

  @abstractmethod
  async def run(
    self,
    test: Test,
    prompt: Message | None
  ) -> TestResult:
    pass
Security Controls
Mitigation
class Mitigation(ABC):
  layer: MitigationLayer

  @abstractmethod
  def apply(
    context: MitigationContext
  ) -> MitigationContext:
    pass

What Extensibility Looks Like in Practice

Because all business logic is written against these interfaces, and never against concrete classes, every extension requires changes in exactly one place.

To add… Implement Register in Services / API changed
A new LLM (e.g. Gemini) GeminiProvider.generate() MODEL_REGISTRY None. ProviderRouter resolves it automatically.
A new test framework (e.g. PyRIT) PyritRunner.run() RunnerRouter None. The API and TestService are unaware of runner types.
A new mitigation (e.g. semantic similarity guard) NewMitigation.apply() MITIGATION_REGISTRY None. The runner pipeline applies all registered mitigations in layer order.

Open/Closed Principle in action: the platform shipped 10 LLM models, 12 mitigations, and 2 test runners without a single change to any service, router, or API route. Each was added by implementing one method and inserting one registry entry.

Supporting Architectural Patterns

Three further patterns work together to keep the interfaces clean and the system testable end-to-end.

Layered Architecture

Dependencies always point inward. The domain layer — which owns all three ABCs — has zero imports from FastAPI, SQLAlchemy, or any third-party library. Outer layers depend on inner ones; the reverse never occurs.

Dependency Injection

A central Container wires all service objects at startup. FastAPI route handlers receive services via Depends(), so no service instantiates its own dependencies — every concrete class is substitutable without touching call sites.

Repository Pattern

Persistence is mediated through TestRepository, which translates between domain dataclasses and SQLAlchemy ORM models. Services call repository methods; they are entirely unaware of the SQL dialect or table schema beneath.

Dependency Rule Visualised

An arrow from layer A to layer B means "A imports B". The domain layer — where all three interfaces live — is at the centre and has no outward arrows.

graph LR API["API Layer
routes · schemas · FastAPI"] APP["Application Layer
TestService · ChatService
ProviderRouter · RunnerRouter"] DOMAIN["Domain Layer
ModelProvider · TestRunner · Mitigation
Test · ModelSpec · RunnerSpec"] INFRA["Infrastructure Layer
PlatformProvider · ExternalProvider
PromptRunner · GarakRunner
12 Mitigation classes · TestRepository"] API -->|"imports"| APP APP -->|"imports"| DOMAIN INFRA -->|"implements"| DOMAIN API -.->|"depends on"| INFRA

3. Frontend Architecture

The frontend is a React 18 single-page application built with Vite and React Router v7. It is organised around six top-level pages, each a dedicated TypeScript module under app/pages/, backed by shared shadcn/ui components and a floating chat widget accessible from every page. All LLM-facing and persistence operations are delegated to the backend; the frontend holds no persistent state beyond React component state and browser localStorage (chat history).

graph LR ROOT["Root Layout
(ThemeProvider)"] subgraph Pages["Pages — app/pages/"] P1["HomePage"] P2["VulnerabilityPage"] P3["MitigationsPage"] P4["TestingPage"] P5["PromptEnhancerPage"] P6["SecurityKnowledgeAssistantPage"] end subgraph Shared["Shared Infrastructure"] WIDGET["ChatWidget
(floating)"] TYPES["TypeScript types"] HOOKS["SSE hook"] UI["shadcn/ui"] end ROOT --> Pages ROOT --> WIDGET P4 --> TYPES P6 --> HOOKS P2 & P3 & P4 --> UI

Key Frontend Subsystems

SubsystemLocationResponsibility
Vulnerability catalogue HomePage.tsx Real-time search and filter across all 8 vulnerability types; staggered card entrance animations
Test workbench TestingPage.tsx Multi-step form for model spec, environment, and runner; serialises to RunnerSpec/ModelSpec shapes and POSTs to /tests
Prompt enhancer PromptEnhancerPage.tsx Three-stage pipeline UI (improve → prepend mitigations → verify); side-by-side diff view of original vs. enhanced prompt
Chat assistant SecurityKnowledgeAssistantPage.tsx + ChatWidget Opens a text/event-stream SSE connection; accumulates streaming tokens into a React state buffer; persists history in localStorage
PDF export lib/pdf/ Uses @react-pdf/renderer to compose a structured PDF report of test results entirely client-side
Component library components/ui/ shadcn/ui components (buttons, dialogs, tables, tabs, toasts, etc.) providing a consistent Radix UI primitive foundation with dark-mode support

Shared TypeScript types serve as the interface contract between frontend and backend. Every REST response is typed against these definitions, ensuring that any backend schema change propagates as a compile-time type error rather than a silent runtime failure.

4. Backend Layered Architecture

The backend is decomposed into four layers. Each layer is a distinct Python package under src/, and the dependency rule is enforced by import discipline: no module in an inner package may import from an outer one.

graph LR subgraph api["API Layer — src/api/"] R["tests · knowledge · config
chat · enhancements · health"] end subgraph app["Application Layer — src/app/"] S["TestService · ProviderRouter
RunnerRouter · ChatService"] end subgraph domain["Domain Layer — src/domain/"] E["Test · ModelSpec · RunnerSpec
ModelProvider · TestRunner · Mitigation"] end subgraph infra["Infrastructure Layer — src/infra/"] I["PlatformProvider · ExternalProvider
PromptRunner · GarakRunner
TestRepository · PromptTestAnalyser"] end api --> app --> domain infra -->|"implements"| domain api -.->|"depends on"| infra

Layer Responsibilities

LayerPackageResponsibilitiesKey Constraints
API src/api/ HTTP routing, request validation (Pydantic schemas), response serialisation, OpenAPI documentation No business logic; delegates immediately to application services
Application src/app/ Orchestration of domain objects and infrastructure calls; transaction boundaries; provider/runner routing No direct I/O; no SQL; must remain unit-testable without network access
Domain src/domain/ Pure business entities (frozen dataclasses), value objects, abstract interfaces for extension points, domain enumerations Zero third-party imports; no I/O of any kind; represents the stable core
Infrastructure src/infra/ Concrete model providers, test runners, mitigation implementations, ORM models, repositories, analysis engine, Garak subprocess management Implements domain interfaces; handles all side effects; swappable without changing business logic

Model and Mitigation Registries

The infrastructure layer exposes two module-level dictionaries that act as service locators for pluggable strategies. MODEL_REGISTRY maps model identifiers (e.g. "gpt-5.2") to ModelProvider instances; MITIGATION_REGISTRY maps slug strings (e.g. "pre_filter") to Mitigation instances. Both registries are populated at module import time, making new providers and mitigations addable without modifying any service code.

5. Database Architecture

The backend uses a single SQLite database file at sqlite:///./app.db, accessed via SQLAlchemy ORM and initialised at startup with Base.metadata.create_all(bind=engine). Persisted storage is deliberately narrow: only user-generated artefacts are written to disk. The platform's security knowledge base is held separately in backend code as in-memory registries.

tests

Stores executed tests.

ColumnTypeKey
idStringPK
nameString
modelJSON
environmentJSON
runnerJSON
created_atDateTime

Purpose: stores complete executed test records.

JSON fields: model, environment, and runner store the nested test configuration.

test_configurations

Stores saved test presets.

ColumnTypeKey
idStringPK
nameString
modelJSON
environmentJSON
runnerJSON
created_atDateTimeINDEX

Purpose: stores saved configurations before a test is run.

Difference from tests: same structure, but used for reusable presets rather than historical executions.

prompt_enhancements

Stores prompt enhancement runs.

ColumnTypeKey
idStringPK
original_promptString
improved_promptString
enhanced_promptString
selected_mitigation_idsJSON
verification_dataJSON
model_idString
attemptsInteger
created_atDateTimeINDEX

Purpose: stores the full output of the prompt enhancement pipeline.

JSON fields: selected_mitigation_ids stores the chosen mitigation list and verification_data stores the verifier result.

JSON column strategy: the model, environment, runner, selected_mitigation_ids, and verification_data fields are stored as JSON columns instead of being normalised into child tables. For tests, TestRepository.create() serialises the nested dataclasses with dataclasses.asdict(), and TestRepository._to_domain() rebuilds them on read, including enum coercion for RunnerType and rehydration of saved message history into Message objects. This keeps the relational schema small while leaving nested-structure validation in the application layer.

There are no foreign keys in the schema because each stored record is designed to be self-contained. A test row already includes its model, environment, and runner payloads; a prompt enhancement row already includes its selected mitigations and verification result. For a single-user SQLite-backed educational platform, this is a pragmatic tradeoff: simpler CRUD operations, no joins for common reads, and a domain model that evolves more quickly than the SQL schema.

How Vulnerabilities and Mitigations Are Stored

The vulnerability catalogue and mitigation knowledge are not database tables. They are stored in the backend as module-level Python dictionaries populated with immutable dataclass instances when the process starts.

Backend sourceStored asRole in the systemPersistence model
domain/knowledge/vulnerabilities.py VULNERABILITIES: Dict[str, Vulnerability] Canonical backend catalogue used by the Security Knowledge Assistant and keyword search Static, version-controlled Python registry rebuilt at every backend startup
domain/knowledge/mitigations.py MITIGATIONS: Dict[str, Mitigation] Knowledge-base mitigation entries used for retrieval and explanatory chat responses Static in-memory registry; not persisted to SQLite
infra/config/mitigations.py MITIGATION_REGISTRY: Dict[str, MitigationConfig] Executable mitigation configuration for prompt compilation and runner-layer defence application Static runtime registry containing both prompt-layer messages and concrete mitigation implementations

This split is important. SQLite stores operational artefacts created by users during normal use of the platform. By contrast, vulnerabilities and mitigations are shipped with the codebase as trusted reference content. The chat assistant queries these in-memory registries through search_knowledge_base(), which tokenises and ranks the Python-defined entries directly without any SQL query, migration, or seed data step.

The overall database architecture is therefore hybrid in practice: relational persistence for mutable workflow state plus static code-defined registries for security knowledge. Both are part of backend state management, but only the former is managed by SQLAlchemy and written to the SQLite file on disk.

6. API Design

The backend exposes a RESTful HTTP API mounted under /api/v1 with six route modules. All request and response bodies are JSON validated by Pydantic v2 schemas. The API contract is formally documented by FastAPI's auto-generated OpenAPI specification, accessible at /docs during development.

Route modulePrefixKey endpoints
tests.py /tests GET / — list all tests
POST / — create test
GET /{id} — retrieve test
PUT /{id} — update test
DELETE /{id} — delete test
POST /{id}/run — execute test and return RunResult
knowledge.py /knowledge GET /vulnerabilities — full vulnerability KB
GET /mitigations — mitigation KB with MitigationLayer metadata
GET /search — BM25-weighted field search across both KBs
config.py /config GET /models — available platform model identifiers
GET /mitigations — registered mitigation slugs and metadata
chat.py /chat POST /stream — initiates an SSE stream; emits token-by-token assistant response via text/event-stream
enhancements.py /enhancements GET / — list saved prompt enhancements
POST / — store a new enhanced prompt
health.py /health GET / — liveness probe returning {"status": "ok"}

Request / Response Contract

The POST /tests/{id}/run endpoint is the most structurally complex, returning a RunResult object that varies by runner type:

FieldTypePresent forDescription
output string All Human-readable summary of the run outcome
analysis TestAnalysis All Composite verdict: flagged (bool), score (0–1), reason
started_at / finished_at ISO-8601 string All Wall-clock timestamps for the run
attempts AttemptResult[] Framework runs Per-probe attempt details: prompt sent, output received, blocked, compromised, HTTP statuses
report_html_url string | null Framework runs Relative URL to the Garak-generated HTML report file, served statically

The SSE endpoint at POST /chat/stream emits newline-delimited data: events. Each event carries a JSON object with a single token field. A terminal data: [DONE] event signals end-of-stream, following the SSE streaming convention standardised by Azure AI Foundry's compatible endpoints.

7. Robust Error Handling

Robustness is handled as a first-class architectural concern. The backend distinguishes between invalid user configuration, unknown mitigation setup, missing resources, and failures from external systems such as model providers or framework subprocesses. Instead of allowing these failures to crash the platform silently, each class of error is caught close to its source, converted into a controlled response, and surfaced back to the frontend with a clear failure reason.

Failure source How it is caught Backend response Frontend effect
Invalid model, runner, or environment configuration InvalidModelConfiguration raised in domain/service validation HTTP 400 with the validation message in detail The UI can show the user exactly which part of the request is invalid and prevent re-running until fixed
Custom or unknown mitigation IDs UnknownMitigation is raised during prompt compilation or mitigation-layer lookup, then translated into InvalidModelConfiguration HTTP 400 rather than an internal crash The user sees that the mitigation setup is unsupported or malformed instead of receiving a generic failure
Missing test or configuration records NotFoundError raised by the service layer HTTP 404 with a specific not-found message The frontend can tell the user the selected record no longer exists
External provider failures, HTTP errors, or Garak runtime failures Provider and runner layers raise RuntimeError after capturing upstream failure details HTTP 502 with the upstream error text in detail The request returns in a controlled way, allowing the UI to show that the failure came from an external dependency rather than the browser or frontend
Prompt enhancement verification failure EnhancementValidationError raised after the retry budget is exhausted HTTP 422 with an explicit explanation that validation did not pass The user is told that enhancement failed verification, rather than incorrectly being shown a "successful" result
Chat streaming failure mid-response The SSE generator catches the exception while streaming A final SSE delta is emitted with an error message, followed by a clean done event The chat widget degrades gracefully instead of hanging indefinitely on a broken stream

Why this is robust: the platform does not hide failures behind vague "something went wrong" behaviour. Validation errors are returned as client errors, upstream provider and framework failures are returned as gateway-style errors, and streamed chat failures are terminated cleanly. This means the frontend receives a usable response body for failed requests and can present users with a concrete reason for what is failing.

This is especially important for external integrations. When an OpenAI-compatible endpoint, Anthropic call, Azure content filter, custom external HTTP model, or Garak framework scan fails, the backend captures the upstream failure and returns it through the API instead of swallowing it. The result is operational transparency: users can distinguish a bad prompt, a broken mitigation configuration, a missing record, and an external-system outage because each travels back through the request path with a different status code and message.