Skip to content

Roadmap & resume point

Purpose: durable implementation plan. Any session (human or AI, fresh context) can pick up the project from this file. Keep it current: when a milestone's state changes, update the checkboxes and the "Current state" line in the same PR.

How to resume a session

  1. Read CLAUDE.md (rules), then this file (where we are), then the Kanban board (what's in flight).
  2. Requirements live in docs/requirements/srs.md (FR/NFR by ID); architecture decisions in docs/adr/ — ADR-0002 fixes the stack.
  3. Work item = one issue → feature branch → PR with "How to validate" → CI (Verify) → Álvaro validates (Validate) → squash merge. main is protected; direct pushes fail.

Current state (update me)

At a glance (2026-07-26). The software shipped as v1.0.0 (all six milestones closed) and post-release work continues on main. Two changes landed this day: the PDF ingestion was redesigned to render the whole report to Markdown and feed it to the model in one call, deleting the regex segmenter (ADR-0047, #294), and the Evaluation chapter was de-duplicated and refocused (#295). The board is empty. Every ADR from 0001 to 0047 is now accepted or superseded: Álvaro ratified ADR-0047 on 2026-07-26, which included relicensing the project from Beerware to AGPL-3.0-or-later — the copyleft the PyMuPDF4LLM/PyMuPDF ingestion dependency forces (reversing ADR-0007's rejection under the single-user threat model, ADR-0008). The LICENSE, pyproject.toml, README and thesis were updated to match (#299). The memoir is submission-ready at 127 pages with the ingestion and evaluation updates folded in; the open authoring decision remains whether to trim the ~90-page core toward the 80-page recommendation. See the last entry. The entries below are chronological, oldest first — the last one is the live state; any "next action" in an earlier entry was true on its own date and is history now.

2026-07-14M2 report-understanding RELEASED as v0.2.0 (tagged at the M2 close-out commit; M1 shipped earlier as v0.1.0). M1 done: package layout (FastAPI app factory, SQLite via SQLAlchemy, domain schemas); FR-02 ingest (#23); FR-06 allowlist/SSRF guard (#34); FR-07 probe executor + FR-09 evidence-backed verdicts (#12/#14) — one hardcoded SQLi login-bypass probe (src/revalid/retest.py) runs against the Juice Shop lab through the FR-06 AllowlistTransport, captures request/response/timing evidence, and yields a still_open/fixed/inconclusive Verdict with a machine-readable reason code; persisted (verdicts table) and exposed at POST /findings/{id}/retest + GET /verdicts. lab/docker-compose.yml (Juice Shop v17.1.1, pinned) + make lab-up/lab-down; make demo-walking-skeleton prints ingest→probe→verdict; the system test asserts still_open against the live lab (nightly system-tests.yml brings the lab up). Verified end-to-end locally (real HTTP 200 + JWT → still_open); security review + codebase-sanity audit both clean before the tag. Process: ADR-0004 right-sized the solo-dev workflow — kept the Kanban board, disabled the forced codebase-memory discovery gate (MCP still available on demand), reserved full PR ceremony for FR/NFR PRs. ADR-0005 removed the ai-compliance-auditor agent (§6 compliance is Álvaro's own judgement). CI on main is green. M2 is now in progress. FR-01 PDF ingestion (#6) is implemented — src/revalid/pdf.py (pdfplumber, ADR-0007) extracts per-page/full text plus best-effort finding candidates and fails closed with a clear error on non-PDF/corrupt/no-text input; validated against a synthetic multi-finding fixture (tests/data/juice_shop_report_synthetic.pdf, regenerable via scripts/gen_fixture_pdf.py). ADR-0007 (pdfplumber vs PyMuPDF-AGPL) is accepted. FR-03 LLM extraction (#8) is now implemented — src/revalid/extract.py (ADR-0009): per-candidate Pydantic AI agent, list[ExtractedFinding] schema gate, invalid output flagged not persisted; make demo-extract runs the FR-01→FR-03 pipeline (Claude with a key, else an offline stand-in). FR-13 (#18) is now implemented — src/revalid/llm.py (ADR-0010): the backend is selected by the REVALID_LLM_MODEL env var holding a Pydantic AI model string (default Claude; ollama:<model> plus OLLAMA_BASE_URL for the local fallback — native provider via the openai extra). Switching is configuration-only: unit + integration tests prove the env-only switch, a system-marked test runs the extraction suite on a live Ollama (skips without a server), and make demo-extract honors the same variable. Verified live on a local Ollama (qwen3.5:9b): 3/4 fixture findings extracted with ollama:* lineage, the 4th flagged by the FR-03 schema gate rather than persisted — the gate holds on weaker local models. M2 close-out is under way: the evaluation now runs against a real OWASP Juice Shop pentest report — Nozipho Mthimunye's public write-up (https://github.com/Nozipho-Mth/Juice-Shop-Pentest-Report), credited in tests/data/README.md and kept local/git-ignored (no upstream license → not re-hosted). FR-01 extracts it into 8 finding candidates with no manual preprocessing; a full FR-01→FR-03 run on the local ollama:qwen3.5:9b backend yielded 8/8 well-formed findings (100%, 78s) — clearing the ≥90% criterion even on a modest local model (Claude/FR-15 will produce the reported number). M3 has started: FR-04 retest-plan generation (#9) is implemented — src/revalid/plan.py (ADR-0011): an LLM proposes typed actions, then a deterministic gate binds each to the allowlisted base URL, drops anything the FR-06 guard rejects or any destructive method, and maps survivors to domain Probes in a RetestPlan (reuses Probe; inherits the FR-13 model switch). make demo-plan shows the gate dropping an off-allowlist action; verified live on ollama:qwen3.5:9b. FR-05 server-side approval gate (#10) is done — src/revalid/approval.py (ADR-0012): versioned plans rows, a single execute_approved_plan chokepoint that refuses without an approved version (AC1), edit/regenerate versioning with edited actions re-gated through FR-06 (AC2), executed version stamped on each verdict; verified via make demo-approval + a live-lab app-path system test. All ADRs through 0012 are now accepted (ratified 2026-07-14). FR-04/FR-05 landed on main after the v0.2.0 boundary and ship in v0.3.0. FR-11 React SPA (#16) is now implementedfrontend/ (ADR-0013, proposed): a Vite/React/TS/Tailwind SPA served by FastAPI at /, the API moved under /api; PDF upload runs FR-01→FR-03 as a background job the UI polls, then the SPA drives the FR-05 plan-approve-retest flow and renders verdicts + evidence + plan history. Verified end-to-end in a real browser on a live Ollama backend (upload → 4 findings → plan → approve → retest → verdict, no console errors); backend /api chain unit/integration-tested (coverage 96%), frontend has its own CI job (eslint/tsc/vite/vitest). The SPA was then reworked visually (PR #55, squash-merged to main): a dark/light-themeable "forensic verdict instrument" console with a left-nav sidebar (brand + Overview + live recent-reports jump list + theme toggle; sticky desktop / slide-in mobile drawer), a two-voice colour language (iris = the system's voice; red/amber/green = reality's verdict), and self-hosted IBM Plex Mono/Sans via @fontsource (offline, NFR-03) — presentational only, no API or architecture change, so ADR-0013 is unaffected; the frontend CI gates (eslint/tsc/vitest) stay green and the test-asserted strings + /api contract are unchanged. M3 is now complete and closed out: ADR-0013 (React SPA architecture) is accepted (ratified 2026-07-14). The pre-release codebase-sanity audit returned GO: all mechanical gates green (xenon --max-absolute C / radon avg A / vulture / pylint duplicate-code 10.0 clean; backend 126 tests @ 96% coverage; frontend eslint/tsc/vitest pass), no dead code / complexity regression / architectural drift, and the redesign confirmed presentational-only. The two non-blocking fast-follows the audit flagged for early M4 are now done (PR #58): (a) frontend test health — added @vitest/coverage-v8, a two-tier CI coverage floor (whole-app regression floor + per-file 100% pins on the owned modules), and Vitest unit tests for the redesign's pure logic (pipelineReach, DeterminationMeter, the activePlan/latestVerdict/verdictCounts selectors, errorMessage/ApiError); (b) de-duplicated the status→colour mapping into one lib/status.ts tone table and the button/pill class strings into shared ui/Button (4 variants) + ui/Badge primitives — presentational-only, no /api or behaviour change (11 test files, 35 tests, frontend gates green). Released as v0.3.0 on 2026-07-14 (bundles FR-04 plan generation + FR-05 approval gate + FR-11 SPA + the redesign). M4 (Trust & audit) is now complete and RELEASED as v0.4.0 (2026-07-15): FR-08 (#13, execution sanity checker, src/revalid/sanity.py, ADR-0014), FR-10 (#15, audit trail + verdict re-derivation, src/revalid/audit.py, ADR-0015), and FR-12 (#17, versioned run export, src/revalid/export.py, ADR-0016) all merged. FR-12 assembles a complete run (reports → findings → plans → verdicts+evidence + descriptive metrics) into one SCHEMA_VERSION-versioned JSON document whose schema is generated from the model, published to docs/reference/schemas/run-export.schema.json (make export-schema), drift-tested, and served at GET /api/export + /api/export/schema; make demo-export validates a run against it — the FR-15 evaluation harness (M5) consumes this format. The pre-release codebase-sanity audit returned GO (all mechanical gates green; 180 unit @ 97% coverage + 22 integration; sanity.py/audit.py/export.py all 100%), its one flagged gap (stale M4 C4 diagrams) fixed in #62, and ADRs 0014/0015/0016 accepted (ratified 2026-07-15). Next action: M5 — Evaluation (FR-15 #20): pin the ground-truth Juice Shop lab + Álvaro's expected verdict per finding, then build the evaluation harness that consumes the FR-12 export into a metrics table (NFR-01: ≥70% correct, zero confidently-wrong on ambiguity) → release v1.0.0.

2026-07-15 (M5 in progress) — retest-technique registry (ADR-0019, proposed): a design review with Álvaro reframed the retest UX around a human-in-the-loop control surface — plan shown as curl/browser-step commands → approve → execute → LLM summaryhuman adjudicates closed / still-open / retest-differently; the model/provider becomes a user setting (default ollama:qwen3.6:27b); scope stays human-validated (no silent host rewriting — the lab-host mismatch is an evaluation artifact, not a general bug). PR1 landed the foundation (branch feat/assessor-taxonomy): retest.py now holds an extensible technique registry (kind → assessor + command renderer + executor) seeded with two new conservative HTTP assessors — access-control (IDOR/BOLA, missing-auth, admin) and sensitive-file-exposure (traversal, backup files) — plus curl / browser-step rendering (render_command); FR-04 (plan.py) tags each planned probe with a technique kind (a lenient model hint normalized by classify_probe_kind, with a finding-text fallback), replacing the hardcoded planned-http. Real-report retests now yield conclusive advisory verdicts instead of no_assessor/inconclusive — the lever the FR-15 evaluation needs. make demo-techniques shows classification + rendering + verdicts offline; 270 unit+integration tests @ 98% coverage, all gates green. Remaining epic: ADR-0020 (LLM retest summary + human adjudication loop), ADR-0021 (user-configurable model/provider, default qwen3.6:27b), ADR-0022 (user-validated scope). Whether these become new SRS requirements (FR-16+) or enhancements is Álvaro's scope call.

2026-07-15 (M5) — manual report entry (ADR-0020, proposed): end-to-end testing on a second real report (61-page Juice Shop web-app assessment, 12 findings) exposed that FR-01's heading segmenter is format-bound — it recognised none of that report's 5.N finding headings and lumped the whole document into one 94k-char candidate, which a small local model (qwen3.6:27b) cannot ingest (context overflow + minutes-long stalls). Álvaro's direction: do not add more deterministic segmentation (the model should consume whole documents — a future large-context change); instead give the human a direct-entry escape hatch. PR (branch feat/manual-report, stacked on feat/assessor-taxonomy) adds POST /api/reports/manual — bypasses the LLM, reuses the FR-02 DefectDojo mapping, lands a ready report with attached findings — plus a SPA "Create report manually" view (/new: a structured form with add/remove finding rows, and a JSON paste/upload mode). Backend 253 unit @ 98% + 4 endpoint tests; frontend 44 tests (form + JSON + validation), all gates green (eslint/tsc/build/vitest). The 12 findings of the assessment report were loaded through this path to validate the FR-04→FR-09 flow and the new advisory verdicts end-to-end. 2026-07-15 (M5)ADR-0021 shipped: the FR-13 LLM backend selection gained a DB-persisted, runtime-editable model/provider settingsrc/revalid/settings.py (load_or_seed/save) makes a single settings row authoritative, seeded once from REVALID_LLM_MODEL/OLLAMA_BASE_URL on a fresh DB (env no longer overrides an already-configured setting); llm.build_model constructs the concrete Pydantic AI model (explicit OpenAIProvider for Ollama/OpenAI-compatible hosts, native provider + stored key otherwise) and is wired into the agent DI factories, so a saved change takes effect on the next agent build with no restart. The shipped default is now local-first: ollama:qwen3.6:27b at http://localhost:11434/v1 (was anthropic:claude-sonnet-5). GET/PUT /api/settings (write-only, masked API key) and POST /api/settings/probe (model discovery + connection test, deliberately bypassing the FR-06 allowlist since the LLM host is infrastructure, not a pentest target) back a new SPA /settings view (Sidebar gear link: model dropdown, base URL, write-only key field, Test-connection button, Save). Scoped as an FR-13 enhancement (new acceptance criterion), not a new SRS requirement. make demo-settings shows the whole lifecycle offline (seed → change → build_model picks it up).

2026-07-16 (M5) — async plan generation (ADR-0022, proposed): live UI testing surfaced that FR-04 plan generation was synchronousPOST /api/findings/{id}/plan ran the LLM inline, so the "generating" state lived only in React and was lost on reload (the finding showed no plan and the button was clickable again, inviting a double-generate), and it behaved differently from report ingest which was already async. Fixed by mirroring the report pattern: PlanStatus gains generating/failed and PlanRecord an error column; approval.start_plan_generation reserves a persisted generating version (superseding any live proposal/generation) and finish_plan_generation settles it in place to proposed/failed (no-op once superseded, so a stale result can't resurrect a version); app.run_plan_generation is the background twin of run_extraction and POST /plan now returns 202. The SPA polls /plans (the useReport pattern in usePlans), and FindingDetail renders the new states via a currentPlan selector (generating spinner / editor / failed-with-retry). Also fixed in the same PR: the PipelineTrack live-dot now marks the next actionable stage (pipelineReach splits furthest for the fill from current for the pulse), so on the generate-plan screen the plan node pulses, not extract. Backend 307 unit+integration @ ≥98% and frontend 54 tests green; C4 UI-flow diagram updated. Numbering note: the earlier roadmap sketch pencilled ADR-0022 in for "user-validated scope" — that scope decision, if pursued, takes a later number; 0022 is this async-generation change.

2026-07-16 (M5) — plan iteration (ADR-0023, proposed; issues #73/#74): the retest-plan loop gained "go back a step, at any point, with history kept" plus per-generation guidance. FR-04 operator instructions: POST /plan takes an optional {instructions} body woven into the generation prompt and recorded in the plan's lineage (raw.instructions); it steers proposals but is gated identically by FR-06 (no scope widening). FR-05 regenerate: start_plan_generation now supersedes any live version — proposed/generating and approved — so regenerating discards the current plan at any point; approved_plan() returns None until re-approval (gate stays honest). FR-05 revise: POST /plan/revise un-approves an approved plan into an editable proposed copy of its probes. No verdicts/versions are ever deleted (FR-10 intact). SPA: a guidance box wherever a plan can be (re)generated, plus Regenerate + Revise buttons and an applied-guidance readout. Route registrar split (_register_plan_routes + _register_retest_routes) to stay under the mccabe gate. Backend 311 unit+integration + frontend 58 green; verified live on Ollama. Also this session (infra, #75/#76): an issue-first Kanban guardrail — a PreToolUse hook (.claude/hooks/remind-board-issue.sh) + an explicit CLAUDE.md rule so every change traces to a board card (Closes #N).

2026-07-16 (M5) — clickable pipeline stages (#78, ADR-0023 follow-up): the finding-detail status track (extract → plan → approve → retest → verdict) is now an interactive stepper — a reached, earlier circle is a focusable button that steps back to it: extract → open the report, plan → discard & regenerate (confirm), approve → un-approve/revise (confirm). retest/verdict and not-yet-reached stages stay inert. Frontend-only (PipelineTrack gains onStageBack, FindingDetail wires the confirm-gated handlers over the existing ADR-0023 mutations); 60 frontend tests green, PipelineTrack stays 100%-pinned.

2026-07-16 (M5) — finding stage wizard + versioned finding edits & notes (FR-16, ADR-0024, accepted; #80): the finding-detail view became a five-page stage wizard/findings/:id/{extract|plan|approve|retest|verdict} as sub-routes under a shared layout (FindingLayout) that pins the identity header + PipelineTrack stepper. The pipeline circles are now plain navigation <Link>s (click = go there, never mutate, no window.confirm); the destructive moves (discard & regenerate, un-approve/revise) are explicit buttons on the Plan/Approve pages. This supersedes ADR-0023's confirm-on-click affordance (the rest of 0023 — regenerate/revise/instructions — stands). Plus FR-16: findings are now versioned like plansFindingRecord split into a stable identity + an append-only finding_versions table (extraction = v1, each operator edit an immutable new version; plans/verdicts keep referencing the identity, so no FK churn), owned by a new findings.py service; POST /api/findings/{id} records an edit → new version, GET …/versions lists the history. A per-finding, stage-tagged, append-only notes log (finding_notes; POST/GET …/notes) is the operator's reasoning trail. FR-12 export carries version history + notes (SCHEMA_VERSION 1.0 → 1.1; published schema regenerated + drift-tested). Backend 326 unit+integration @ 97.66% (new findings.py/db.py/domain.py/export.py 100%); frontend 76 tests, coverage 70.8% (PipelineTrack stays 100%-pinned); mypy --strict/ruff/eslint/tsc/vite build all green. Verified live on a fresh DB: manual-report seed → edit → v2 (severity corrected, reason recorded) → versions history → note post/list over the real /api, and both stage deep-links serve the SPA shell. FR-16 added to the SRS (docs/requirements/srs.md); C4 container + M3 UI-flow note updated.

2026-07-16 (M5) — FR-16 merged & closed out (#82, #84): the stage wizard landed on main (PR #82, squash 5655961; #80 closed) after a rebase dropped a duplicate of the already-merged #79 commit. A follow-up (#83 → PR #84) made deep-links to a not-yet-reached stage redirect to the current stageFindingLayout sends any requested stage beyond reach.current to the current stage (<Navigate replace>), instead of stranding the operator on an active-but-dim circle; 6 FindingLayout tests, frontend gates green. All five populated wizard stages were visually verified end-to-end on a live ollama:qwen3.6:27b + Juice Shop lab (extract editor + v2→v1 revision history + stage-tagged notes → plan → approve → retest → verdict), dark + light, zero console errors. ADR-0024 is now accepted (ratified 2026-07-16) and the four FR-16 acceptance criteria are ticked in the SRS (immutable versions, stable-identity edits, stage-tagged append-only notes, FR-12 export SCHEMA_VERSION 1.0→1.1). Álvaro's design calls this session: notes stay full-thread-on-Extract / stage-scoped elsewhere; the deep-link redirect above. Observed: an LLM-proposed weak SQLi payload (' OR '1'='1' + real password) produced a false fixed on the live retest — a plan-quality artifact the human-adjudication loop (ADR-0020) is meant to catch, not a wizard bug.

Pending side items: the evaluation ground-truth report is sourced (external, credited in tests/data/README.md, kept local). Álvaro still owns per-finding expected verdicts for FR-15's ground truth (M5).

2026-07-16 (M6) — agentic retest console Slice 0 (FR-17, ADR-0025 proposed; #88): live use of the FR-04→FR-09 batch-plan model exposed a hard failure mode — a single weak LLM-proposed payload (the SQLi ' OR '1'='1' instead of ' OR 1=1--) spoils the whole batch and yields a false fixed, because one-shot planning can't observe a response and adapt. Álvaro's direction (design spec docs/superpowers/specs/2026-07-16-agentic-retest-console-design.md, epic #87): let the LLM roam and self-correct — reason → run a command → observe → decide the next — with the human approving every command before it runs. Slice 0 (the walking skeleton) is done: src/revalid/sandbox.py — a Sandbox protocol + FakeSandbox (scripted, no Docker, for unit/integration tests) + a real DockerSandbox that provisions a per-session Docker --internal network with only the allowlisted lab container attached (FR-06 allowlist becomes network membership), gated behind an optional sandbox extra (docker>=7.0, mirrors the browser/Playwright precedent). src/revalid/retest_agent.py — a Pydantic AI agent with one gated tool run_command (requires_approval=True) and output_type=[ConcludeOutput, DeferredToolRequests]: a step either proposes a command (run pauses, returns DeferredToolRequests) or concludes with a ConcludeOutput verdict — no second "conclude" tool. src/revalid/retest_session.py — the orchestrator: a session state machine (starting → thinking → awaiting_command → running_command → (thinking | concluded | given_up | ended | error)), an append-only session_events transcript table (every proposed/approved/rejected command, its output, state changes, and the verdict, ordered by a monotonic seq), an in-memory SessionRegistry for live agent state (message history, sandbox handle, budget counters — ephemeral by design, a process restart abandons in-flight sessions but the transcript survives), and a step-budget backstop that force-concludes inconclusive ("budget exhausted") if the agent never stops proposing commands. app.py — REST endpoints (POST /findings/{id}/retest-session, GET /retest-sessions/{id}, .../commands/{cid}/approve|reject, .../end) wired to background workers, plus WS /retest-sessions/{id}/stream (a ~250 ms DB-poll tail of session_events — no in-process broker; the wire interface is what Slice 1's true char-streaming PTY will reuse). Frontend RetestSession.tsx — a read-only xterm-styled terminal rendering the transcript, an approval card (approve/reject the one pending command), and a verdict banner; command editing before approval is deferred (Slice 0 is approve/reject only). The egress lock was proven live: tests/system/test_retest_session_system.py starts a real DockerSandbox and asserts it can reach the lab container but gets a failed curl against example.com — confirmed against the actual Docker daemon + lab, nightly in system-tests.yml. The old FR-04/05/07-09 batch-plan path stays fully operational — no big-bang removal; both paths coexist until Slice 5. ADR-0025 records the decision (proposed) including the explicit NFR-02 reproducibility shift: an agentic verdict is a human-adjudicated judgment, not a pure function of one request's evidence, so reproducibility now means a replayable append-only transcript, not deterministic recomputation — a stronger human-in-the-loop contribution, a weaker deterministic-reproducibility claim, stated plainly rather than papered over. FR-17 (umbrella) added to the SRS with Slice 0's four acceptance criteria ticked. Six-slice build order (epic #87): Slice 0 done (this entry); Slice 1 — shared interactive PTY (human types into the same shell, agent observes); Slice 2 — plan panel (gated plan updates); Slice 3 — chat steering/Q&A; Slice 4 — free-launch mode + session controls + budget/give-up UI; Slice 5 — verdict adjudication + FR-09/FR-10/FR-12 integration, retiring the old batch path.

2026-07-16 (M6) — Slice 0 landed + slice plan reoriented to chat-centric (FR-17): Slice 0 shipped (PR #89 squash-merged to main) after two fixes this session — a CodeQL incomplete-url-substring-sanitization false positive on a test assertion ("example.com" in cmd) rewritten as an exact command-contract check, and a repair of scripts/demo/retest_session.py (broken since 29c18f3 added the required command_id kwarg to apply_decision). make demo-retest-session verified end-to-end: propose → approve → output → verdict, correct append-only transcript. Design change: Álvaro's call — the console should read as a chat with the model, not a terminal with an approval card beneath it. Inserted a new Slice 1 — chat-centric console shell (chat center: agent rationale → gated command card with inline approve/reject → verdict; terminal docked to the bottom, collapsible, read-only executed-output only), shifting the capability slices down one and reframing the former "chat steering" as the chat input slice (now Slice 4). Presentation-only, no API/orchestrator/sandbox change. Recorded in the design spec §2/§3, an ADR-0025 update note, this roadmap, and epic #87; Slice 1 issue #90 (branch feat/fr17-console-shell-slice1). Seven slices now (0–6); the old batch path retires in Slice 6.

2026-07-16 (M6) — Slice 1 merged + Slice 2 built (FR-17): Slice 1 (chat-centric console shell) merged to main (PR #91). Slice 2 then reframed after a design research pass: the original "shared interactive PTY" vision was dropped in favour of what Claude Code does — discrete execs — plus a Claude-Code-style ! command. (Research: pure AI shell agents use discrete subprocess capture; VS Code/iTerm2/Warp reconcile a real PTY with per-command results via OSC 133 shell integration — a bigger build than this tool needs. ADR-0026 proposed, records the decision + alternatives.) Built (issue #92, branch feat/fr17-shared-pty-slice2): POST /api/retest-sessions/{id}/human-command runs a command ungated through the same sandbox.exec the agent uses, records a new HUMAN_COMMAND transcript event, and the agent observes it on its next turn (buffered operator activity drained into the next run_command result on approve, or the tool denial on reject — no mid-deferred-run message surgery). SPA: a !<command> console line under the docked terminal (operator$ marked apart from the agent's $); plain text reserved for chat steering (later slice). Backend 367 unit+integration @ 99%, frontend 104 vitest; mypy/ruff/xenon/eslint/tsc/build all green. Agent commands stay gated; egress lock + single-user model (ADR-0008) untouched.

2026-07-16 (M6) — Slice 2 merged + Slice 3 built (FR-17): Slice 2 (operator ! commands) merged to main (PR #93). Slice 3 then added the guiding plan (issue #94, branch feat/fr17-plan-panel-slice3, ADR-0027 proposed): the agent proposes & maintains a short ordered plan via a second gated tool set_plan, so "every plan change is human-approved" reuses the Slice 0 deferred-tool gate (same approve/reject endpoint — no new REST surface). The orchestrator branches on the proposed tool (command_proposed/awaiting_command vs plan_proposed/awaiting_plan); pending_kind tags the approval so the right events are recorded and only command approvals consume the step budget (plan approvals resume in a transient thinking state). New states thinking/awaiting_plan, events plan_proposed/approved/rejected/updated; the current plan is derived from the latest plan_updated (transcript is the source of truth → survives reload). SPA: a Plan panel at the top of the console + plan proposals as chat approval cards. Backend 371 unit+integration @ 99%, frontend 107 vitest; mypy/ruff/xenon/eslint/tsc/build green; make demo-retest-session shows plan → approve → !whoami → command → verdict.

2026-07-16 (M6) — Slice 4 built (FR-17): chat input / steering & Q&A (issue #96, branch feat/fr17-chat-steering-slice4, ADR-0028 proposed). The operator types free-text into the console to steer the agent or ask it questions; messages queue and are delivered as a first-class user_prompt on the next approve/reject (pure-queue, forced by the deferred-tool gate — the agent is never idle), never interrupting a run or discarding a pending proposal. A non-gated respond tool lets the agent answer in prose (reusing the already-reserved agent_message event), budget-exempt. !-command results stay folded into the tool result (observed fact); chat is the operator's voice (a user turn). New: SessionEventKind.HUMAN_MESSAGE, POST /api/retest-sessions/{id}/message, a human_messages buffer on LiveSession; SPA sends plain text as a message (Send) with a Claude-Code-style "queued" treatment. No new session state; gate + egress lock untouched. make demo-retest-session unaffected.

2026-07-17 (M6) — Slices 0–4 landed + Slice 5 built (FR-17): the runnability fix (PR #99) and Slice 4 chat steering (PR #97) both squash-merged to main. Slice 5 — free-launch + budgets then built (issue #100, branch feat/fr17-free-launch-slice5, ADR-0029 proposed). A free-launch switch auto-approves the agent's commands so it runs reason→run→observe without a click; plan changes stay gated, always. It reuses the Slice-0 gate — an iterative _drive_auto loop drives the same apply_decision chokepoint a click would (same compare-and-swap, same step-budget check, same transcript events), marking auto-approvals {"auto": true}; no forked execution path, no recursion (one _resume_with_decision per loop pass). The step budget (max_steps) is now configurable + shown as a live meter (steps used / max, derived from command_approved events); a free-launch-only wall-clock budget (max_seconds, checked at step boundaries via a shared _give_up exit) is added; given_up renders as a distinct amber banner. Both entry points: POST /api/findings/{id}/retest-session takes an optional {free_launch, max_steps, max_seconds} body (the headless entry a future FR-15 eval run uses), and a new POST /api/retest-sessions/{id}/free-launch live toggle auto-approves a pending command on enable (a free_launch_changed transcript event). retest_sessions gained free_launch/max_steps/max_seconds columns; new SessionEventKind.FREE_LAUNCH_CHANGED. Backend 392 unit+integration green (mypy --strict/ruff/xenon C), frontend 122 vitest + tsc/eslint/build green; the egress lock (NFR-03) + single-user model (ADR-0008) untouched. Slice 6 (verdict adjudication + FR-09/10/12 integration, retiring the old batch path) is the last slice.

2026-07-18 (M6) — Slice 6a built (FR-17): verdict adjudication + FR-09/FR-10/FR-12 integration (issue #102, branch feat/fr17-verdict-adjudication-slice6a, ADR-0030 proposed). The agentic console's verdict now reaches the three places a verdict matters — the verdicts table (FR-09), the audit (FR-10), and the export (FR-12) — via polymorphic storage: VerdictRecord gains a source (batch/agentic) discriminator, a nullable session_id FK, and a nullable evidence column, plus an agentic() constructor; the frozen domain Verdict/Evidence is untouched (to_domain() stays batch-only). record_verdict — the single conclude/give-up hook — auto-persists the agent's verdict (actor="agent"), so a headless free-launch run finally produces a measurable outcome. A human accepts or overrides it via POST /api/retest-sessions/{id}/adjudicate {status, rationale}, which appends a verdict_adjudicated event + a superseding operator record (actor="operator", higher id ⇒ latest-per-finding) — append-only, the agent's row never mutated (new SessionEventKind.VERDICT_ADJUDICATED). FR-10 rederive_run branches on source: batch rows re-derive from evidence as before, agentic rows are verified against their transcript (the verdict event for the agent's record, latest verdict_adjudicated for an operator record) — a denormalization-integrity check honest to ADR-0025's replayable-transcript reframing. FR-12 VerdictExport (and the API's VerdictOut) flatten to one shape carrying the verdict fields + source/session_id/optional evidence; SCHEMA_VERSION 1.1 → 1.2 (published schema regenerated + drift-tested); agentic verdicts are now queryable at GET /api/verdicts. SPA: an adjudication panel on a terminal session (Accept records the agent's call; Override picks a status + rationale). Backend 409 unit+integration @ 99% (new agentic branches 100%; db.py/audit.py/export.py/eval.py all 100%), frontend 126 vitest + tsc/eslint/build green; mypy --strict/ruff/xenon C all green. Two pre-existing FR-16 demo regressions fixed in passing (make demo-export/demo-eval builders predated FindingExport's required versions/notes and a removed FindingRecord.from_domain). Egress lock (NFR-03) + single-user model (ADR-0008) untouched; the old batch path still coexists — Slice 6b retires it.

2026-07-18 (M6) — Slice 6b reframed + 6b-i built (FR-17): a design session with Álvaro reshaped the last slice from "delete the batch path" into "reshape the retest flow around the agentic console." The key realisation: the agentic console is currently gated behind the batch wizard (its "Start agentic session" button only appears after a plan is generated + approved), and FR-04 plan-generation shouldn't be deleted but repurposed as the agent's user-owned goal. Slice 6 is now split three ways (6b-i/ii/iii, above); the Kali-tooling sandbox is its own infra issue (#105). 6b-i — flexible command-output evidence (issue #104, branch feat/fr17-agentic-evidence-slice6b-i, ADR-0031 proposed) is done: 6a made the agentic verdict evidence-free, but the agent runs arbitrary tooling (Kali-style) and, on conclude, has decisive proof — so a new frozen tool-agnostic AgenticEvidence (explanation + command + output + exit_code + elapsed_ms) is stored in the same polymorphic evidence JSON column (keyed by source), leaving the HTTP Evidence/batch shape untouched. record_verdict — the single conclude/give-up hook — builds it from the transcript's last command_output (the real captured output, not the model restating it; explanation reuses the verdict rationale; output truncated to the 16_384-char cap), so a headless run's verdict carries pinned proof and a no-command conclusion is explanation-only. Threaded through FR-09 (GET /api/verdicts) and FR-12 (VerdictExport/VerdictOut.evidenceEvidence | AgenticEvidence | None, SCHEMA_VERSION 1.2 → 1.3, schema regenerated + drift-tested); the SPA EvidenceView renders the agentic explanation + command + output. Backend 414 unit+integration @ 99%, frontend 126 vitest + tsc/eslint/build green; mypy --strict/ruff/xenon C all green. FR-10 audit unchanged (agentic verdicts re-derive from the transcript; the evidence is captured from it, so consistent by construction). 6b-ii (user-owned goal) and 6b-iii (retire batch execution + UI reshape) remain.

2026-07-19 (M6) — Slice 6b-ii-a built (FR-17): the guiding plan is being reversed into a user-owned goal (issue #107, branch feat/fr17-user-goal-slice6b-ii, ADR-0032 proposed, supersedes ADR-0027). Given the size, 6b-ii ships as two PRs: 6b-ii-a — remove set_plan (this PR, Part of #107) and 6b-ii-b — the user-owned goal (generic generate_goal seeded at start, POST …/goal + /regenerate with pure-queue injection, "Current goal" panel — closes #107). 6b-ii-a is a clean teardown: the agent's gated set_plan tool is gone, so the orchestrator's dual command/plan gate collapses to a single command path — LiveSession.pending_kind, the set_plan branch in _emit_proposal, the plan mapping in _decision_event_kind, the plan transient in _resume_with_decision, and the plan guard in _drive_auto are removed; the awaiting_plan status and plan_proposed/plan_approved/plan_rejected events are dropped (plan_updated kept — it becomes the goal's event in 6b-ii-b); the SPA plan-proposal card is removed (the panel stays, placeholder now "No goal set yet"). Backend 409 unit+integration green, frontend 125 vitest + tsc/eslint/build green; mypy --strict/ruff/xenon C all green; make demo-retest-session shows the command-only flow (propose → !whoami → approve → verdict). Command gating, free-launch, egress lock (NFR-03) untouched. 6b-ii-b (the goal itself) is next on the same branch.

2026-07-19 (M6) — Slice 6b-ii-b built (FR-17): the user-owned goal (issue #107, branch feat/fr17-user-goal-slice6b-ii-b, completes ADR-0032). FR-04 is repurposed (not deleted): a new tool-agnostic generate_goal(finding) → steps (plan.py) asks the LLM for a few generic, finding-agnostic verification steps (no vuln-class assumptions), separate from generate_plan (which still emits HTTP probes for the batch path until 6b-iii). run_first_step seeds the goal in its background task — emitting the initial plan_updated (the "Current goal" panel) and prepending it to the agent's prompt — best-effort (a generation failure degrades to an empty goal via contextlib.suppress, never blocking start). The user owns it: POST /api/retest-sessions/{id}/goal {steps} + /goal/regenerate append a fresh plan_updated and queue the change on LiveSession.pending_goal, delivered to the agent as a first-class user turn on its next approve/reject (_resume_prompt combines it with any queued chat messages) — pure-queue, never interrupting a run. SPA: the panel is relabelled "Current goal" with Edit (steps as text, one per line) + Regenerate (live sessions only). Backend 417 unit+integration @ 99%, frontend 128 vitest + tsc/eslint/build green; mypy/ruff/xenon C all green. Slice 6b-ii is complete (teardown + goal); 6b-iii (retire batch execution + finding-flow reshape) is the last slice. Kali sandbox: #105.

2026-07-19 (M6) — Slice 6b-iii-a built (FR-17): retire the batch execution path (backend) (issue #110, branch feat/fr17-retire-batch-slice6b-iii-a, ADR-0033 proposed). Álvaro's call — full deletion, clean end-state — the agentic console (ADR-0025→0032) now covers the whole lifecycle, so the batch path is pure redundancy. Deleted end-to-end: the modules approval.py/retest.py/sanity.py/browser.py (+ their demos/tests/Makefile targets), the batch plan/approve/retest REST surface (app.py keeps only GET /verdicts + GET /audit), the batch domain types (Probe/RetestPlan/PlanStatus/Verdict/Evidence), and PlanRecord. plan.py is stripped to the goal-only generate_goal. The polymorphic shapes collapse to one agentic shape: VerdictRecord loses its source discriminator + batch columns (only agentic() remains), the FR-10 audit re-derives solely from the transcript (_rederive_agentic), and the FR-12 export drops PlanExport/plansVerdictExport is agentic-only, SCHEMA_VERSION 1.3 → 1.4 (regenerated + drift-tested). FR-14 dropped; FR-06 unchanged — egress control was already the sandbox's Docker --internal network (ADR-0025), strictly stronger than the now-orphaned HTTP allowlist.py (kept this slice, cleanup follow-up); lab_base_url() moved to sandbox.py. SRS re-marks FR-04/05/07/08 superseded by FR-17 and FR-14 dropped. Backend 277 unit+integration green; mypy (CI, 58 files)/ruff/xenon C green; make demo-audit/demo-export/demo-eval/demo-retest-session reshaped to the agentic flow + green. The SPA still calls the removed batch endpoints (harmless 404s; client is mocked in tests) — 6b-iii-b reshapes it (Extract → Goal → Agentic retest → Verdict), the last remaining piece.

2026-07-19 (M6) — Slice 6b-iii-b built (FR-17): finding-flow reshape (issue #110, branch feat/fr17-finding-reshape-slice6b-iii-b, ADR-0033 proposed). The last FR-17 slice: the SPA finding flow collapses from the old five-stage batch wizard to four stages — extract → goal → retest → verdict — with no batch stage/hook/client-fn/Plan type left anywhere and no call to a removed endpoint. Backend (three additions): POST /api/findings/{id}/goal/draft generates a retest-goal draft for a finding with no session and no persistence (reuses generate_goal); StartSessionRequest gained initial_goal so POST /api/findings/{id}/retest-session seeds that exact draft verbatim instead of re-generating one (run_first_step only falls back to generation when no draft was supplied); GET /api/findings/{id}/retest-sessions lists a finding's sessions newest-first (RetestSessionSummary) for the new session-picker. Frontend: a new Goal stage generates the editable pre-start draft (useGoalDraft) — the operator edits it before anything runs — and Start retest launches a session seeded with it; PlanStage/ApproveStage and their hooks/components (usePlans, PlanActions, PlanHistory, InstructionsField) are deleted outright, and the Retest stage now hosts the agentic console directly (no more "start session" gate behind an approved plan). The console itself is relaid out: chat center, the goal panel moves to a right column and stays live-editable during a session, the terminal docks to the bottom — with live goal edit, the command gate, chat steering, and adjudication all intact, and an in-progress session surviving a page reload (session state, not local component state, drives resume). FindingLayout/PipelineTrack move to the four stages; pipelineReach is re-signatured to {sessionExists, hasVerdict} (was {planned, approved, retested}); VerdictCard/EvidenceView render agentic-only (the batch/source branch is gone, matching the SRS/export/audit collapse from 6b-iii-a). Two cleanups landed in the same branch: the orphaned FR-06 HTTP allowlist.py (+ its 200+-line test file) is deleted — egress control has been the sandbox's Docker --internal network since ADR-0025, strictly stronger, and allowlist.py had been dead weight since 6b-iii-a; and docs/architecture/c4.md is refreshed end-to-end (container diagram retitled M6, the walking-skeleton sequence diagram now shows the agentic gated-command loop instead of the retired HTTP-probe one). SRS FR-17 gains AC22 (met) and FR-11's description/AC reworded off "plan-approval workflow" to the agentic flow. Full gate green: backend 244 unit+integration (200 unit + 44 integration) at 90.51% coverage (mypy --strict 56 files / ruff check+format / xenon --max-absolute C all clean); make demo-retest-session/demo-export/demo-eval all green (demo-eval's printed "NFR-01: FAIL" is by design — it exercises all four grading buckets on synthetic data, exit code 0); frontend 111 vitest (20 files) + tsc --noEmit/eslint/vite build all green. FR-17 is now complete — Slice 6b (i/ii/iii-a/iii-b) closes out the six-slice umbrella (0, 1–5, 6a, 6b) — and M6 is functionally done. Release timing/tagging is Álvaro's call.

2026-07-19 (M6) — Agentic console redesign, Slice 7 built (FR-17 enhancement): a live-UI design session with Álvaro opened a post-completion UX program on the retest console, delivered in slices (each its own issue/PR). Slice 7 (issue #115, branch fr17-slice7-console-redesign) — the full-width cockpit + interaction cleanup: the retest stage now breaks out of the app's max-w-[64rem] reading cap (App gives /…/retest + /retest-sessions/:id the full column width), so the conversation is the center hero, the goal is a right rail, and the terminal docks full-width at the bottom with the composer between them. Command entry moved into the terminal — a shell-style operator$ prompt runs each command through the existing submitHumanCommand path (discrete exec, not a PTY) and the agent observes it on its next turn as its own (FR-17 Slice 2 format_observations, unchanged) — so the chat composer is now messages-only and the !command prefix is gone. New Restart control (ends this session, opens a fresh one seeded with the current goal, follows it). Goal-edit bug fixed: set_goal silently no-op'd when the session wasn't live in the in-memory registry (so an edit on a session that outlived a backend reload vanished, though the endpoint returned 202) — it now emits plan_updated for any non-terminal session (panel always reflects the edit) and only queues to a live agent when one is attached; a terminal session stays a no-op. Also fixed a pre-existing deep-link race: FindingLayout now waits for the sessions + verdicts queries before rendering the stage outlet, so opening /findings/:id/retest cold no longer bounces to the goal stage. Verified visually (both routes screenshotted against a seeded scratch session; terminal prompt visible with the finding-wizard chrome above). Backend 246 unit+integration @ 90.5% (mypy --strict/ruff/xenon C clean); frontend 113 vitest + tsc/eslint/build green. Remaining slices (design agreed, not yet built): pause-and-ask lifecycle — drop the wall-clock give-up, the agent no longer self-concludes inconclusive; budget-reached / "exhausted known options" pause the session (sandbox kept alive) and ask the operator (Keep going +N / manually conclude) instead of force-concluding — carries an ADR; budget in Settings + a No limit option; and one determination per finding in the home ledger (verdictCounts counts latest-per-finding, not every row).

2026-07-19 (M6) — Agentic console redesign, Slice 8 built (FR-17 enhancement): pause-and-ask lifecycle (issue #117, branch fr17-slice8-pause-and-ask, ADR-0034 proposed). The retest session no longer gives up: the give-up backstop (a step or wall-clock budget that force-concluded inconclusive, marked given_up, and tore down the sandbox) is replaced by a non-terminal needs_guidance pause that keeps the sandbox alive and asks the operator. Two triggers funnel through _pause_for_guidance: (1) the step budget is reached — the agent's next proposed command is held (paused, not refused) rather than surfacing an approve gate; (2) the agent concludes inconclusive, now reinterpreted as "exhausted my options — guide me" (the surface ConcludeOutput is unchanged, so the deferred-approval output-tool wiring and the test scripts' single-output-tool assumption are untouched — only the orchestrator's interpretation changes). The wall-clock budget is deleted (max_seconds column/param/clock/started_at, _time_budget_exhausted, plus _give_up/_mark_given_up/_step_budget_exhausted). Two operator exits: Keep going (POST …/continue {extra_steps?}) raises max_steps and resumes — a held command re-opens its gate (auto-runs in free-launch), an exhausted-options pause re-runs the agent folding in queued goal/chat guidance; Conclude (POST …/conclude {status, rationale}) writes the operator's verdict (actor="operator", the only path that records inconclusive) and tears down. record_verdict gains actor/reason_code; the FR-10 audit projects an operator conclusion from the verdict event (only an adjudication uses verdict_adjudicated). SPA: the given-up banner is replaced by a pause banner (reason + Keep going + Conclude form); needs_guidance is non-terminal so the composer and terminal stay usable to steer while paused. given_up is retired (kept in the enum + _TERMINAL for legacy rows). Backend 249 unit+integration @ 89.9% (mypy --strict/ruff/xenon C clean; make demo-retest-session green); frontend 117 vitest + tsc/eslint/build green. Remaining: budget in Settings + No limit, and one determination per finding in the home ledger.

2026-07-19 (M6) — Agentic console redesign, Slice 10 built (FR-17 enhancement): one determination per finding (issue #119, branch fr17-slice10-one-verdict-per-finding). The home overview's DeterminationMeter was tallying every VerdictRecord, so a finding re-tested across sessions or with an operator adjudication (verdicts are append-only for the FR-10 audit) counted several times and inflated the ledger. verdictCounts now counts the latest verdict per finding (highest id per finding_id); the ledger eyebrow reads "one per finding". Display/count only — the append-only history is untouched. Frontend 118 vitest + tsc/eslint/build green. Remaining: budget in Settings + No limit.

2026-07-19 (M6) — Agentic console redesign, Slice 9 built (FR-17 enhancement): configurable retest step budget + No-limit (issue #121, branch fr17-slice9-budget-in-settings). The step budget that pauses a retest for guidance (ADR-0034) is now a Settings default (default_max_steps), not hard-coded to 8, and can be set to No limit. max_steps is int | None end-to-end (Settings / session record / LiveSession / the budget check): None never pauses on the budget (it still pauses on the agent's exhausted-options hand-back); continue_session only raises a finite budget. New sessions inherit the Settings default unless the start body overrides max_steps. Both nullable columns (settings.default_max_steps, retest_sessions.max_steps) carry a None column-default so an explicit no-limit isn't clobbered to a value on insert — the 8 default lives on the domain model / create_session. SPA Settings: a step-budget number input + a No-limit toggle; the session budget meter reads "n steps · no limit" under no-limit. Backend 252 unit+integration @ 89.8% (mypy --strict/ruff/xenon C clean); frontend 120 vitest + tsc/eslint/build green. The console-redesign program is complete — cockpit (Slice 7), pause-and-ask lifecycle (Slice 8, ADR-0034), one-determination-per-finding (Slice 10), and configurable budget (Slice 9).

2026-07-19 (M6) — Retest step budget removed + cockpit redesign (FR-17 enhancement, ADR-0035) (issue #123, branch fr17-remove-budget-redesign-cockpit). Live testing showed the 0 / 8 steps meter reads as a broken progress bar and "steps" is dev jargon; Álvaro's call — remove the step budget entirely (reverses Slice 9). needs_guidance now has a single trigger: the agent handing back after exhausting its options (ADR-0034 trigger 1 removed). Deleted end-to-end: RetestSessionRecord.max_steps, Settings.default_max_steps, LiveSession.step_count/max_steps, the budget gate, and continue's extra_steps (continue just resumes; its dead "held command" branch is gone); frontend budget meter, Settings control, and max_steps/default_max_steps types. The latent thinking status is now emitted before every agent.run_sync, so the console shows a live "thinking…" indicator during the (slow — ~80 s measured on qwen3:14b) local-model turns that previously looked frozen ("preparing its first step…"). Cockpit redesign (same /api+WS contract): goal moves to a full-width panel below the stages bar; the conversation is a boxed, chat-like log with a bouncing-dots thinking indicator; "Regenerate goal" gets a loading state; dev labels are replaced with operator copy (Free-launchAuto-run, budget meter/AUTO chip/"egress-locked" removed). Also fixed out-of-band during diagnosis: a stale local revalid.db (no migrations) 500'd /api/settings and blocked new sessions — patched in place, data preserved. Backend 247 unit+integration @ 89.8% (mypy --strict/ruff/xenon C clean); frontend 115 vitest + tsc/eslint/build green; verified live end-to-end (session reaches a real command_proposed turn). lib/sessionBudget.ts renamed to lib/sessionDerivations.ts. Follow-up: register ADR-0035 in the codebase-memory graph.

2026-07-20 (M6) — Reports chat assistant (FR-18, ADR-0036 proposed) (issue #136, branch fr18-reports-chat, worktree-isolated from concurrent thesis edits). A new Chat tab in the left nav opens a read-only conversational assistant over the whole corpus — Álvaro's request: "a normal chat with an agent that has access to all the reports" so he can ask "how many reports do we have?", "how many findings relate to SQL injection?", etc. Design forks were put to Álvaro (AskUserQuestion): read-only query tools (not context-stuffing — exact counts, no small-local-model overflow), DB-persisted threads (survive reload), and a new FR-18 + ADR (his scope call). src/revalid/reports_chat.py: a Pydantic AI agent (build_reports_agent, reuses the FR-13 build_model backend) whose deps carry a DB Session and whose four read-only tools — get_corpus_overview (report/finding/verdict counts + breakdowns, verdicts latest-per-finding), search_findings (keyword/severity/report filter, exact total even when the row list caps at 50), list_all_reports, finding_detail — wrap plain session-taking query fns (unit-testable, no LLM); it mutates nothing and can start no retest. New chat_sessions/chat_messages tables (created by create_all, no migration — ADR-0002/0008); only prose turns are stored (the agent re-queries via tools each turn) and rebuilt into message_history per call. REST: POST/GET /api/chats, GET/DELETE /api/chats/{id}, and POST /api/chats/{id}/messages (runs the agent inline on the threadpool — the answer is what the caller awaits — returns the whole updated thread). SPA: left-nav Chat tab + Chat.tsx (thread rail + conversation, optimistic pending turn, bouncing-dots "thinking", example prompts, Enter-to-send). Backend 272 unit+integration (219 unit @ 85% incl. reports_chat.py 98% + 53 integration; mypy --strict 59 files/ruff/xenon C clean); frontend 121 vitest (Chat page + a pre-existing verdictCounts branch-coverage gap on this branch closed) + tsc/eslint/vite build + the test:coverage thresholds green. Follow-ups: register ADR-0035 and ADR-0036 in the codebase-memory graph (deferred — graph is indexed against the main tree); Álvaro ratifies ADR-0036.

2026-07-20 (M2 back-fill) — CVSS + MITRE ATT&CK enrichment (FR-19, ADR-0037 proposed) (issue #144, branch feat/fr19-cvss-mitre-ingest, PR #145). Ingestion now attaches a CVSS code (vector + base_score) and a MITRE ATT&CK technique mapping to every finding, realising the §2.1.3 "map findings onto standard reference frameworks" requirement. Values stated in the report are copied verbatim (inferred=false); when the report is silent the extraction model derives a best-estimate CVSS v3.1 vector/score + the most applicable ATT&CK technique IDs, each flagged inferred=true — a bounded, explicitly-recorded exception to the "never guess" rule, justified because the taxonomy is classificatory metadata that never feeds the verdict (ADR-0037). New frozen CvssCode/MitreMapping domain models; Finding + ExtractedFinding gain cvss/mitre; the extraction instructions copy-or-derive-with-provenance; finding_versions gains cvss/mitre JSON columns so the fields survive the FR-16 version round trip (not just the raw audit blob); FR-12 export SCHEMA_VERSION 1.4 → 1.5 (regenerated + drift-tested). Unit tests cover stated/inferred/absent + the round trip; mypy --strict/ruff/xenon C + coverage green. AC4 is a tracked follow-up (#144): surface the CVSS/ATT&CK values + inferred provenance in the /api finding payload and the SPA finding view, and tag the FR-15 eval ground truth with them. SRS gains FR-19; ADR-0037 + README row added.

2026-07-21 (M6) — Reports-chat streaming + console/overview UI polish (FR-18 enhancement, ADR-0038 proposed). Three threads from a live testing session with Álvaro, currently on the working tree (issues/PRs are Álvaro's landing call). (1) Streaming chat (ADR-0038, enhances FR-18/ADR-0036): the chat reply now streams token-by-token — a new async POST /api/chats/{id}/messages/stream emits Server-Sent Events (event: token per delta → event: done), consumed by a fetch+reader client (streamChatMessage) that grows the assistant bubble live and hands off to the persisted thread on completion. It had to be async: the sync run_stream_sync binds its anyio portal to the calling thread and dies inside StreamingResponse with a cancel-scope error, so stream_answer runs agent.run_stream in the request's own event loop; the blocking …/messages endpoint is kept as a fallback. Realises the evolution ADR-0036 anticipated ("latency is visible … can move to background+stream later"). Proven live on ollama:qwen3.5:9b (real per-token SSE) + a stable integration test; the streaming path is intentionally not unit-tested (the explicit event loop competes with the run_sync agent unit tests' portal — documented in ADR-0038). (2) Overview risk profile: a new "Risk profile" band on the home overview — a segmented severity bar + five readouts (Critical/High/Medium/Low/Informative) over all findings (severityCounts selector + SeverityMeter, both unit-tested; selectors.ts stays 100%-pinned). (3) Settings + width root-fix: Settings collapses from two stacked panels into one divided panel (LLM backend | Display) now that pages are wide; and the app-shell content width is made wide-by-default (max-w-[90rem], the agentic cockpit keeps max-w-[100rem]) — removing the per-route allowlist that had kept most pages at a cramped max-w-[64rem], so any future page is wide automatically (the recurring "new page is too narrow" bug, fixed at the root). Frontend 131 vitest + tsc/eslint/vite build green; backend mypy --strict/ruff/xenon C green; src/ coverage ≥ 80%. Docs synced this session: ADR-0038 + README row, SRS FR-18 AC5, C4 container (Chat + FR-19 + SSE), AI-usage log. Follow-ups: land the three threads as issue(s)/PR(s) (Álvaro's call); register ADR-0037/0038 in the codebase-memory graph once on main; FR-19 AC4 (#144).

2026-07-21 (M6) — Retest console: operator lifecycle controls + agent-chosen timeout (FR-17 enhancement, issue #150). From a live driving session: the operator gains full control of a retest's lifecycle, plus a real per-command time bound. On the working tree (Álvaro's landing call). (1) Conclude at any live point: the manual conclude (write your own verdict) was only reachable at a needs_guidance pause; it is now offered whenever the session is live. conclude_session already guarded only on terminality — the fix was the UI plus a mid-flight race guard: concluding while an agent step is in flight tears the sandbox down, making the in-flight run_command raise, so _fail now swallows that failure when the row is already terminal (never clobbers the operator's verdict with error). (2) Start / Stop / Resume / Restart: two new non-terminal states — idle (created but not started; Restart now opens the fresh session here so it never auto-runs, provisioning the sandbox only on Start) and stopped (a cooperative operator pause — a running command finishes and is recorded, then the session parks with its sandbox alive; Resume re-opens a held gate or re-runs the agent; the free-launch loop halts). New endpoints POST /retest-sessions/{id}/{start,stop,resume}; launch gains a deferred flag. Each button is shown only when it makes sense. (3) Agent-chosen per-command timeout: DockerSandbox.exec accepted a timeout but never applied it (exec_run has none), so a hanging/long command (an nmap sweep) could wedge a session forever. Now run_command takes a timeout_seconds the model sets to fit each command, clamped to a hard ceiling, enforced in-container by wrapping with timeout; on overrun the command is killed and the model observes it timed out and can retry narrower. Operator ! commands are bounded the same way (default = ceiling). Backend 237 unit + integration (new orchestrator/agent/API tests; mypy --strict/ruff/xenon C green; src/ coverage 82%); frontend 136 vitest (lifecycle buttons, conclude-anytime, timeout display; RetestSession.tsx 96%) + tsc/eslint/build green. Docs synced: ADR-0034 update (lifecycle) + ADR-0025 update (timeout), architecture/workflow.md (state machine + operator-actions table + sandbox timeout). Follow-ups: land as PR (Álvaro's call, Closes #150); egress lock + per-command gate unchanged — these are controls layered on top, not a relaxation of containment.

2026-07-21 (M6) — Retest console: wake-on-demand, one chat panel, icons + a 405 root-fix (FR-17 enhancement, issue #157). From a live driving session. On the working tree (Álvaro's landing call). (1) Method Not Allowed root-fixed: the reported 405 in the retest screen came from a server predating the #150 lifecycle routes, but the reason it read as 405 was a real trap — _mount_spa registers @app.get("/{full_path:path}"), which was the only route matching an unknown /api path, so any non-GET to a renamed/absent endpoint matched the path but not the method (POST /api/nonexistent{"detail":"Method Not Allowed"}). A new _register_api_fallback claims /api/{rest:path} for every method after the real routes: an unknown path now 404s with No such API endpoint: …, while a wrong method on a real path still 405s (re-derived by matching the path against the registered /api routes, since the fallback would otherwise swallow that signal too). Registration needs response_model=None — FastAPI cannot infer a response model from NoReturn and raises at startup. (2) The agent no longer starts unbidden: the Goal stage's Start retest becomes Open console and launches deferred, so every session — first launch and Restart alike — lands idle with its goal and scope recorded but no sandbox and no LLM call, and begins only on an in-thread Wake the agent button (the header's competing Start is gone). This exposed a latent bug from #150: the console derives status from the latest state_change event and falls back to starting when the transcript holds none, so an idle session rendered as "Working" with a thinking indicator and never offered its start action — the frontend tests mock the hook's status directly and could not catch it. _seed_deferred_session now records the idle state itself, keeping the transcript the source of truth for state (ADR-0025). (3) One chat panel: the conversation and its composer are welded into a single panel (the composer sits on the thread's bottom edge, chat-app style) and the Conclude form moved in-thread, so the console reads as one conversation instead of a stack of detached boxes. (4) Icons: a local components/icons.tsx set (hand-rolled inline SVG, following the Sidebar.tsx precedent — no new npm dependency, holding the offline posture) across the lifecycle controls, the gate, the goal panel and the terminal. Backend 306 unit+integration @ 96% (new test_api_fallback.py; mypy --strict/ruff/xenon C clean); frontend 138 vitest + tsc/eslint/build green. Verified live in a browser against ollama:qwen3.5:9b + the Juice Shop lab: a deferred session opens asleep, Wake provisions the sandbox and the agent proposes a real gated command, zero console errors.

2026-07-22 (M6 polish + backlog burn-down) — nine PRs closing the open board. A landing + fix session; every item below is merged to main. (1) Landing the working tree (#164/#167/#169–#172): a large uncommitted tree was split into per-issue PRs — the per-layer UML pipeline hardening (#160: a LAYERS coverage gate that fails naming the module, snippets: check_paths: true, three falsifiable claims corrected), the console lifecycle + wake-on-message + 405 root-fix (#150/#157/#163), FR-18 token-by-token SSE chat streaming (#168, ADR-0038 still proposed), the per-report severity meter + archived-report exclusion from the ledger (#161/#162), the thesis chapters (ch1–ch3, conclusions, annex), and the rebuilt README. (2) #156 flaky test root-fixed (#174) — and it was never only a test: the four FR-18 @agent.tool functions are sync and share one SQLAlchemy Session, Pydantic AI runs sync tools in worker threads and runs them concurrently when a turn emits several tool calls, and neither engine stops the overlap (in-memory StaticPool with check_same_thread=False; the pysqlite dialect disables the same guard for file DBs — verified). Reproduced at 21/40 runs before the fix. All four tool bodies now go through one locked _read seam; the flaky test passes 30/30. (3) #113 coherence (#175): FindingStage gains a real GOAL (PLAN/APPROVE kept legacy-readable), with an idempotent _backfill_note_stages rename on engine open so notes already written on the goal stage don't silently vanish; the goal text↔steps transform is deduped into lib/goal.ts; an empty goal box now means an empty goal instead of being coerced into a generated one. (4) #144 (#176): CVSS + ATT&CK surfaced on the finding view with a visible inferred badge and an em dash for absent values — the /api payload had carried both all along. (5) #105 (#177): the sandbox runs revalid-sandbox:1.0, a Kali toolbox built by make sandbox-image (nmap/sqlmap/nikto/hydra/…); baked in because the container is egress-locked, built not pulled, and a missing image fails naming the fix rather than falling back to a toolless one. Containment re-verified against the new image (DNS fails, nmap finds no route, lab reachable). (6) #140 (#178): the console streams the model's reasoning instead of showing a motionless spinner for the whole turn. Measured first: a live ollama:qwen3:14b turn emits 746 thinking deltas and zero text/tool-arg deltas, so reasoning is what streams; deltas are transient (no seq, never persisted, dropped when the turn lands) so the transcript stays the sole audit record. End-to-end: 36 WS frames, 3.5 KB, first at +3.7 s of a 14 s turn. Board: epic #87 closed as delivered (all nine slices merged); #144 rescoped and moved off M2. Open: #20 (FR-15 ground truth — Álvaro's design input, deliberately not authored by an agent: writing the answer key and scoring against it would make the Results chapter self-referential) and the CWE fork on #144.

2026-07-23 (M6) — Retest console: guided mode drives the operator, not the goal (FR-17 enhancement, ADR-0040 accepted, issues #201/#202, branch feat/fr17-guided-operator-driven). From Álvaro: the agent behaved like "a brainless goal-seeker" — in gated mode every approval bounced straight into the next proposal (an approval treadmill), and it self-concluded, so the operator could never just drive. His model: "go for it" hands over the wheel; anything else, the operator runs one thing and it stops. (1) Guided one-action-then-park (ADR-0040): with Auto-run OFF (the default) the agent now does exactly one action per operator turn, then parks in the existing non-terminal needs_guidance — after an approved command runs, its next output does not re-open the gate or terminate; a proposed next command is discarded and surfaced as an advisory suggestion in the hand-back (the trailing unresolved approval call is trimmed from the history so a later continue resumes cleanly), and a fixed/still_open conclusion is surfaced as a recommendationthe agent never records a terminal verdict while guided, only the operator concludes. Auto-run / free-launch is unchanged and is "go for it" (still an explicit toggle — no NL trigger, per ADR-0034's no-classifier stance). The agent's persona now branches on deps.free_launch via dynamic instructions (guided = do-one-thing-and-hand-back; autonomous = drive-to-a-verdict), read fresh each turn so a live toggle takes effect next turn. The per-command approve gate and the ! one-touch path are untouched. (2) Cockpit rebalance (#202): after the scrollable rework (#157/#163) the conversation panel was squeezed to nothing between a tall goal panel and the open terminal; the conversation + terminal now share the vertical space ~3:2 (the xterm fills its flex area), the goal panel is capped + scrollable, the over-reserved top space is reclaimed (embedded 20rem16rem), and the now-routine needs_guidance copy is lightened ("Over to you"). Presentational only. Backend 248 unit + 38 retest integration green (mypy --strict/ruff clean); frontend 158 vitest + tsc/eslint + coverage thresholds green. Note: several pre-existing integration tests hit the live Q&A model on a machine with Ollama up (_client() never stubbed get_qa_agent) — orthogonal test-hygiene debt, fail-fast in CI. Follow-ups: land as PR (Closes #201, #202); eyeball the cockpit layout live; register ADR-0040 in the codebase-memory graph once on main; optionally stub the Q&A agent in _client(). Rebase note (2026-07-24): landed by cherry-picking the guided-mode change onto main after ADR-0039 (operator control of in-flight LLM work, #207) merged first — so the guided ADR is renumbered 0040 and the (2) cockpit rebalance (#202) is dropped as superseded by main's later console layout (#204/#206). This PR is the guided-mode backend + tests + ADR-0040 only; the frontend already renders needs_guidance.

2026-07-24 (M6) — scope-based sandbox target + console cap (FR-17/FR-06). A live-testing + consolidation session (guided mode ADR-0040 / #210 also merged here — see above). (1) Conversation-panel cap (#209, PR #211, merged): the retest console conversation panel gained a max-h ceiling (48rem) so it stops growing on roomy viewports and the transcript scrolls inside instead — frontend-only, 158 vitest green. (2) Scope-based sandbox target (#208, ADR-0041 proposed): the retest sandbox is provisioned against the finding's scope host, not the hardcoded lab. revalid/scope.py parses each scope endpoint to its host (https://domain.com/#/logindomain.com; SPA hash routes, ports, sub-domains handled), threaded from the launch target_set through start_and_step into a new Sandbox.start(scope_hosts). Two provisioning modes (Álvaro chose the egress proxy over an IP-allowlist / open egress): lab scope (empty or the lab host) keeps the unchanged --internal + attached-lab-container path; an online host provisions a deny-all-by-default Squid allowlist proxy as the sandbox's only route out (session network stays --internal; the proxy allowlists only the scoped host(s)), fail-closed on any provisioning error — never open egress. Non-HTTP egress is unavailable online (fails closed), stated plainly in the ADR. FR-06 broadens from "network membership (lab)" to "network membership (lab) or a host-allowlisting egress proxy (online)". Backend unit+integration green (new test_scope.py; sandbox mode-decision + squid-config tests; an end-to-end threading test asserting the parsed scope reaches the sandbox; mypy --strict/ruff clean); the live DockerSandbox._start_online is # pragma: no cover, validated by a follow-up system test (Docker + the sandbox extra, like the egress lock).

2026-07-24 (M6) — one agent, one voice, five states over guided mode (FR-17 enhancement, ADR-0042, issue #217, branch feat/retest-onevoice-guided). Álvaro compared a from-scratch "Claude-Code" redesign (built earlier this session on a stale local branch that predated #207/#210/#211/#212/#215) against current main and chose to keep main's deliberate wins — guided mode (ADR-0040), free-launch, and online-target scope egress (ADR-0041) — while adopting the redesign's interaction-surface improvements, rebuilt cleanly on current main. (1) One voice: deleted the parallel read-only Q&A (build_qa_agent/answer_operator_question/_qa_context + the QaAgent DI wiring) — this also retires the "stub the Q&A agent in _client()" test-hygiene follow-up by removing the thing. A message to a working agent is queued and answered by the same agent at the next turn boundary. (2) Five states: collapsed to idle/working/awaiting_command/awaiting_operator/stopped (+ terminals); working replaces thinking/starting/the never-set running_command, and needs_guidance folds into awaiting_operator — the guided one-action report, the verdict recommendation, and the inconclusive hand-back are all surfaced as ordinary agent_messages now (_pause_for_guidance_await_operator; the guided _suggestion_reason/_recommendation_reason/after_command logic is unchanged). (3) Message-at-gate steering: a message while a command awaits approval withdraws it and re-runs the agent with the message (Claude Code's "type at the permission prompt"); the free-launch loop _drive_auto is generalised into _advance, which delivers queued messages at every turn boundary (pre-empting the gate) as well as auto-approving under free-launch. (4) Questions trigger a lookup: the instructions are retuned so a question the agent can't answer from context makes it work out which command reveals the answer and propose that (general, no hardcoded examples), replacing main's "say what you can't." Guided mode, free-launch, and scope egress are unchanged in behaviour; the conversation cap keeps main's max-h-[48rem] (#211). Backend 285 unit + retest-integration green (mypy --strict/ruff clean; src/ coverage ≥ 80%); frontend 158 vitest + tsc/eslint/build green. Docs: ADR-0042 (amends 0028/0034/0035/0040, extends 0039) + README rows (0041 back-filled, 0042 added); architecture/data-model.md + workflow.md state diagrams. Closes #217. Env note: the full integration suite is slow locally because non-retest chat tests hit the live Ollama; CI is unaffected.

2026-07-24 (M6) — documentation + thesis currency audit, and the authored UML models. A full sweep of the codebase against every authored document, then the software-engineering diagrams the design chapter was missing. (1) Drift corrected: guided-mode comments in retest_agent.py/retest_session.py cited ADR-0039 where the 2026-07-24 rebase had renumbered the decision to ADR-0040 (11 sites); the retired needs_guidance state still named in two app.py docstrings, docs/index.md, the README and SRS AC23; scope.py (ADR-0041) and deltas.py (#140) absent from the C4 component diagram, the workflow.md module map and both reference pages; the report-ingest state diagram missing cancelled (ADR-0039) and the note-stage enum still showing the pre-#113 plan tag; FR-18 SSE streaming described as "not yet on main" in workflow.md, subsystem-flows.md and the SRS although it shipped in #168 (and the "three transports" table listed two); the retired !command prefix in the README and SRS AC8; FR-06/NFR-03/README-safety not reflecting ADR-0041's scope-driven egress (lab network or allowlisting proxy). (2) SRS: FR-17's acceptance criteria had stopped at AC23 (Slice 8) — added AC24–AC29 covering ADR-0035 (budget removal), ADR-0039 (operator control of in-flight work), ADR-0040 (guided mode), ADR-0041 (scope-driven sandbox target) and ADR-0042 (one voice, five states), and annotated AC8/AC9/AC23 where the mechanism they named is gone. (3) New authored models: docs/architecture/class-model.md — four curated class diagrams (domain core, persistence seam + domain converters, the agentic session collaboration, the export document), each paired with the decision it encodes and positioned explicitly against the exhaustive generated pyreverse view; docs/requirements/use-cases.md — actors, sixteen use cases traced to FR/ADR, and UC-6 expanded as a scenario whose exception table is where the design's claims live; plus a responsibility-partitioned activity diagram in workflow.md. All six are thesis-fig-marked, and gen_thesis_figs.py gained the three new sources + a class mermaid config, so the memoir renders them from the documentation rather than from redrawn copies (14 figures now). The session-lifecycle diagram was relaid out (short edge labels + direction LR, transitions moved to a table) because it was illegible at print size. (4) Thesis: every chapter reviewed against the implementation — ADR count 38→42, CI/PR statistics refreshed (289 CI / 295 security runs over 121 merged PRs, median 38 s, p95 90 s), milestone status corrected (M6 feature-complete/tag pending, M5 open), a figure caption naming thinking/running_command/needs_guidance rewritten for the five real states, containment updated for ADR-0041 in ch3/ch4/annex, the annex's uv sync corrected to uv sync --extra sandbox, one markdown-in-LaTeX emphasis bug fixed; new §4.4 "The class model" plus the use-case and activity figures, and new prose on guided mode (ADR-0040) and the five-state collapse (ADR-0042), which the Design chapter had not covered. (5) Future work — LLM observability (Álvaro's request this session): the conclusions gain a second future-work direction tied to the limitation NFR-02 already admits — the trail records every command and verdict but not the model's side of each exchange (prompt, parameters, tokens, latency), so a run cannot be interrogated after the fact. Since Pydantic AI already emits OpenTelemetry spans per agent step, a self-hostable open-source platform (Langfuse, new ref.bib entry) ingests them with an instrumentation call rather than a redesign, and stays inside the localhost-only NFR-03 posture; stated with the boundary that it is a development instrument, not a second audit trail — the append-only transcript remains what a verdict is derived from. The same edit removed a stale claim in that section: verdict quality no longer rests on "the panel of assessors", which was deleted with the batch path (ADR-0033). Builds clean: 92 pages, zero undefined references, max overfull box 4.5 pt; mkdocs build --strict green; backend 285 unit + mypy --strict + ruff green. (6) Evaluation chapter recovered from an unmerged branch. Álvaro pointed out that an evaluation existed elsewhere: local branch feat/retest-console-controls (tip a0d3b98, 2026-07-23, never pushed to main) carried a complete thesis/chapters/ch5.tex — Evaluation, 310 lines with measured results — plus a restructured conclusions, a ch1 structure paragraph, three bib entries, and Design-chapter improvements main never received. Ported onto this branch (his call: evaluation + design fixes, keeping the chapter's own honest framing): ch5.tex, \input{chapters/ch5}, the three references, the branch's conclusions wholesale (its future-work restructure supersedes what this session had written — the Langfuse item was re-added as \subsection{Observing the model's own work}, now motivated by questions the evaluation had to leave open), the ch1 paragraph, and ch4's \section{From report to finding} (ingestion, O1/O2) + its more honest "these diagrams are authored, not code-extracted" qualification, all reconciled with this session's \section{The class model}. Deliberately not taken: the branch's ch3 (older than main's — would revert #191), Makefile, gen_thesis_figs.py, c4.md, data-model.md (all superseded). Downstream claims updated to match: the ch4 table's FR-15 row now points at Chapter 5 and NFR-01 reads partially met — safety constraint held (zero confidently wrong), accuracy 67% against the 70% target; the ch2 gap section and the abstract no longer call the evaluation future work. One inconsistency fixed on import: ch5 spoke of the retest's "effort budget", which ADR-0035 removed — reworded to the operator's own ~10-command cut-off, with a cross-reference to the lifecycle section that explains there is no system budget. The measured result: 12 findings, manual-entry ingest, pinned Juice Shop v17.1.1, local qwen3.6 27B — 8 correct / 4 inconclusive / 0 wrong, and a timing study (manual ≈60 min vs guided 52 min vs autonomous 29.7 min on the 9 non-browser findings) whose headline is that removing the approval gate bought no meaningful speed. Thesis now 104 pages, zero undefined references. Left to Álvaro (not changed): ratification of ADR-0038 and ADR-0041 (both merged, still proposed); the unticked SRS acceptance criteria for FR-01/02/03/06/09 that the record shows met; and whether feat/retest-console-controls should now be deleted or landed for its remaining history. 2026-07-24 (M6) — operator control of the loop: reply to questions + reopen verdicts (FR-17). From live testing. (1) Reply, don't pause (#213, PR #215): the agent only used AwaitOperator for "a simple question you can answer without running anything", so a question it couldn't fully answer (e.g. "what's our ip?") fell through to inconclusive → a "Paused — needs you" pause with a rambling non-answer. The base instructions now make any operator message that isn't a clear run-this instruction (a question, greeting, chat) get a short AwaitOperator reply — answer what you can, say what you can't, offer the reachable host(s), hand back with an open composer — and reserve inconclusive for a genuine retest dead-end. Prompt-only (main retest agent). (2) Reopen a concluded session (#214, ADR-0043 proposed): POST …/reopen withdraws a verdict the operator thinks was premature and returns the session to idle so they can wake it and keep testing. The verdict is kept in the transcript (VERDICT + a new VERDICT_CANCELLED event — the append-only audit, ADR-0025) but removed from the verdicts projection (a withdrawn verdict is not a current determination); reuses the idle→wake re-provision. A Reopen control sits on the console's verdict panel. Backend unit+integration green (reopen returns idle, withdraws the projection row, keeps the transcript events; no-op unless concluded); frontend 158 vitest + tsc/eslint/build green. (Also fixes a doc gap: the ADR-0041 row missing from docs/adr/README.md since #212 is added here.)

2026-07-25 (v1.0) — containerised deployment (ADR-0044 proposed, issue #220, branch feat/docker-deployment). Running revalid meant running a development checkout; for a deployable v1.0 it now comes up with one command. make deploy builds the Kali toolbox image, then docker compose up -d --build starts the app (a two-stage image: Node 22 builds the SPA, Python 3.12 installs the locked deps with --extra sandbox --no-dev) on 127.0.0.1:${REVALID_PORT:-8000} plus the pinned Juice Shop lab — the root compose includes lab/docker-compose.yml rather than restating the v17.1.1 pin, which is FR-15 ground truth. The load-bearing decision is how a containerised app reaches a Docker daemon, since the retest provisions its own per-session networks and sandbox containers: Docker-in-Docker (needs --privileged, and a nested daemon can't attach the host's lab container) and a socket proxy (would have to allowlist nearly the whole dangerous surface) were both rejected in favour of mounting the host socket and provisioning siblings — stated plainly in the ADR, the compose file and the README as root-equivalent access to the host, accepted only under the single-operator threat model (ADR-0008). Containment is unchanged and was re-verified inside the deployed stack, not assumed: from revalid-app a real DockerSandbox reached the lab container (HTTP 200) while example.com failed to resolve (curl exit 6). Also verified live: /api/health and the SPA on loopback, the host Ollama reachable through host.docker.internal (models listed), the Docker socket usable from inside, and the database surviving a down/up cycle on its named volume (the container's working directory is /data, so create_app's CWD-relative revalid.db persists with no new configuration surface). A build-time assertion fails the image if the SPA build did not land where the app resolves it, rather than serving a blank page. The LLM stays on the host — no weights in the stack. Closes #220.

2026-07-25 — v1.0.0 close-out: M5 and M6 closed, the board emptied. Álvaro's call: nothing ships as v1 with an issue or PR still open. (1) Everything merged. PR #219 (one voice, five states + the documentation/thesis sweep, Closes #217/#218), PR #216 (reopen a concluded session, Closes #214) and PR #221 (containerised deployment, Closes #220) are all on main; no issue and no PR is left open. #216 needed a rebase and an ADR renumber to 0043 — it had claimed 0042, which #219 landed first with far more references — plus two fixes the rebase exposed: a test asserting the removed STARTING state, and a Reopen control that had shipped with no frontend test at all. (2) A hang, not a failure. The WS transcript test waited on a needs_guidance event kind ADR-0042 had deleted, so it blocked forever rather than going red — it would have stalled the Integration tests job instead of reporting anything. It now keys off the state_change to awaiting_operator (5.5 s, was unbounded). A pymdown-extensions advisory (CVE-2026-61632) surfaced in the same run and was relocked to 11.0.1. (3) The decision record is complete: ADR-0038, 0041, 0043 and 0044 ratified — every ADR from 0001 to 0044 is now accepted or superseded, none proposed. (4) SRS honesty pass: the acceptance criteria the record showed met but left unticked are ticked with their evidence (FR-01/02/03/09/13, and FR-04/FR-07 marked met-as-shipped alongside their superseded note, matching how FR-05/FR-08 were already handled); FR-06's two criteria are reworded to the mechanism that actually enforces them now — sandbox topology, not an HTTP allowlist — and backed by the live check. FR-19 AC4 is marked half met, not ticked: the CVSS/ATT&CK surfacing landed in #176, but tagging the evaluation ground truth has not been done and is deliberately left to Álvaro, since the answer key must not be authored by an agent. (5) Thesis: §6.3.1 "Strengthening the evaluation and the verdicts" is removed now that the evaluation exists and is reported; its remediation↔revalidation paragraph (CodeMender) is promoted to its own future-work subsection rather than lost, and ch5's forward reference is repointed at its own threats-to-validity section. The manual baseline in the timing table is filled in as 12/12 — with the caveat, stated in the caption, that the manual pass is what established the ground truth, so it is the yardstick and not an independent score. The honest reading now in the text: on this backend the tool is neither faster nor more accurate than a careful operator; its edge is that it constructs the checks, keeps an append-only trail, and never issues a false clearance. (6) Gates at the tag: mypy --strict 77 files, ruff, xenon --max-absolute C, radon avg A (2.09), vulture clean, pylint duplicate-code 9.99, 287 unit + 75 integration, frontend 160 vitest + coverage thresholds + lint/tsc/build, mkdocs build --strict, all offline demos, thesis 104 pages / zero undefined references. Version bumped 0.4.0 → 1.0.0.

2026-07-25 (post-v1.0) — online retests never worked, and editing a finding wiped its taxonomy (issue #226). Two defects found while trying to retest https://www.hackthissite.org/. (1) The egress proxy never started (FR-17/FR-06, ADR-0041). DockerSandbox._start_online carried two independent, fatal bugs behind a # pragma: no cover: the Squid config was built as printf '%s' {config!r}, and since Python's repr escapes newlines to literal \n while printf '%s' doesn't interpret escapes, Squid got a one-line file and died; and the shell was passed as command, but the Squid images declare ENTRYPOINT ["entrypoint.sh"], so it arrived as arguments to Squid itself (FATAL: '-c': unrecognized option). Either alone made every online-scope target unreachable — exactly the reported symptom. Fixed by passing the config base64-encoded (no shell quoting rule can mangle it) and the shell as entrypoint. Validated live against the real host, not in principle: target HTTP 200 at / and at a deeper path, off-scope http://google.com refused by the proxy with 403 (proving it is alive and denying, not absent), off-scope HTTPS refused at CONNECT. Two unit regression tests pin the defects — one asserts the decoded config has real newlines, the other that the proxy launches via entrypoint — because the pragma is why this shipped broken with every gate green. (2) Editing a finding destroyed its CVSS/ATT&CK (FR-19, ADR-0037 update). FindingEditIn.to_finding() never carried cvss/mitre across, so they fell back to empty defaults and any edit — correcting a title — wiped what extraction had derived. Compounding it, only the extraction path ever sets them: FR-02 structured import and manual entry leave them empty with no backfill, so for a manually-entered corpus (how the FR-15 evaluation was ingested) the taxonomy was unreachable. The finding editor now owns it: optional cvss/mitre on the edit payload, with fields in the SPA form. Provenance stays server-derived and the client cannot assert it — no inferred in the request; omitted or unchanged keeps the current flag (so the form's round-trip can't launder a model guess into an author-stated fact), changed means author-stated. Backend 294 unit + 75 integration, frontend 163 vitest; mypy --strict/ruff/xenon C clean. Closes #226.

2026-07-25 (post-v1.0) — online egress rebuilt as a per-session L3 gateway (FR-17/FR-06, ADR-0045 supersedes ADR-0041, issue #228). From live testing: an online-scope retest of https://www.hackthissite.org/ couldn't reach the target, and the root cause ran deeper than a bug — the ADR-0041 Squid proxy is L7 (HTTP-only), so nmap and every raw-socket tool had no route out. Álvaro's call: move egress control to L3 and enforce it outside the sandbox. The mechanism: for an online scope the sandbox resolves the host to its IPv4 address(es) and runs inside the network namespace of a per-session gateway container (network_mode=container:…) that holds an iptables OUTPUT allowlist (default-drop; permit loopback, established, one DNS resolver on :53, and the scoped IPs) and NET_ADMIN; the sandbox keeps NET_RAW (SYN scans) but not NET_ADMIN, so every tool reaches the scoped host and nothing else and no command can change the rules — the capability lives in a container it can't reach. iptables -F from inside fails Operation not permitted, verified live. Image changes: the sandbox image gains iptables/iproute2 (the gateway reuses it) and strips file capabilities at build — Kali sets cap_net_admin on nmap and the kernel refuses to execve a file whose effective caps exceed the shrunk bounding set, so nmap failed until stripped (root+NET_RAW still scans). Lifecycle: every per-session resource is named by session id and torn down by name, so deleting a report reaps the sandbox of every session under it — including one orphaned by a backend restart (containers up, dropped from the registry) — closing a real leak. Lab scope unchanged. Validated live against hackthissite + example.com: curl 200 at any path, nmap open ports, off-scope refused, tamper denied, clean teardown; a new system test exercises the whole path nightly, and unit tests over a fake Docker client pin the firewall script / entrypoint / caps / network_mode / by-name teardown (the seams that hid the earlier proxy bugs). Design note: per-session (not a shared gateway) is the stronger choice — the netns model is inherently 1:1, and a shared router would leak scope across sessions and let sandboxes see each other unless extra per-IP rules are maintained (ADR-0045 alternatives). Backend 299 unit + 76 integration, sandbox.py 95%; docs synced (SRS FR-06, C4, workflow, use-cases, README, thesis ch3/ch4/annex). Closes #228.

2026-07-25 (post-v1.0) — the FR-19 ingest doors get a taxonomy (issues #233/#237/#239/#241). Enrichment reached only the PDF door, because it rides inside the extraction call and the FR-02 JSON / manual doors make no such call — so anything imported or typed by hand landed with an empty CVSS/ATT&CK and no way to derive one (the FR-15 evaluation corpus was entered exactly that way). Fixed in four steps. (1) Opt-in enrichment (#233 → PR #236): a stated code is copied verbatim; derivation happens only behind an explicit enrich=true. Álvaro's call — opt-in, not automatic, so the "never guess silently" line holds and an import stays deterministic by default. (2) Typed on entry (#237 → PR #238): CVSS vector, base score and ATT&CK techniques on the manual creation form and the JSON door, so transcribing a report that already states a vector no longer means creating the finding and then reopening it in the editor. mitre_techniques is documented as a revalid key (DefectDojo has no ATT&CK field), and a stated cwe is still never renamed into a technique id. (3) A silent no-op fixed (#241 → PR #242): found validating the above live on ollama:qwen3.5:9b — the same import ran twice gave enriched:1 then enriched:0 with nothing reported, because the model occasionally returned {} and the schema allowed it. A model that answers nothing now retries instead of reporting success. (4) Docs (#239 → PR #240): the claim that every finding "whichever door it came through" is enriched survived in workflow.md, index.md and the README, plus a factual error crediting findings.py for work it never did. Also in this window: CodeQL py/stack-trace-exposure on the FR-18 SSE stream (#231 → PR #232) — raw exception text was framed to the client; replaced with a fixed detail plus a real log, deliberately more than the suggested one-line autofix, which would have swapped an information leak for a silent failure. And the network-topology page (#230 → PR #234): containment is the project's central claim and had no diagram anywhere.

2026-07-25 (post-v1.0) — a handed-back console waits (FR-17, ADR-0046 amends ADR-0042, issue #243). From live use. awaiting_operator rendered a banner — "The agent handed back — your move / Reply below to keep it going … or conclude to record the verdict now" — after every guided action. Álvaro's objection: a hand-back is the operator thinking, and restating their two options each turn frames their own pace as a pending decision. In his words, "when handed to me it will just wait for me to be done". What changed: the banner is deleted (the agent's message is the hand-back); the status label becomes the factual "Waiting for you", not the imperative "Your move"; Conclude becomes a permanent toolbar control in every live state — it had been hidden in awaiting_operator so the banner could own "a single entry point", which meant the state gated the one control that ends the retest; and the agent proposes concluding only when it has reached a determination (_suggestion_reason loses its options menu, _recommendation_reason keeps one proposal). Removing the banner also exposed a latent ADR-0043 bug: reopen keeps the verdict event in the append-only transcript by design, but the console only looked for verdict, so a reopened session kept reporting a determination the operator had withdrawn — the hook now takes the later of verdict/verdict_cancelled. Backend 406 unit+integration, frontend 177 vitest, mypy --strict/ruff/xenon C clean; verified live in the browser by Álvaro against the running lab. Closes #243.

2026-07-25 — this version is CLOSED and thesis-ready (Álvaro's call). The software is done for this release: v1.0.0 is tagged, the board is empty, every ADR from 0001 to 0046 is accepted or superseded, and the console has been validated live by the author. Remaining work is thesis writing, not implementation. The documentation sweep that accompanies this decision (issue #245) brought the AI-usage log current — it had fallen 15 PRs behind, which matters because the §6 declaration is generated from it — refreshed this roadmap, and cleared retired-state drift from the authored docs. If implementation resumes, the honest starting points are the deferred items already recorded above, not a fresh survey.

2026-07-25 — the memoir is submission-ready; two decisions are the author's (issues #261/#266 → PR #267). The pre-deposit review's mechanical half landed earlier (#263); this closes the rest. Rubric and regulation: a new §2.1 states the literature-search strategy and admission criteria ("Explicar la estrategia de búsqueda de la información utilizada" is a graded learning outcome and appeared nowhere); a new §6.2.1 names the competencies covered, leading with the proposal's own IS5 and IS6 against the evidence for each and then the degree's instrumental, systemic, personal and UCLM transversal codes (§6.2 had argued generically and named none); both evaluation reports are attributed for the first time — the twelve-finding retest input traced from the PDF's own metadata to Hack & Fix, a consultancy sample deliverable for a fictitious client, and the extraction/hosted-backend input to Nozipho Mthimunye's public write-up, both now ref.bib entries and cited, which is what the Authorship Statement had been asserting without support; and all eight table captions moved above the tabular per the seminar. Correctness: the impossible 87.5% reworded to its real basis (seven of eight exploitable findings, no confident verdict on the deliberately ambiguous ninth); a new §5.2 reports O1, which ch5 promised and never measured — 8/8 well-formed findings (100%, 78 s) on local qwen3.5:9b against FR-03's ≥90%, with the honest split that the model half clears the bar while the deterministic segmenter recovered 8 of 9 findings on one report and nothing on the 61-page one, which is exactly why ADR-0020's manual door exists; NFR-02 marked partial in Table 4.1; statistics refreshed and dated (336 CI / 342 security runs over 143 merged PRs, median 43 s, p95 94 s); the §6 declaration's inverted claim about which sessions lack a model identity corrected against the log it is compiled from; class count 101 → 105. Three honesty items the review had not asked for: a determinism threat to validity (an agentic retest is not reproducible run to run — which is why the two nine-finding rows are separate runs, not re-slices, and a reader recomputing the subset from Table 5.1 gets eight where Table 5.2 says seven); a statement of exactly which artefacts of the twelve-finding run survive (input and outcomes; no FR-12 export, no committed answer key, so the study can be repeated but this run cannot be re-scored); and a paragraph in §1.3 recording that the proposal's "de forma autónoma" became a per-command human gate, with the evidence that the gate costs no time. Presentation: the use-case figure split in two and re-laid out, and per-figure type overrides added at the Mermaid sources, lifting the smallest printed labels from 33–47% of body-text size to 54–69%; the containment mechanism stated once instead of three times; filler verso pages made genuinely blank; prose em-dashes cut 354 → 101. Build verified: 34 bibliography entries, 34 cited, zero orphans either way, zero undefined references, zero overfull boxes, no float-too-large, and every float within four pages of its first mention (three had been drifting 20+ pages). Typography re-checked against docs/Plantillas TFG/PLANTILLA TFG_ENG/: 12 pt, a4paper, \onehalfspacing, geometry and every titleformat size are identical to the template; Carlito stands in for Calibri as before.

Álvaro's decisions on the two items #267 left him: 1. Table 5.1's Cmds total — settled (2026-07-25): 58 is the real figure, which is what the memoir prints and what the per-row data sums to; the old 48 was a transcription slip. No further change needed. 2. Page span — still open. 90 pages from chapter 1 to the bibliography, against §3's recommendation of 80 (appendices excluded). Every page added by #267 answers a graded criterion, so trimming to 80 means dropping something a rubric item asks for. His call.

2026-07-26 — evaluation review, then a real cross-model study (PRs #282/#283/#286/#288, all merged). Álvaro asked for a truth-pass over the memoir against the codebase, which turned into running the evaluation properly. (1) Framing corrected (#282/#283): the memoir led with what the study couldn't settle; it now reports the measured result and names the reasoning backend as the lever. The bigger fix was that two earlier edits had corrupted Table 5.2 — #267 swapped the non-browser exclusion set (dropping DOM-XSS for SSTI) so row 3 stopped recomputing, and #275 changed row 4's backend to claude-sonnet on recollection, inventing a confound. The contemporaneous record (a0d3b98 + this roadmap) and the table's own arithmetic (drop the three XSS → 7/9 and 1189 s subtracted from 3139 = 1950 s = 32.5 min, exact) both show the original was one backend, row 3 an exact re-slice; restored. Also: §6.2 "Relation to the degree" and §6.2.1 "Competencies covered" removed at Álvaro's request; the "departure from the proposal" caveat dropped (the system is autonomous — the agent runs the retest; the human holds the authority). (2) Diagram audit: all code-derived diagrams verified up to date against the implementation; rendering fixes to Fig 4.5 (subgraph titles overlapping nodes), 4.6 (too small) and 4.17 (session lifecycle decluttered, re-verified state-by-state); the ToC page-number box widened now that the memoir passed 100 pages. (3) The cross-model study (#286/#288), the chapter's new centrepiece (§5.5). The pipeline was re-run across four model tiers (qwen3.5:9b, qwen3:14b, qwen3.6:27b, hosted claude-sonnet) and both interaction modes on the nine non-browser findings of the assessment, all still-open in the unpatched lab, on this host (RTX 5080 16 GB / Ryzen 9800X3D / 32 GB, Ollama 0.32.1). Guided runs were operated by a human in the loop; autonomous runs drove themselves to a verdict; the frontier model was given the verbatim local-model system prompt and the same egress-locked sandbox, so only the model varies. The result:

backend          mode    correct  inconc  FALSE-FIXED  error
qwen3.5:9b       guided   8/9      1       0            0
qwen3:14b        guided   8/9      1       0            0
qwen3.6:27b*     guided   3/3      0       0            0
claude-sonnet    guided   8/9      1       0            0
qwen3.5:9b       auto     5/9      3       0(1 in run1) 1
qwen3:14b        auto     4/9      2       2            1
claude-sonnet    auto     8/9      0       1            0
(* 27B partial — 17 GB exceeds the 16 GB card → CPU offload → too slow to finish)

Headline: under the human gate no model — 9 B to frontier — ever issued a false "fixed"; with the gate removed every model did, including claude-sonnet, which cleared a live SSTI it could not reproduce over HTTP ("does not reproduce" → fixed). The dangerous false clearance is a property of unsupervised autonomy, not of a weak model, and the gate prevents it across the whole capability range. The cleanest single datapoint is the guided claude-sonnet SSTI: the model recommended "fixed" (confident) — its exact autonomous error — and the operator overruled it to inconclusive; same model, same finding, same evidence, false clearance alone vs safe hedge under the gate. Secondary: under the gate, capability shows up as operator effort, not a different verdict (9 B needs the operator to run every multi-step command — bad quoting, wrong endpoints; 27 B/frontier compose them). Ground truth for the twelve-finding assessment committed (tests/data/eval/ground_truth_assessment.json). Build clean throughout: 127 pages, 0 overfull, 0 undefined references. The FR-15 evaluation is now a cross-model, human-gated-vs-autonomous study, not a single run — the "larger evaluation" the conclusions promised.

2026-07-26 — ingestion redesigned: whole document to the LLM, no regex (ADR-0047 proposed, issue #293, branch feat/whole-doc-llm-ingestion). Álvaro's call: FR-01/FR-03 must not depend on regex — the model should ingest the entire PDF and return the findings, the "right long-term fix" ch5 had named. What changed: pdf.py now renders the whole report to Markdown with PyMuPDF4LLM (deterministic legacy mode — no ML layout, no OCR), replacing pdfplumber; the regex heading segmenter (segment_findings/FindingCandidate/_FINDING_HEADING) is deleted. extract.py sends report.text to the extraction agent in one call returning list[ExtractedFinding] — the schema gate is unchanged (invalid output flagged, not persisted), Pydantic AI stays the framework (no Instructor), lineage simplified to source/model/extracted, cancellation is interrupt-only (a Stop keeps no partial findings). The extraction agent sets an explicit max_tokens so the multi-finding response isn't truncated. The new boundary is the context window: a hosted backend (Claude, ~200k) reads a full report at once; a small local model handles only a short one — measured on ollama:qwen3.5:9b, the 4-finding synthetic fixture extracts 4/4 and a new deterministic one_finding_report.pdf fixture 1/1, while the 9- and 12-finding real reports overflow the local model's context (they need the hosted backend). Ollama's /v1 endpoint was verified to ignore num_ctx (Modelfile and per-request alike), so a large local context is the operator's OLLAMA_CONTEXT_LENGTH server setting, not something the app can request. License: PyMuPDF4LLM/PyMuPDF are AGPL-3.0, so the project was relicensed from Beerware to AGPL-3.0-or-later (LICENSE, pyproject.toml, README, thesis ch3; ratified 2026-07-26, #299) — the reversal of ADR-0007's rejection, taken under the single-user local threat model (ADR-0008), source already public. Supersedes ADR-0009, amends ADR-0007. Backend mypy --strict/ruff/xenon C clean, 392 unit+integration @ 95% (pdf.py 100%, extract.py 97%), a system test extracts the one-finding fixture on live Ollama. Docs synced (SRS FR-01/FR-03, C4, workflow, subsystem-flows, index, api, and thesis ch3/ch4/ch5/annex). Closes #293.

Milestones (= GitHub milestones; each closes with a release)

M1 — Walking skeleton · FR-02 #7, FR-06 #11, FR-07 #12, FR-09 #14

Thin end-to-end slice proving the architecture. Scope deliberately minimal:

Since retired (ADR-0033, 2026-07-19): the batch execution path this milestone built is deleted — src/revalid/allowlist.py, src/revalid/retest.py, the Probe/Verdict/Evidence domain types, POST /findings/{id}/retest and make demo-walking-skeleton no longer exist (GET /verdicts does). Target authorization is now the sandbox's Docker --internal network (FR-06). The bullets below record the milestone as it shipped in v0.1.0.

  • [x] Package layout per ADR-0002: FastAPI app factory, SQLite via SQLAlchemy, domain models as Pydantic schemas (Finding done; Probe/Verdict arrive with FR-07/FR-09)
  • [x] FR-02 (minimal): ingest a simple structured JSON findings file from tests/data/ (full DefectDojo mapping can wait)
  • [x] FR-06: allowlist config + executor-level enforcement (SSRF guard test from SRS) — src/revalid/allowlist.py, #11
  • [x] FR-07 (minimal): ONE hardcoded SQLi login-bypass probe against local Juice Shop through the FR-06 transport, capturing request/response/timing evidence — src/revalid/retest.py, #12
  • [x] FR-09 (minimal): still_open/fixed/inconclusive verdict linked to evidence with a machine-readable reason code, exposed at POST /findings/{id}/retest + GET /verdicts — #14
  • [x] lab/docker-compose.yml with Juice Shop (pinned v17.1.1) — fills in the retest-lab skill + system-tests CI job
  • [x] scripts/demo/walking_skeleton.py (make demo-walking-skeleton): one command, ingest→probe→verdict printed
  • [x] Done: demo + system test green end-to-end against the lab; released as v0.1.0 (2026-07-13).
  • No LLM and no frontend in M1 — deterministic slice first.

M2 — Report understanding · FR-01 #6, FR-03 #8, FR-13 #18

  • [x] PDF ingestion pipeline (FR-01) — src/revalid/pdf.py (pdfplumber, ADR-0007): text + best-effort finding candidates, fail-closed on non-PDF/corrupt/no-text. Validated on the real evaluation report (Nozipho Mthimunye's public Juice Shop write-up, credited + kept local per tests/data/README.md): 11 pages → 8 clean finding candidates, no manual preprocessing. FR-01 acceptance met.
  • [x] LLM finding extraction with Pydantic AI + Claude API, schema-validated, TestModel-based unit tests (FR-03) — src/revalid/extract.py (ADR-0009): per-candidate agent, list[ExtractedFinding] gate, invalid output flagged not persisted, lineage in raw (NFR-02); domain Finding gained impact/attack_vector. make demo-extract (Claude if ANTHROPIC_API_KEY, else offline stand-in). On the real evaluation report the pipeline extracted 8/8 well-formed findings (100%) on ollama:qwen3.5:9b; the reported ≥90% figure is produced by the FR-15 harness (M5).
  • [x] Model-agnostic config; Ollama fallback runs the same extraction suite (FR-13) — src/revalid/llm.py (ADR-0010): REVALID_LLM_MODEL selects any Pydantic AI backend with no code change (ollama:<model> + OLLAMA_BASE_URL for the local backend); env-only switch covered by unit/integration tests plus a live-Ollama system test that skips when no server is reachable. Since ADR-0021 the env vars only seed a fresh DB — the persisted settings row is authoritative and llm.build_model(cfg) constructs the model — and the shipped default is local-first (llm.DEFAULT_MODEL = "ollama:qwen3.5:9b", DEFAULT_BASE_URL = "http://localhost:11434/v1"), not Claude.
  • [x] FR-19 — CVSS + MITRE ATT&CK enrichment (#144, PR #145, ADR-0037 accepted; back-filled into M2 on 2026-07-20, after the v0.2.0 tag): ingestion attaches cvss (vector/base_score/inferred) and mitre (techniques/inferred) to every finding — stated values verbatim, absent ones model-derived and flagged inferred; persisted as first-class finding_versions columns; FR-12 SCHEMA_VERSION 1.4 → 1.5. AC4 (surface them in the /api finding payload + SPA, tag the FR-15 ground truth) is an open follow-up.
  • Done when: the real Juice Shop PDF report yields ≥90% well-formed findings (FR-03 criterion — met: 8/8 on a local model). Released as v0.2.0 on 2026-07-14 (ADRs 0007/0009/0010 accepted).

M3 — Plan & approve · FR-04 #9, FR-05 #10, FR-11 #16

Since retired (ADR-0033, 2026-07-19): FR-04/FR-05 were superseded by FR-17 — approval.py, generate_plan, the PlanRecord/RetestPlan/PlanStatus types, the batch plan/approve/retest endpoints and make demo-plan/demo-approval are deleted; plan.py survives stripped to the user-owned generate_goal. FR-11 (the SPA) stands, reshaped to extract → goal → retest → verdict. The bullets below record the milestone as it shipped in v0.3.0.

  • [x] Retest-plan generation: typed probe actions + expected indicators from reproduction steps (FR-04) — src/revalid/plan.py (ADR-0011): a Pydantic AI agent proposes list[PlannedAction] (typed HTTP fields only → no free-form commands; expected_indicator required); generate_plan then gates deterministically — resolves each target against the allowlisted base URL, drops anything the FR-06 TargetGuard rejects or any destructive method, maps survivors to domain Probes in a RetestPlan, records dropped actions. Reuses Probe (no parallel action type) and the FR-13 model switch. Unit + integration tests (typed-only, allowlist-drop, method-drop, schema gate); make demo-plan (offline stand-in or a live backend) shows an off-allowlist action being dropped. Verified live on ollama:qwen3.5:9b (5-action plan, all allowlisted).
  • [x] Server-side approval gate; plans versioned; nothing unapproved executes (FR-05) — src/revalid/approval.py (ADR-0012): versioned plans rows, a single execute_approved_plan chokepoint that refuses without an approved version (AC1), edit + regenerate versioning with edited actions re-gated through FR-06 (AC2/D4), executed version stamped on each verdict. make demo-approval shows retest refused → approve → retest; the retest endpoint now returns per-probe verdicts.
  • [x] React SPA: plan review/edit/approve + results dashboard with evidence drill-down (FR-11) — frontend/ (ADR-0013): Vite/React/TS/Tailwind SPA served by FastAPI at / (StaticFiles + client-routing catch-all), API moved under /api (APIRouter). New reports entity doubles as the ingest job; POST /api/reports runs FR-01→FR-03 in a background task and persists findings (nullable report_id FK); the SPA polls GET /api/reports/{id} until ready. Three drill-down views (overview → report → finding) drive the FR-05 plan workflow and show verdicts + evidence + plan history. Injectable get_extraction_agent keeps ingest unit/integration-tested with a Pydantic AI stand-in (coverage 96%). Frontend toolchain (eslint + tsc + vite build + vitest) enters CI as its own job; make build-ui/dev-ui/demo-ui. Verified end-to-end in a real browser on a live ollama:frob/qwen3.5-instruct backend: upload → 4 findings → plan (allowlisted through FR-06) → approve → retest → evidence-backed verdict, zero console errors.
  • Done when: full flow operable from the UI alone (FR-11 criterion) — met; ADR-0013 accepted (2026-07-14); SPA subsequently redesigned (dark/light theme + left-nav sidebar, PR #55 — presentational, ADR-0013 unaffected). Released as v0.3.0 (2026-07-14).

M4 — Trust & audit · FR-08 #13, FR-10 #15, FR-12 #17

Partly retired (ADR-0033, 2026-07-19): FR-08 was superseded by FR-17 — sanity.py, retest.assess_evidence and make demo-sanity are deleted. FR-10 (audit.py) and FR-12 (export.py) stand, collapsed to the single agentic shape: the audit re-derives from the session transcript (_rederive_agentic) and the export dropped plans. SCHEMA_VERSION is now 1.5 (1.4 at ADR-0033, 1.5 at ADR-0037's cvss/mitre).

  • [x] Execution sanity checker: plan-deviation blocking + ambiguity→inconclusive (endpoint-moved test case) (FR-08) — src/revalid/sanity.py (ADR-0014, superseded by ADR-0033): an independent verifier wired into execute_approved_plan via guarded_run. Fail-closed plan-deviation block (PlanDeviationError + log → API 409, AC1); conservative ambiguity downgrade (any fixed on 404/410 → endpoint_changed, on 3xx → ambiguous_response, AC2) — only ever downgrades, never manufactures confidence. make demo-sanity shows both offline.
  • [x] Full audit trail; verdict re-derivation routine (FR-10, NFR-02) — src/revalid/audit.py (ADR-0015, superseded by ADR-0033): a verdict is a pure function of its stored evidence, so rederive_run recomputes every verdict from the shared pure retest.assess_evidence + FR-08 review_verdict and diffs it against storage — reproduced from the trail alone, no re-execution. VerdictRecord gained created_at/actor; exposed at GET /api/audit; make demo-audit. NFR-02 met for verdicts (per-LLM-call prompt capture is a follow-up).
  • [x] Versioned JSON export with schema (FR-12) — src/revalid/export.py (ADR-0016, accepted): RunExport assembles a complete run (reports → findings → plans → verdicts+evidence + descriptive metrics) into one document, versioned by SCHEMA_VERSION. The JSON schema is generated from the model (export_schema() = RunExport.model_json_schema()), published to docs/reference/schemas/run-export.schema.json (make export-schema) and drift-tested so it can never lie about the document. Exposed read-only at GET /api/export + GET /api/export/schema; make demo-export builds a run offline and validates it against the published schema with jsonschema. Metrics are neutral facts (counts + evidence timing), never correctness — grading is FR-15's job. export.py 100% covered; FR-15 (M5) consumes this format.
  • Done when: re-derivation reproduces all verdicts from stored data; release v0.4.0. Met — M4 complete and released as v0.4.0 (2026-07-15). All three items landed (FR-08 ✅, FR-10 ✅, FR-12 ✅); the pre-release codebase-sanity audit returned GO (all mechanical gates green: xenon --max-absolute C / radon avg A / vulture / pylint 9.98; 180 unit @ 97% coverage + 22 integration; mypy --strict / ruff clean; sanity.py/audit.py/export.py all 100%), its one flagged gap (stale M4 C4 diagrams) fixed in #62, and ADRs 0014/0015/0016 accepted (ratified 2026-07-15).

M5 — Evaluation · FR-15 #20, (FR-14 #19 Could)

  • [x] Ground truth: deliberately vulnerable Juice Shop version pinned in lab; expected verdict per finding — Álvaro's design input (the harness matches it by finding title; template + schema shipped at tests/data/eval/ground_truth.example.json). Authoring aid shipped: make ground-truth-skeleton EXPORT=… OUT=… (scripts/make_ground_truth.py) emits a pre-keyed, fill-in-the-blanks skeleton from a real export, warns on colliding titles, and stays fail-closed (unfilled TODO sentinels won't load). Authored & committed (tests/data/eval/ground_truth.json, 2026-07-22): the 9 findings of the external THM Juice Shop report keyed to the pinned unpatched v17.1.1 lab. Each expected verdict is grounded in the unpatched-lab property (the vulnerability ships in the pinned image by construction) — deliberately independent of what the retest tool returned, so the eval is not circular. Eight exploitable findings → still_open; the ninth, "Scoreboard Access", is the report author's own flagged training feature ("not a real-world vulnerability") and is encoded as the NFR-01 hard-constraint case (ambiguous: true, expected inconclusive) — making the answer key harder, not a rigged scorecard.
  • [x] Evaluation harness: metrics table (correct/wrong/inconclusive, timing) from a run export (FR-15) — scaffold done (src/revalid/eval.py, ADR-0017, accepted): pure scorer over an FR-12 RunExport + a title-keyed ground truth; picks each finding's latest verdict and buckets it correct / inconclusive (safe hedge) / wrong (confidently wrong, ×2), surfacing unmatched findings on both sides. scripts/evaluate.py (make eval EXPORT=… GROUND_TRUTH=…) is the one-command entry (exit-code gated on NFR-01); make demo-eval proves all buckets offline. eval.py 100% covered. Ground truth now authored (above), so the harness has a real answer key to score against.
  • [x] NFR-01 measured: ≥70% correct verdicts, zero confidently-wrong on ambiguity — harness computes the pass (correct_pct ≥ 0.70 AND wrong_on_ambiguous == 0) + confidently_wrong/weighted_error. Accepted by Álvaro's decision on one Claude-API agentic run (see #20): the run returned still_open correctly for 7 of the 8 exploitable findings (≈ 87.5% correct, clearing the ≥70% bar) with no confident verdict on the ambiguous scoreboard case — so NFR-01's hard constraint held. Honest limitation for the Results chapter: this is a single accepted run, not a large-N study, and the run's FR-12 export was not preserved, so the number is Álvaro's recorded observation rather than an on-demand make eval figure; reproducing it needs a capable backend (ANTHROPIC_API_KEY) + the lab up. The harness + committed ground truth make any future run scorable with one command.
  • [~] If time allows: Playwright probes for one DOM-dependent finding (FR-14) — built, then dropped: FR-14 was dropped and src/revalid/browser.py, its system test and make demo-browser-xss deleted in FR-17 Slice 6b-iii-a (ADR-0033, 2026-07-19); DOM/JS verification is reachable inside the FR-17 sandbox instead. As shipped — src/revalid/browser.py (ADR-0018, superseded by ADR-0033): a browser-xss probe drives Juice Shop's DOM XSS in a real (headless) browser under the same FR-05 gate, FR-06 allowlist (checked pre-nav + on every in-page request), and FR-10 audit as HTTP probes — guarded_run is now executor-agnostic and both paths converge on the shared assess_evidence, so a browser verdict re-derives offline. Playwright is an optional browser extra (lazy-imported; HTTP-only paths never need it; missing → HTTP 501). Unit-tested with a canned runner (browser.py 100% of non-live lines); the live-lab acceptance is tests/system/test_browser_xss_system.py (provisioned in system-tests.yml). make demo-browser-xss shows the gated pipeline offline. Honest scope: the exemplar is DOM (browser-only-verifiable) XSS, not a persisted one — same probe kind/assessor would cover persisted with a different URL/flow.
  • Done when: Results-chapter numbers exist and are reproducible; release v1.0.0. Numbers now exist — the thesis Evaluation chapter (thesis/chapters/ch5.tex, recovered from feat/retest-console-controls on 2026-07-24) reports a measured 12-finding run on the pinned lab against a local qwen3.6 27B backend: 8 correct / 4 inconclusive / 0 confidently wrong, i.e. NFR-01's safety constraint met and its ≥70% accuracy bar missed at 67% entirely on safe hedges, plus a manual-vs-guided-vs-autonomous timing study. Reproducibility is partial: neither run's FR-12 export was preserved (verified 2026-07-25 — no export artefact exists in the repo or the working DB, which holds only the two reports re-loaded for the annex screenshots), so both stand as reported outcomes rather than artefacts that can be re-scored on demand. What survives of the 12-finding study is its input (the report and the manual-entry payload) and the per-finding results in Table 5.1; the committed ground truth (tests/data/eval/ground_truth.json) covers the 9-finding TryHackMe report only. Preserving each run's export is the cheap fix and was not done. Released as v1.0.0 (2026-07-25) — M5 is closed.

M6 — Agentic interactive retest · FR-17 #88 (Slice 0), epic #87

Reworks the retest execution model from one-shot structured plans into an interactive, sandboxed, human-in-the-loop agentic console (ADR-0025, accepted). Built walking-skeleton-first, one slice at a time; the old FR-04/05/07-09 batch path stayed operational until Slice 6b-iii deleted it (ADR-0033). - [x] Slice 0 — skeleton: egress-locked Docker sandbox (sandbox.py, --internal network, allowlisted lab container only) + a Pydantic AI agent with one gated run_command tool (deferred-tool approval) + ConcludeOutput verdict (retest_agent.py) + an orchestrator with a state machine and append-only session_events transcript (retest_session.py) + REST/WS surface (app.py) + a read-only SPA terminal, approval card, and verdict banner (RetestSession.tsx). Egress lock proven live by a nightly system test. FR-17 acceptance criteria AC1–AC4 met; ADR-0025 accepted. - [x] Slice 1 (#90) — chat-centric console shell: the chat becomes the center column (agent rationale → gated command card with inline approve/reject → verdict); the terminal docks to the bottom as a collapsible read-only output panel. Álvaro's post-Slice-0 design call — the console should read as a chat with the model, not a terminal with a card bolted on (ADR-0025 update note, 2026-07-16). Presentation-only; steering stays approve/reject (human chat input is the reframed Slice 4). - [x] Slice 2 (#92) — operator manual commands: !<command> runs a one-shot command in the session's sandbox (discrete exec, not a shared PTY — Álvaro's call after researching how Claude Code / VS Code / Warp handle the shell; ADR-0026 accepted), recorded as a human_command transcript event and observed by the agent on its next turn. The agent's commands stay gated; the human's are ungated (ADR-0008). - [x] Slice 3 (#94) — plan panel: the agent proposes & maintains a short guiding plan (ordered steps) via a gated set_plan tool (ADR-0027 — superseded by ADR-0032: the plan became the user-owned goal and set_plan was removed in Slice 6b-ii-a); every change is human-approved through the same gate as commands (reuses the approve/reject endpoint), budget-exempt; a Plan panel shows the current steps, plan proposals render as approval cards in the chat. - [x] Slice 4 (#96) — chat input / steering & Q&A: the human types messages into the center chat to redirect the agent or ask it about what it observed (the chat view itself now arrives in Slice 1). Messages queue and deliver as a first-class user_prompt on the next approve/reject (pure-queue, forced by the deferred-tool gate); a non-gated respond tool lets the agent answer in prose, budget-exempt. ADR-0028 accepted. - [x] Slice 5 (#100) — free-launch mode (agent auto-runs commands; plan changes stay gated) + session controls + a step/wall-clock budget UI (ADR-0029, accepted; both budgets are since gone — the wall-clock one in ADR-0034, the step budget in ADR-0035). Free-launch reuses the Slice-0 gate via an iterative _drive_auto loop (auto-approvals marked {"auto": true}); the step budget is configurable + visible, a free-launch-only wall-clock budget is added, and given_up renders distinctly. Both entry points: session-start body + a live toggle endpoint. - [x] Slice 6a (#102) — verdict adjudication + FR-09/FR-10/FR-12 integration (ADR-0030 accepted): the agentic verdict is wired into the verdicts table, the FR-10 audit, and the FR-12 export via polymorphic storageVerdictRecord gains a source (batch/agentic) discriminator, a nullable session_id FK, and a nullable evidence column; the frozen domain Verdict/Evidence is untouched. The agent's verdict auto-persists on conclude/give-up (actor="agent"); a human accepts or overrides it (POST …/adjudicate), appending a verdict_adjudicated event + a superseding operator record (append-only). FR-10 re-derives agentic rows from their transcript; FR-12 VerdictExport flattens (schema 1.1 → 1.2). - [x] Slice 6b — reshape the retest flow around the agentic console (design: docs/superpowers/specs/2026-07-18-agentic-retest-console-slice-6b-i-design.md). Split three ways: 6b-i (#104, done) — flexible tool-agnostic AgenticEvidence (the agent's explanation + the real last command's output) so an agentic verdict carries pinned proof, not just the transcript (ADR-0031); 6b-ii (#107, done) — repurpose FR-04 as the agent's user-owned goal (generate → editable "Current goal" → agent works to it; set_plan removed, ADR-0032); 6b-iii — retire the batch path + reshape the SPA, split in two: 6b-iii-a (#110, done) — the backend deletion (batch modules/endpoints/domain/PlanRecord; VerdictRecord/export/audit collapse to agentic-only, schema 1.4; FR-14 dropped; ADR-0033 accepted), 6b-iii-b (#110, done) — the SPA finding flow collapses to Extract → Goal → Agentic retest → Verdict. Slice 6b — and FR-17/M6 — is now complete; the Kali-tooling sandbox image is tracked separately (#105) and does not gate FR-17. Release timing is Álvaro's call. - [x] Slices 7–10 (#115/#117/#119/#121/#123) — post-completion cockpit program on top of a complete FR-17: full-width cockpit + in-terminal command entry (Slice 7, PR #116); pause-and-ask lifecycle replacing give-up with the non-terminal needs_guidance state (Slice 8, ADR-0034 accepted, PR #118); one determination per finding in the home ledger (Slice 10, PR #120); the step budget made configurable (Slice 9, PR #122) and then removed entirely (ADR-0035 accepted, PR #124) — max_steps/default_max_steps/max_seconds no longer exist and needs_guidance has a single trigger (the agent handing back). Launch-time scope + conversational agent replies followed (PR #126). - [x] FR-18 — reports chat assistant (#136, PR #146, ADR-0036 accepted): a read-only Pydantic AI agent over the corpus with typed DB query tools, persisted chat_sessions/chat_messages threads, and a Chat tab in the SPA (src/revalid/reports_chat.py, POST/GET /api/chats…). Token-by-token SSE streaming (ADR-0038, proposed) is built but not yet on main. - Done when: all slices land, the agentic console fully supersedes the FR-04/05/07-09 path, and FR-17's acceptance criteria are complete; ADR-0025 ratified accepted. — Met (2026-07-19: all slices merged, batch path deleted, ADR-0025 accepted). Released as v1.0.0 (2026-07-25) — M6 is closed.

Thesis track (parallel, thesis label)

Write chapters as their content becomes real, not at the end: Introduction & objectives (any time) → State of the art (during M2) → Design (after M3, from ADRs + C4 docs) → Implementation (during M4) → Evaluation/Results (M5) → Conclusions + AI declaration (generated by the ai-declaration skill, last). Run codebase-sanity before each release. §6 AI-usage compliance is Álvaro's direct responsibility (ADR-0005), not an agent's.