Architecture — C4 model
Authored diagrams: these do NOT auto-sync with code. Any PR changing a flow or boundary depicted here must update this page (checked by the
doc-curatoragent).
Level 1 — System context
flowchart TB
AUD(["Security auditor / developer<br/>person<br/>wants to know whether reported findings are fixed"])
REV["revalid<br/>the system<br/>parses pentest reports and re-validates each finding<br/>against an authorised target"]
LLM["LLM backend<br/>external system<br/>Claude API or local Ollama, one config switch (ADR-0010)"]
LAB["Authorised lab targets<br/>external system<br/>Juice Shop, ... — reachable only inside the sandbox network"]
AUD -->|"uploads a report, sets the goal,<br/>approves commands, reads verdicts"| REV
REV -->|"finding text<br/>synthetic / lab data only"| LLM
REV -->|"non-destructive verification commands"| LAB
classDef person fill:#dbe4ff,stroke:#3b5bdb,stroke-width:2px
classDef system fill:#d0ebff,stroke:#1971c2,stroke-width:2px
classDef ext fill:#f1f3f5,stroke:#868e96,stroke-width:2px
class AUD person
class REV system
class LLM,LAB ext
Level 1 answers who uses this and what it touches. The two external systems are the only things revalid talks to, and it reaches the lab only through the egress-locked sandbox of Level 3.
Level 2 — Containers
The whole tool runs from one uvicorn process bound to 127.0.0.1 (NFR-03):
FastAPI serves the React SPA at / and the JSON API under /api (ADR-0013).
%%{init: {"flowchart": {"wrappingWidth": 320, "rankSpacing": 28, "nodeSpacing": 22}}}%%
flowchart TB
AUD(["Security auditor<br/>person"])
subgraph BOUND["revalid — localhost only (NFR-03)"]
direction TB
SPA["React SPA<br/>FR-11 · FR-16<br/>FR-17 · FR-18"]
API["FastAPI app<br/>served under /api"]
WRK["Background tasks<br/>Starlette threadpool"]
DB[("SQLite<br/>via SQLAlchemy")]
end
LLM["LLM backend<br/>external<br/>Claude / Ollama<br/>(ADR-0010)"]
LAB["Authorised lab targets<br/>external — reachable only<br/>inside the FR-06 sandbox"]
AUD -->|"localhost"| SPA
SPA -->|"JSON + WebSocket over /api"| API
API --> WRK
API --> DB
WRK --> DB
WRK -->|"extraction + agent reasoning<br/>synthetic / lab data only"| LLM
API -->|"approved commands, inside an egress-locked<br/>Docker sandbox — FR-06 is network membership"| LAB
classDef person fill:#dbe4ff,stroke:#3b5bdb,stroke-width:2px
classDef container fill:#d0ebff,stroke:#1971c2,stroke-width:2px
classDef store fill:#e6fcf5,stroke:#0ca678,stroke-width:2px
classDef ext fill:#f1f3f5,stroke:#868e96,stroke-width:2px
class AUD person
class SPA,API,WRK container
class DB store
class LLM,LAB ext
style BOUND fill:none,stroke:#1971c2,stroke-dasharray:6 4
What each container owns (kept in prose rather than in the boxes, so the diagram stays legible when it is rendered into the thesis):
- React SPA — report overview with risk profile; the finding stage wizard (extract / goal / retest / verdict) with versioned edits and stage-tagged notes (FR-16); the agentic retest console (FR-17); read-only corpus chat (FR-18).
- FastAPI app — ingest and extraction, with the CVSS/MITRE taxonomy derived
during extraction and opt-in on the other doors (FR-19);
finding revision and notes (FR-16); goal drafting; gated agentic sessions in an
egress-locked sandbox (FR-17); corpus Q&A (FR-18); the FR-10 audit and FR-12
export; and serving the compiled SPA at
/. - Background tasks — PDF parse → extraction, and every agent step, off the request path.
- SQLite — reports; findings as identity plus append-only versions, each carrying CVSS and MITRE; notes; retest sessions and their transcript; verdicts and evidence; chat threads.
Level 3 — Components (backend modules)
The decomposition of the FastAPI container. Arrows are real Python imports —
app.py is the composition root that wires every component and owns the routes;
domain.py is the typed core everything depends on and which depends on
nothing. Persistence (db.py) is the only module that talks to SQLite, and
llm.py is the only one that constructs a model.
%%{init: {"flowchart": {"useMaxWidth": false, "wrappingWidth": 125, "rankSpacing": 30, "nodeSpacing": 18}, "themeVariables": {"fontSize": "26px"}}}%%
flowchart TB
APP["app.py<br/>composition root"]
subgraph UND["report understanding"]
direction TB
PDF["pdf.py<br/>FR-01"]
EXT["extract.py<br/>FR-03, FR-19"]
IGS["ingest.py<br/>FR-02, FR-19"]
FND["findings.py<br/>FR-16"]
end
subgraph RET["agentic retest"]
direction TB
PLN["plan.py<br/>goal (ADR-0032)"]
RSS["retest_session.py<br/>orchestrator"]
RAG["retest_agent.py<br/>agent + 2 tools"]
SBX["sandbox.py<br/>containment"]
SCP["scope.py<br/>FR-06 target host"]
DLT["deltas.py<br/>live token channel"]
end
subgraph DER["derivations & side channel"]
direction TB
AUD["audit.py<br/>FR-10"]
EXP["export.py<br/>FR-12"]
EVL["eval.py<br/>FR-15"]
CHT["reports_chat.py<br/>FR-18"]
end
subgraph CORE["shared core"]
direction TB
DOM["domain.py<br/>typed core"]
DBM["db.py<br/>sole SQLite door"]
LLMM["llm.py<br/>sole model builder"]
SET["settings.py<br/>FR-13"]
end
APP --> UND
APP --> RET
APP --> DER
APP --> SET
EXT --> PDF
EXT --> LLMM
PLN --> LLMM
RAG --> LLMM
CHT --> LLMM
CHT --> FND
RSS --> RAG
RSS --> SBX
RSS --> DLT
RSS --> SCP
RAG --> SBX
SBX --> SCP
EXP --> FND
EVL --> EXP
SET --> LLMM
FND --> DBM
RSS --> DBM
AUD --> DBM
EXP --> DBM
CHT --> DBM
SET --> DBM
DBM --> DOM
LLMM --> DOM
classDef root fill:#ffec99,stroke:#f08c00,stroke-width:2px
classDef core fill:#fff9db,stroke:#f08c00,stroke-width:2px
classDef comp fill:#e7f5ff,stroke:#1971c2,stroke-width:2px
class APP root
class DOM,DBM,LLMM,SET core
class PDF,EXT,IGS,FND,PLN,RSS,RAG,SBX,AUD,EXP,EVL,CHT comp
Module responsibilities, in prose so the diagram stays readable at print size:
pdf.py turns a PDF into whole-document Markdown; extract.py turns that into
schema-validated findings in one LLM call and owns the FR-19 taxonomy — both
inside the extraction call and, for the other doors, as the opt-in
enrich_findings pass; ingest.py covers the two LLM-free doors (DefectDojo
JSON and manual entry) and copies a stated CVSS or ATT&CK across without a
model; findings.py owns versioned findings and notes, storing whatever
taxonomy those two produced but never deriving one itself. On the retest side, plan.py generates the
retest goal, retest_session.py is the orchestrator (lifecycle, transcript, the
approval gate, verdicts), retest_agent.py holds the agent and its two tools —
the gated run_command and the ungated respond — and sandbox.py provides the
Sandbox protocol with its DockerSandbox and FakeSandbox implementations.
Two small modules complete that side: scope.py parses each scope endpoint down
to the host the sandbox is provisioned against (FR-06, ADR-0041/0045), and deltas.py
is the deliberately non-persisted channel carrying the model's reasoning
tokens to the console while a turn is in flight — a half-finished thought is not
evidence, so it never reaches the transcript.
The derivations are audit.py (re-project the transcript, diff the verdicts),
export.py (versioned run document plus generated schema), eval.py (score an
export against ground truth) and reports_chat.py (read-only corpus Q&A).
Arrows are real Python imports. Every component also imports
domain.py; those arrows are omitted so the diagram stays readable. The three invariants worth keeping:domain.pydepends on nothing,db.pyowns the engine, the schema and the mappings — no other module constructs an engine or reaches forsqlite3, though feature modules do issue their own queries through the session it hands them — andllm.pyis the only one that constructs a model.
Deployment — runtime and network topology
The whole tool is one process on the loopback interface (NFR-03). The only non-trivial part is the retest sandbox, and its topology is the FR-06 control: the agent cannot reach an unauthorised host because no route exists, not because a check rejected it.
That process runs one of two ways, and the sandbox topology is identical in
both — which is precisely why containerising the app was safe to do. Either it
runs directly on the host from a checkout (make run), or it runs inside the
revalid-app container (make deploy, ADR-0044) with the host Docker socket
mounted, creating the session network and sandbox as siblings rather than
children. The session network is created by the same daemon either way, so the
egress lock is unaffected by where the process asking for it lives. The cost of
the second mode is stated plainly in ADR-0044: mounting the socket is
root-equivalent on the host, accepted only under the single-operator threat model
(ADR-0008).
The topology below is lab mode — the default, and the one the evaluation
runs on. Since ADR-0041 the sandbox is provisioned against the scope host the
operator set at launch rather than the hardcoded lab, so an online target instead
gets gateway mode (ADR-0045): the scoped host is resolved to its IPv4
address(es) and a per-session egress gateway container installs an iptables
OUTPUT allowlist for those IP(s); the sandbox runs inside the gateway's network
namespace with NET_RAW but not NET_ADMIN, so every tool reaches the scoped
host and nothing else and no command can change the rules. Provisioning fails
closed — any error tears the session down rather than opening egress. (This
replaced ADR-0041's L7 Squid proxy, which carried only HTTP and so left nmap and
raw-socket tools with no route to an online target.)
Both modes are drawn, rule by rule, on the network topology page, which also covers per-session resource naming, the teardown order and the limits of the guarantee. This section stays at deployment altitude: where the process runs, and what it talks to.
%%{init: {"flowchart": {"useMaxWidth": false, "wrappingWidth": 150}, "themeVariables": {"fontSize": "24px"}}}%%
flowchart TB
subgraph host["operator's machine"]
BR["Browser<br/>127.0.0.1:8000"]
subgraph proc["single uvicorn process — NFR-03"]
API["FastAPI /api"]
SPAF["compiled SPA at /<br/>frontend/dist"]
BG["background tasks<br/>Starlette threadpool"]
end
DBF[("revalid.db<br/>SQLite file")]
DOCK["Docker daemon"]
end
subgraph net["revalid-retest-{id} — internal network"]
SBX["sandbox container<br/>Kali toolbox, sleep infinity"]
LAB["revalid-juice-shop<br/>the authorised target"]
end
INET(["the internet"])
BR -->|"HTTP + WebSocket"| API
BR --> SPAF
API --> BG
API --> DBF
BG --> DBF
API -->|"exec approved command"| DOCK
DOCK --> SBX
SBX <-->|"only reachable peer"| LAB
SBX -.->|"no route"| INET
SBX -.-> host
BG -->|"model calls"| INET
style net fill:#fff5f5,stroke:#e03131
style proc fill:#e7f5ff,stroke:#1971c2
style INET fill:#f1f3f5,stroke:#868e96
The single uvicorn process runs either directly on the operator's machine or inside the
revalid-appcontainer (ADR-0044); the topology below it is the same either way. Dashed lines are connections that do not exist — the--internalnetwork has no gateway, so the sandbox can reach neither the host nor the internet.start()self-heals resources left behind by a crashed prior session of the same id;stop()tolerates "already gone" at every step (and works by name, so a report deletion reaps even a session the registry forgot after a restart) so a partial failure cannot block teardown. In gateway mode (an online scope host, ADR-0045) the sandbox instead shares the network namespace of a per-session egress-gateway container whoseiptablesallowlist admits only the scoped IP(s) — the sandbox holds noNET_ADMIN, so it cannot change that; every dashed line stays dashed for anything not allowlisted.
Level 3 — Walking-skeleton retest flow (M6, agentic)
M1's walking skeleton (FR-02 → FR-07 → FR-09) shipped a deterministic slice: one
verification-only HTTP probe, executed through a request-level target allowlist,
yielding an evidence-backed verdict. That whole batch path (plan.py's HTTP
executor, the allowlist transport, the FR-08 sanity checker) was retired in
FR-17 Slice 6b-iii (ADR-0033); this diagram now shows its replacement — the
current minimal loop a retest walks (ADR-0025 Slice 0): one gated command,
approved by a human, executed inside an egress-locked sandbox, concluding a
verdict. It is the smallest instance of the gated sandbox execution
loop shown in full further down.
sequenceDiagram
actor Dev as Auditor/Dev
participant API as FastAPI (app.py)
participant DB as SQLite
participant Agent as RetestAgent (retest_agent.py)
participant SB as DockerSandbox (--internal network, FR-06)
participant Lab as Juice Shop (lab)
Dev->>API: POST /api/findings/import (DefectDojo JSON)
API->>DB: persist Finding
Dev->>API: POST /api/findings/{id}/retest-session
API->>DB: create RetestSessionRecord (working)
API-->>Dev: 202 session
API->>Agent: run_first_step (background) → start_and_step
Agent->>Agent: propose run_command("curl ... login", rationale)
Agent-->>API: DeferredToolRequests (awaiting_command)
Dev->>API: POST .../commands/{cid}/approve
API->>SB: sandbox.exec(command, timeout)
SB->>Lab: forward (only the connected lab container is reachable)
Lab-->>SB: 200 + auth token (evidence)
SB-->>API: CommandResult (stdout/stderr/exit_code)
API->>Agent: resume (ToolApproved)
Agent->>Agent: observe → conclude ConcludeOutput(status, rationale)
Agent-->>API: ConcludeOutput (Auto-run only — guided hands back instead)
API->>API: record_verdict → VerdictRecord (agentic) + AgenticEvidence
API->>DB: persist Verdict
API-->>Dev: Verdict JSON
Level 3 — FR-11 UI ingest → verdict flow (M6, agentic)
The full flow operated from the React SPA alone (FR-11 acceptance): a PDF report
is uploaded, extracted into findings by a background worker the UI polls, then
the operator drafts and edits the retest goal, starts an agentic retest
session, and walks the gated command loop to a verdict — every step a UI action
over /api (ADR-0013). The gated propose → approve → exec → observe cycle is
shown here as one loop; FR-06/FR-17 gated sandbox execution
below zooms into one iteration of it.
The finding detail is a stage wizard (ADR-0024, reshaped for the agentic
flow in FR-17 Slice 6b-iii-b): the operations shown below are reached as four
sub-routes (/findings/{id}/{stage}, stage ∈ extract | goal | retest |
verdict) the operator walks by clicking the pipeline stepper — navigation
only, never a mutation. FR-16 adds two sibling /api actions on the finding
itself: POST /findings/{id} records a versioned finding edit (append-only,
extraction = v1) and POST /findings/{id}/notes appends a stage-tagged note;
both are read back by the wizard.
sequenceDiagram
actor U as Auditor (browser)
participant SPA as React SPA
participant API as FastAPI (/api)
participant W as Background tasks
participant DB as SQLite
participant LLM as LLM backend
participant Agent as RetestAgent (sandboxed, FR-17)
participant Lab as Lab target
U->>SPA: drop PDF report
SPA->>API: POST /api/reports (multipart)
API->>DB: insert report (extracting)
API-->>SPA: 202 report
API->>W: schedule extraction
W->>LLM: FR-01 parse → FR-03 extract
W->>DB: insert findings (report_id), report → ready
loop poll until ready / failed
SPA->>API: GET /api/reports/{id}
API-->>SPA: {status, ...}
end
U->>SPA: review finding → draft goal
SPA->>API: POST /api/findings/{id}/goal/draft
API->>LLM: generate_goal(finding) — best-effort, no persistence
API-->>SPA: GoalDraftOut {steps}
U->>SPA: edit goal (optional) → Start retest session
SPA->>API: POST /api/findings/{id}/retest-session {initial_goal}
API->>DB: create RetestSessionRecord (working)
API-->>SPA: 202 session
API->>W: schedule run_first_step
W->>Agent: start_and_step(goal prompt)
loop gated command loop (detail below) until a verdict or a hand-back (awaiting_operator)
Agent->>Agent: propose run_command / observe output
SPA->>API: GET /retest-sessions/{id} or WS /stream
U->>SPA: approve / reject each command
SPA->>API: POST .../commands/{cid}/approve|reject
end
Agent->>API: ConcludeOutput (Auto-run) / verdict recommendation (guided default)
U->>SPA: Conclude — guided, only the operator records it (ADR-0040/0046)
SPA->>API: POST .../conclude {status, rationale}
API->>API: record_verdict → VerdictRecord (agentic) + AgenticEvidence
API->>DB: persist verdict + transcript
API-->>SPA: verdict (poll / stream)
SPA-->>U: verdict, evidence, session transcript
Level 3 — FR-06/FR-17 gated sandbox execution (ADR-0025)
FR-17's agentic console makes the retest chokepoint human-in-the-loop on
every command, not just a batch (superseding M4's plan-level sanity check,
ADR-0033). Gating is a Pydantic AI deferred tool: run_command is declared
requires_approval=True, so a proposal literally cannot resolve without a
human approve/reject — the run pauses and returns a DeferredToolRequests,
never executing anything on its own. Once approved, the command runs inside a
DockerSandbox on a per-session Docker --internal network with only the
authorised lab container attached — FR-06 is now network membership, not
an HTTP-layer check: no route to anything else exists. (For an online scope host
the same requirement is met by the per-session L3 egress gateway of ADR-0045 —
an iptables allowlist for the scoped IP(s), in a helper container the sandbox
cannot alter, letting every tool through to the scoped host and nothing else.)
Three limits of that
boundary are recorded in the ADR-0025 update of 2026-07-22 and stated in the
memoir alongside the guarantee: the lock confines the agent, not code the agent
successfully executes on the target (which stays attached to lab_default);
internal mode blocks routed traffic but not name resolution on hosts with a
loopback DNS stub; and the system test asserting the lock passes on a failed
lookup alone, never probing a bare address.
%%{init: {"flowchart": {"useMaxWidth": false, "wrappingWidth": 150}, "sequence": {"useMaxWidth": false, "width": 150, "wrap": true}, "themeVariables": {"fontSize": "18px"}}}%%
sequenceDiagram
actor Dev as Auditor/Dev
participant API as retest_session.py
participant Agent as RetestAgent (Pydantic AI)
participant DB as session_events
participant SB as DockerSandbox
participant Lab as Lab target
API->>Agent: run / resume
Agent->>Agent: reason, propose run_command
Agent-->>API: DeferredToolRequests (paused)
API->>DB: command_proposed, awaiting_command
Dev->>API: approve | reject
alt approved
API->>SB: sandbox.exec(command, timeout)
SB->>Lab: the only reachable peer
Lab-->>SB: response
SB-->>API: CommandResult
API->>DB: append_event(command_output)
API->>Agent: resume (ToolApproved)
Agent->>Agent: observe, then act
else rejected
API->>Agent: resume (ToolDenied)
Agent->>Agent: reconsider next command
end
Note over API,Agent: loop until a determination — a hand-back parks in awaiting_operator, sandbox alive (ADR-0042/0046)
Agent-->>API: ConcludeOutput(fixed | still_open) — Auto-run only, guided recommends instead
API->>API: record_verdict + evidence
API->>DB: verdict event + row
Level 3 — FR-10 audit re-derivation & FR-12 export (M6, agentic-only)
Two read-only derivations off the persisted trail, no network. FR-10
(ADR-0025/ADR-0033's NFR-02 shift): an agentic verdict is a human-adjudicated
judgment over a whole session, not a pure function of one request's evidence,
so rederive_run re-projects the session's authoritative transcript event —
the verdict event for the agent's own record, or the latest
verdict_adjudicated event for an operator override — and diffs it against
the stored VerdictRecord: a clean run proves the row still equals the
transcript it was derived from; any mismatch is a discrepancy. (The old
FR-04/05/07-09 batch evidence-rederivation, ADR-0015, was removed with the
batch path in FR-17 6b-iii, ADR-0033.) FR-12 (ADR-0016): build_export
assembles the whole run into one SCHEMA_VERSION-versioned document that
validates against a schema generated from the model, so the published
schema can never drift from the document.
sequenceDiagram
actor U as Auditor / FR-15 harness
participant API as FastAPI (/api)
participant AU as audit.py (FR-10)
participant EXP as export.py (FR-12)
participant DB as SQLite
U->>API: GET /api/audit
API->>AU: rederive_run(session)
AU->>DB: read every VerdictRecord
loop each verdict
AU->>DB: read the session's transcript verdict event (verdict / verdict_adjudicated)
AU->>AU: compare transcript event vs stored status/rationale
end
AU->>AU: collect discrepancies (rows that drifted from their transcript)
API-->>U: AuditReport {total, reproduced, discrepancies}
U->>API: GET /api/export
API->>EXP: build_export(session)
EXP->>DB: read reports, findings, verdicts (id order)
EXP-->>API: RunExport {schema_version, generator, metrics, ...}
U->>API: GET /api/export/schema
API->>EXP: export_schema() = RunExport.model_json_schema()
API-->>U: JSON Schema (matches docs/reference/schemas/run-export.schema.json)