Skip to content

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-curator agent).

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.py depends on nothing, db.py owns the engine, the schema and the mappings — no other module constructs an engine or reaches for sqlite3, though feature modules do issue their own queries through the session it hands them — and llm.py is 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-app container (ADR-0044); the topology below it is the same either way. Dashed lines are connections that do not exist — the --internal network 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 whose iptables allowlist admits only the scoped IP(s) — the sandbox holds no NET_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}, stageextract | 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)