API reference
Generated from docstrings by mkdocstrings — edit the code, not this page. For the narrative of how these modules fit together, read How it works.
revalid — AI-driven revalidation of pentest findings.
Parses pentest reports, extracts findings and reproduction steps, then drives a human-gated agentic retest against a contained target to verify applied fixes.
Domain schemas shared across ingestion, goal-setting, and agentic retest (ADR-0002).
These Pydantic models are the internal representation every layer speaks.
AgenticEvidence and VerdictStatus join this module with the FR-17 retest.
AgenticEvidence
Bases: BaseModel
Flexible proof backing an agentic verdict (FR-17 Slice 6b) — tool-agnostic.
An agentic retest runs arbitrary tooling (not just HTTP probes), so its
evidence is the agent's explanation plus the decisive command's real output,
not a structured request/response. The orchestrator captures it on conclude
from the transcript's last command_output (real data, not the model
restating it); command/output are empty when the agent concluded
without running a command.
Attributes:
| Name | Type | Description |
|---|---|---|
explanation |
str
|
The agent's account of what proves the verdict (its rationale). |
command |
str
|
The decisive command the agent ran. |
output |
str
|
That command's captured stdout/stderr excerpt (truncated). |
exit_code |
int | None
|
The command's exit status, or |
elapsed_ms |
float
|
The command's wall-clock time in milliseconds. |
Source code in src/revalid/domain.py
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
CvssCode
Bases: BaseModel
CVSS severity code attached to a finding at ingestion (FR-19).
vector is the CVSS base vector string (e.g.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H); base_score is the
derived 0.0--10.0 base score. inferred records provenance: False
when the code was read from the report verbatim, True when the model
derived it because the report stated none. An empty vector with
inferred=False means the report had no CVSS code and none was derived.
Source code in src/revalid/domain.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | |
Finding
Bases: BaseModel
A single pentest finding in the internal model (FR-02/FR-03).
Attributes:
| Name | Type | Description |
|---|---|---|
title |
str
|
Short human-readable name of the finding. |
severity |
Severity
|
Normalized severity level. |
description |
str
|
Free-text description of the vulnerability. |
impact |
str
|
What an attacker gains / the business consequence (FR-03). |
attack_vector |
str
|
How the vulnerability is reached and exploited (FR-03). |
affected_endpoints |
tuple[str, ...]
|
URLs or endpoint identifiers the finding applies to. |
reproduction_steps |
tuple[str, ...]
|
Ordered steps to reproduce the issue. |
cvss |
CvssCode
|
CVSS severity code, read from the report or derived at ingestion
(FR-19). Provenance is on the |
mitre |
MitreMapping
|
MITRE ATT&CK technique mapping, read or derived at ingestion
(FR-19). Provenance is on the |
raw |
dict[str, Any]
|
Complete source payload as ingested, preserving fields the internal model does not map (FR-02 audit criterion). For LLM-extracted findings it also carries extraction lineage — model name and source text — for the audit trail (FR-10 / NFR-02). |
Source code in src/revalid/domain.py
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | |
FindingOrigin
Bases: StrEnum
How a finding version came to be (FR-16, ADR-0024).
Extraction is version 1 — the finding as the LLM/import first produced it;
every later version is an operator EDIT. The distinction is audit
lineage: which content the machine proposed vs. what a human corrected.
Source code in src/revalid/domain.py
110 111 112 113 114 115 116 117 118 119 | |
FindingStage
Bases: StrEnum
The pipeline stage a note was written on (FR-16, ADR-0024).
The stages mirror the finding's lifecycle track — extract → goal → retest →
verdict — while GENERAL tags a note left from the finding overview
rather than any one stage.
PLAN and APPROVE are the retired batch flow's stages (ADR-0033).
They are kept readable so a database written before the reshape still
loads, but nothing produces them any more: the goal stage tagged its notes
plan until issue #113, and existing rows are renamed to goal by the
lightweight backfill in :func:revalid.db.create_db_engine. Do not use them
for new notes.
Source code in src/revalid/domain.py
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
MitreMapping
Bases: BaseModel
MITRE ATT&CK technique mapping for a finding (FR-19).
techniques are ATT&CK technique IDs (e.g. T1190,
T1110) the finding maps onto; inferred is True when the model
derived the mapping rather than reading it from the report. Empty
techniques with inferred=False means none stated and none derived.
Source code in src/revalid/domain.py
60 61 62 63 64 65 66 67 68 69 70 71 72 | |
ReportStatus
Bases: StrEnum
Lifecycle state of an uploaded report's ingest job (FR-01/FR-11).
A report starts EXTRACTING when uploaded and always settles on exactly
one terminal state — READY once its findings are persisted, FAILED
(with the error recorded) if extraction never completes, or CANCELLED
when the operator stops it mid-run (issue #205) — so the UI's status poll is
guaranteed to terminate. A CANCELLED report keeps whatever findings had
already been extracted before the stop, so it is re-runnable or deletable.
Source code in src/revalid/domain.py
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | |
RetestSessionStatus
Bases: StrEnum
Lifecycle of an FR-17 agentic retest session — one agent, five live states.
A live session is always in exactly one of: WORKING (a turn in flight),
AWAITING_COMMAND (a proposed command awaits the operator's approval),
AWAITING_OPERATOR (the agent handed back — a reply, an acknowledgement, a
guided one-action report, "I've exhausted my options", or a verdict recommendation),
IDLE (created but not yet provisioned — a Restart lands here so it never
auto-runs), or STOPPED (the operator paused it, sandbox kept alive). A
session reaches a terminal state (CONCLUDED/ENDED/ERROR) only on a
determination or an operator action. GIVEN_UP is retired — kept only so any
legacy row stays terminal. ADR-0042 collapsed the earlier
needs_guidance/running_command/starting/thinking states into
these five (needs_guidance folded into awaiting_operator).
Source code in src/revalid/domain.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | |
SessionEventKind
Bases: StrEnum
Kinds of append-only transcript event (FR-17 audit trail).
Source code in src/revalid/domain.py
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | |
Settings
Bases: BaseModel
User-configurable LLM backend selection (FR-13 / ADR-0021).
Attributes:
| Name | Type | Description |
|---|---|---|
model |
str
|
A Pydantic AI |
base_url |
str | None
|
Provider base URL for OpenAI-compatible backends (Ollama and
friends); |
api_key |
str | None
|
Provider API key, or |
Source code in src/revalid/domain.py
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | |
Severity
Bases: StrEnum
Normalized severity scale for findings.
Source code in src/revalid/domain.py
15 16 17 18 19 20 21 22 | |
VerdictStatus
Bases: StrEnum
Outcome of retesting a finding (FR-09).
Source code in src/revalid/domain.py
272 273 274 275 276 277 | |
Report ingestion and understanding
pdf extracts whole-document Markdown; extract turns that into
schema-validated findings in one LLM call; ingest maps DefectDojo-style JSON and
manual entry with no LLM at all; findings owns versioning, notes and the
CVSS/MITRE enrichment every door shares.
Structured-report ingestion by schema mapping — no LLM involved (FR-02).
Initial supported format: DefectDojo-style JSON findings export, i.e. a
top-level {"findings": [...]} array where each entry carries at least
title and severity. Every source entry is preserved verbatim in
Finding.raw so unmapped fields stay auditable.
No LLM is the invariant of this module, and it is why this door is the
deterministic, instant, free seeding path for demos and tests. A stated
taxonomy is still copied across — cvssv3/cvssv3_score from the DefectDojo
format, plus the revalid-specific mitre_techniques the manual form supplies —
because copying is not inferring, and both land inferred=False. Deriving a
taxonomy the source never stated is the opt-in enrichment pass in
:mod:revalid.extract, which the caller runs after mapping (issues #233, #237).
IngestError
Bases: ValueError
Raised when an input document cannot be mapped to the internal model.
Source code in src/revalid/ingest.py
33 34 | |
load_defectdojo_export(text)
Parse a DefectDojo-style JSON export string into domain findings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
Raw JSON document. |
required |
Returns:
| Type | Description |
|---|---|
list[Finding]
|
Findings in document order. |
Raises:
| Type | Description |
|---|---|
IngestError
|
If the document is not valid JSON or does not map. |
Source code in src/revalid/ingest.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | |
map_defectdojo_export(data)
Map an already-parsed DefectDojo-style export to domain findings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
object
|
Parsed JSON document; must be an object with a |
required |
Returns:
| Type | Description |
|---|---|
list[Finding]
|
Findings in document order. |
Raises:
| Type | Description |
|---|---|
IngestError
|
If the document shape or any entry is invalid. |
Source code in src/revalid/ingest.py
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | |
PDF pentest-report ingestion — whole-document text extraction (FR-01).
This module turns report bytes into LLM-ready Markdown text. It is
intentionally LLM-free: the semantic step (text -> validated Finding
objects) is FR-03's job (see :mod:revalid.extract). Extraction uses
PyMuPDF4LLM in its deterministic legacy mode (ADR-0047): the whole document is
rendered to GitHub-flavoured Markdown, so headings, tables and lists survive as
structure the model can read, and the same bytes always produce the same text
(NFR-02). The extractor then hands the entire report to the model in one
call — there is no heading segmentation ahead of it (that regex step was removed
in ADR-0047 because it was format-bound and silently failed on unfamiliar
layouts).
Malformed input fails closed with a clear :class:PdfError rather than crashing:
a non-PDF (missing %PDF- header), a structurally corrupt PDF, and a PDF with
no extractable text (scanned/image-only — out of scope, no OCR) are all rejected.
PdfError
Bases: ValueError
Raised when a PDF cannot be read or carries no extractable report text.
Source code in src/revalid/pdf.py
36 37 | |
PdfPage
Bases: BaseModel
Text extracted from one page of a report.
Attributes:
| Name | Type | Description |
|---|---|---|
number |
int
|
1-based page number in document order. |
text |
str
|
Reading-order Markdown of the page (empty if the page has none). |
Source code in src/revalid/pdf.py
40 41 42 43 44 45 46 47 48 49 50 51 | |
PdfReport
Bases: BaseModel
The full deterministic extraction of a PDF report (FR-01 output).
Attributes:
| Name | Type | Description |
|---|---|---|
page_count |
int
|
Number of pages in the source document. |
pages |
tuple[PdfPage, ...]
|
Per-page extracted Markdown, in document order. |
text |
str
|
Whole-report Markdown, pages joined in order — the single input FR-03's LLM structures into findings in one call. |
Source code in src/revalid/pdf.py
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | |
read_pdf(data)
Extract a PDF report to Markdown text, tolerating common layouts (FR-01).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
bytes
|
Raw bytes of the PDF document. |
required |
Returns:
| Type | Description |
|---|---|
PdfReport
|
The extracted report: per-page and whole-document Markdown text. |
Raises:
| Type | Description |
|---|---|
PdfError
|
If |
Source code in src/revalid/pdf.py
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | |
Model-agnostic LLM backend selection (FR-13, ADR-0010, ADR-0021).
One switch, REVALID_LLM_MODEL, selects the backend for every LLM-using
component: it holds a Pydantic AI model string (provider:model, e.g.
ollama:qwen3.5:9b or anthropic:claude-sonnet-5) and defaults to a
local-first Ollama backend (ADR-0021) — no API key or network egress required
out of the box. Switching backends is configuration-only — no code change.
The Ollama backend additionally needs a base URL (OLLAMA_BASE_URL, falling
back to :data:DEFAULT_BASE_URL) for its OpenAI-compatible endpoint.
The string is resolved to a concrete model lazily, at the first model call
(agents are built with defer_model_check=True), so construction never
needs the network and a misconfigured backend surfaces as a clear error on
first use.
DEFAULT_BASE_URL = 'http://localhost:11434/v1'
module-attribute
Default OpenAI-compatible endpoint for the local-first Ollama backend (ADR-0021).
DEFAULT_MODEL = 'ollama:qwen3.5:9b'
module-attribute
Local-first default backend (ADR-0021); used when :data:MODEL_ENV is unset.
A small, responsive model on purpose: the FR-17 agentic console runs a
multi-turn reason->command->observe loop, and a heavier model (e.g.
ollama:qwen3.6:27b at ~50s+/turn locally) makes the interactive session
read as hung. qwen3.5:9b proposes its first step in ~10s here; users can
still select a larger model in /settings when they can tolerate the latency.
Not a member of Pydantic AI's KnownModelName literal (Ollama models are
open-ended, not a fixed catalog), so this is a plain str.
MODEL_ENV = 'REVALID_LLM_MODEL'
module-attribute
Environment variable that selects the Pydantic AI backend (ADR-0010).
agent_model_name(agent)
Return a best-effort stable model identifier for an agent's audit lineage.
Recording which backend produced a finding or plan is required for the audit
trail (NFR-02). Works whether the agent was built from a model string or an
injected model instance (TestModel/FunctionModel in tests).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[Any, Any]
|
Any Pydantic AI agent. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The model string, or the instance's |
str
|
|
Source code in src/revalid/llm.py
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | |
build_model(cfg)
Construct a concrete Pydantic AI model from a persisted setting (ADR-0021).
- A
base_urlselects an OpenAI-compatible model (Ollama or any OpenAI-compatible host); theollama:/openai:provider prefix is stripped from the model name and a placeholder key is used when none is stored (Ollama ignores it). - Otherwise a native provider with a stored key is built explicitly; with
no stored key the bare
provider:modelstring is returned so Pydantic AI resolves credentials from the environment (backward-compatible with FR-13).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Settings
|
The persisted settings. |
required |
Returns:
| Type | Description |
|---|---|
Model | str
|
A Pydantic AI :class: |
Model | str
|
string when the environment should supply credentials. |
Source code in src/revalid/llm.py
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | |
resolve_model()
Return the configured Pydantic AI model string.
Reads :data:MODEL_ENV (REVALID_LLM_MODEL); an unset or blank value
falls back to :data:DEFAULT_MODEL.
Returns:
| Type | Description |
|---|---|
str
|
A |
Source code in src/revalid/llm.py
51 52 53 54 55 56 57 58 59 60 | |
LLM extraction of structured findings from report text (FR-03).
Turns the whole-report Markdown produced by FR-01 (pdf.py) into
schema-validated domain :class:~revalid.domain.Finding objects using Pydantic
AI (ADR-0002, ADR-0047). The entire report is sent to the model in one call,
which must return a list of :class:ExtractedFinding objects; output that fails
schema validation is retried by Pydantic AI and, if still invalid, flagged as
an :class:ExtractionFailure — never silently mapped to a Finding or persisted
(FR-03's schema-validation gate). The model is injectable so unit tests drive it
with Pydantic AI's TestModel/FunctionModel and never touch the network;
when none is passed, the configured backend is used (REVALID_LLM_MODEL,
FR-13/ADR-0010).
EnrichmentReport
Bases: BaseModel
Outcome of an opt-in taxonomy enrichment pass (FR-19, issue #233).
Attributes:
| Name | Type | Description |
|---|---|---|
findings |
tuple[Finding, ...]
|
The findings in input order, enriched where possible. A finding whose model call failed is present, unchanged. |
enriched |
int
|
How many findings actually gained a CVSS vector or ATT&CK
techniques. Lower than |
failed |
int
|
How many model calls failed schema validation. Non-zero means the import succeeded but part of the taxonomy is missing — surfaced to the operator rather than swallowed. |
Source code in src/revalid/extract.py
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
ExtractedFinding
Bases: BaseModel
One finding exactly as the model must return it — the FR-03 gate.
Every field is required, so the model must account for all of them; Pydantic
AI validates the tool output against this schema and retries on mismatch.
Strings may be empty when the report omits a field; title may not.
Source code in src/revalid/extract.py
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 | |
ExtractionFailure
Bases: BaseModel
A report whose extraction never validated — flagged, not persisted.
Extraction is a single whole-document call, so a failure is all-or-nothing: one record stands for the report, not for an individual finding.
Attributes:
| Name | Type | Description |
|---|---|---|
error |
str
|
The reason extraction failed (retries exhausted, etc.). |
source_text |
str
|
The report text, kept so the failure is auditable and re-runnable. |
Source code in src/revalid/extract.py
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | |
ExtractionRegistry
Process-local cancel flags for in-flight extractions (issue #205).
Extraction is a single whole-document model call run as a background task
(:func:~revalid.app.run_extraction). This lets the request thread flag a
report so the worker settles cooperatively. Thread-safe: the flag is written
by the request thread and read by the extraction worker.
A flag carries a reason: "operator" (a Stop) or "deleted" (the report
is being removed). A pending delete always wins over a Stop, since the row is
going away.
Beyond the flag, the registry holds the event loop + task of an in-flight extraction (attached by the worker) so a cancel can interrupt the running model call cross-thread. Because extraction is one call, interrupting it is the only way to stop it — there is no between-candidates checkpoint — which is exactly what makes Stop work when a local model wedges on a long document.
Source code in src/revalid/extract.py
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | |
__init__()
Start with no extraction flagged.
Source code in src/revalid/extract.py
291 292 293 294 295 | |
attach(report_id, loop, task)
Register the loop + task of the extraction now running for report_id.
Source code in src/revalid/extract.py
320 321 322 323 324 325 326 327 328 | |
cancel_reason(report_id)
Return the cancel reason flagged for report_id, or None if not flagged.
Source code in src/revalid/extract.py
315 316 317 318 | |
clear(report_id)
Drop any flag + run handle for report_id (the worker settled).
Source code in src/revalid/extract.py
330 331 332 333 334 | |
request_cancel(report_id, reason='operator')
Flag report_id for cancellation and interrupt its in-flight call.
Records the reason (a "deleted" flag always wins over an operator Stop)
and, if an extraction task is attached, cancels it cross-thread so the
current model call aborts immediately rather than at the next candidate.
Source code in src/revalid/extract.py
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 | |
ExtractionReport
Bases: BaseModel
Outcome of extracting a whole report.
Attributes:
| Name | Type | Description |
|---|---|---|
findings |
tuple[Finding, ...]
|
Schema-valid findings, safe to persist. |
failures |
tuple[ExtractionFailure, ...]
|
A single flagged failure when the whole-document call never passed the validation gate; empty otherwise. |
cancelled |
bool
|
Whether extraction stopped early because the operator asked it
to (issue #205). Extraction is one call, so a cancel yields no
findings — |
Source code in src/revalid/extract.py
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | |
FindingTaxonomy
Bases: BaseModel
A finding's derived CVSS + ATT&CK classification (FR-19, opt-in enrichment).
Deliberately without an inferred flag: everything this model returns is
by definition the model's own derivation, so provenance is stamped server-side
(:func:apply_taxonomy sets inferred=True) and the model has no way to
express "the report stated this". That is the same rule the finding editor
follows — a client, human or machine, never asserts provenance (ADR-0037).
Every field is required, for the same reason :class:ExtractedFinding's
are: the model must account for all of them, and Pydantic AI retries when it
does not. With defaults, {} was trivially valid — a small model could
return nothing, no retry would fire, and the result was indistinguishable from
"assessed and found nothing to say" (issue #241, seen live on a 9b local
model). An unassessable finding is still expressible, as an explicit empty
vector and empty technique list; it just has to be chosen rather than
reached by omission.
Source code in src/revalid/extract.py
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | |
Person
Bases: BaseModel
A person named in the report, with their stated role (#133).
Source code in src/revalid/extract.py
145 146 147 148 149 150 151 | |
ReportMetadata
Bases: BaseModel
Document-level metadata extracted from a report (FR-03, #133).
Every field defaults to empty, so an absent value — or a failed extraction on a small local model — yields a blank the operator can fill in, never a guess.
Source code in src/revalid/extract.py
154 155 156 157 158 159 160 161 162 163 164 165 166 | |
apply_taxonomy(finding, taxonomy)
Fill a finding's empty CVSS/ATT&CK from taxonomy, flagged as inferred.
Never overwrites a stated value. A finding that already carries a CVSS
vector or ATT&CK techniques — mapped verbatim from a DefectDojo export, or
typed by the operator — keeps them exactly, with their existing provenance.
Only the empty fields are filled, and what this fills is always
inferred=True: it is the model's derivation, never a source's claim.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
finding
|
Finding
|
The finding to enrich. |
required |
taxonomy
|
FindingTaxonomy
|
The model's derived classification. |
required |
Returns:
| Type | Description |
|---|---|
Finding
|
The finding with previously-empty taxonomy fields filled, or the original |
Finding
|
object unchanged when there was nothing to fill. |
Source code in src/revalid/extract.py
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 | |
build_extraction_agent(model=None)
Build the finding-extraction agent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Model | KnownModelName | str | None
|
A Pydantic AI model instance or name. When omitted, the
configured backend is used ( |
None
|
Returns:
| Type | Description |
|---|---|
Agent[None, list[ExtractedFinding]]
|
An agent whose validated output is a list of :class: |
Source code in src/revalid/extract.py
446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 | |
build_metadata_agent(model=None)
Build the document-metadata extraction agent (FR-03, #133).
Source code in src/revalid/extract.py
181 182 183 184 185 186 187 188 189 190 191 | |
build_taxonomy_agent(model=None)
Build the opt-in CVSS/ATT&CK enrichment agent (FR-19, issue #233).
The PDF door gets its taxonomy inside the extraction call itself. The FR-02 JSON and manual doors are deliberately LLM-free, so for them enrichment is a separate, opt-in pass driven by this agent — one call per finding, only when the operator asks for it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Model | KnownModelName | str | None
|
A Pydantic AI model instance or name. When omitted, the configured
backend is used (FR-13/ADR-0010); tests pass |
None
|
Returns:
| Type | Description |
|---|---|
Agent[None, FindingTaxonomy]
|
An agent whose validated output is one :class: |
Source code in src/revalid/extract.py
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | |
enrich_findings(agent, findings)
Synchronous wrapper over :func:enrich_findings_async (the request path).
Source code in src/revalid/extract.py
439 440 441 442 443 | |
enrich_findings_async(agent, findings)
async
Derive the missing CVSS/ATT&CK for each finding, one model call apiece.
A finding whose call fails schema validation (after Pydantic AI's retries) is
left exactly as it was and counted in failed rather than raising: an
import must not be lost because a small local model could not produce a CVSS
vector. The count is returned so the caller can surface it — a silently
unenriched import would look identical to one the operator never asked to
enrich.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[None, FindingTaxonomy]
|
The agent from :func: |
required |
findings
|
Sequence[Finding]
|
The findings to enrich, in order. |
required |
Returns:
| Type | Description |
|---|---|
EnrichmentReport
|
The findings in the same order, plus how many were enriched and how many |
EnrichmentReport
|
model calls failed. |
Source code in src/revalid/extract.py
403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 | |
extract_metadata(agent, report)
Extract document metadata from a report's opening text (FR-03, #133).
Best-effort and non-fatal: metadata lives near the top of a report, so only the first few thousand characters are sent, and any model or validation failure yields empty metadata the operator can edit — it never blocks the report from becoming ready.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[None, ReportMetadata]
|
The metadata agent (from :func: |
required |
report
|
PdfReport
|
An extracted report from :func: |
required |
Returns:
| Type | Description |
|---|---|
ReportMetadata
|
The extracted (or empty-on-failure) :class: |
Source code in src/revalid/extract.py
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | |
extract_report(agent, report, should_cancel=_never_cancel)
Synchronous wrapper over :func:extract_report_async (tests, offline demos).
The production path (:func:~revalid.app.run_extraction) drives the async form
directly on a cancellable loop so a Stop can interrupt it; this wrapper runs it
to completion on a throwaway loop for callers that do not need cancellation.
Source code in src/revalid/extract.py
516 517 518 519 520 521 522 523 524 525 526 527 | |
extract_report_async(agent, report, should_cancel=_never_cancel)
async
Extract structured findings from a whole report in one call (FR-03), async.
Sends the entire report to the model in a single await agent.run — the
async path — so the run can be interrupted mid-call (issue #205): cancelling
the task cancels the in-flight HTTP request, which is what makes Stop work even
when a local model wedges on a long document. should_cancel is checked once
up front for the case where a stop was already requested before the call began.
A cancel yields an empty, cancelled=True result. Valid output is mapped to
domain findings; output that never passes the schema gate is flagged as a
single failure instead of persisted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[None, list[ExtractedFinding]]
|
The extraction agent (from :func: |
required |
report
|
PdfReport
|
An extracted report from :func: |
required |
should_cancel
|
Callable[[], bool]
|
Returns |
_never_cancel
|
Returns:
| Type | Description |
|---|---|
ExtractionReport
|
The valid findings and any flagged failure, with |
ExtractionReport
|
run stopped early. |
Source code in src/revalid/extract.py
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 | |
taxonomy_prompt(finding)
Render the finding as the prompt the taxonomy agent classifies.
Source code in src/revalid/extract.py
364 365 366 367 368 369 370 371 372 373 | |
Finding revision & annotation: versioned findings + stage-tagged notes (FR-16, ADR-0024).
A finding is a stable identity (:class:~revalid.db.FindingRecord) plus
append-only immutable version rows (:class:~revalid.db.FindingVersionRecord):
extraction is version 1, each operator edit a new version — symmetric with the
FR-05 plan model (ADR-0012). Plans and verdicts reference the stable identity, so
amending a finding never orphans them. Notes
(:class:~revalid.db.FindingNoteRecord) are a per-finding, stage-tagged,
append-only log. This module owns those lifecycles; nothing mutates a version or a
note in place.
add_note(session, finding_id, stage, body, *, author=_ACTOR)
Append a stage-tagged note to the finding's log (append-only).
Source code in src/revalid/findings.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 | |
add_version(session, finding_id, finding, *, edited_by=_ACTOR, reason='')
Append an operator edit as a new immutable version (origin=edit).
Never mutates a prior version — the earlier content stays in history as the correction record (FR-10). The appended version becomes the current one.
Source code in src/revalid/findings.py
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | |
create_finding(session, finding, report_id=None)
Create a finding identity and its version-1 content (origin=extraction).
The single entry point for a newly ingested finding — extraction, FR-02
import, and manual entry all land here. Flushes so the version row can
reference the new identity id, but leaves the commit to the caller
(findings are created in a batch alongside their report).
Source code in src/revalid/findings.py
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | |
current_version(session, finding_id)
Return the finding's current (highest-version) content, or None.
Source code in src/revalid/findings.py
79 80 81 82 83 84 85 | |
list_notes(session, finding_id)
Return the finding's notes, newest first.
Source code in src/revalid/findings.py
115 116 117 118 119 120 121 122 123 | |
list_versions(session, finding_id)
Return every version of a finding, oldest first (extraction = v1).
Source code in src/revalid/findings.py
88 89 90 91 92 93 94 95 96 | |
Retest goal and agentic session
plan generates the retest goal (FR-04, repurposed by ADR-0032);
sandbox provides the egress-locked execution environment and scope parses the
host it is provisioned against; retest_agent is the Pydantic AI agent and its
two tools; retest_session is the orchestrator that owns the lifecycle, the
transcript and the approval gate; deltas is the transient reasoning-token
channel that deliberately never reaches that transcript.
Retest-goal generation from findings (FR-04, repurposed — ADR-0032).
FR-04's plan generator was repurposed (FR-17 6b-ii) from producing HTTP probes
into producing a retest goal: a few concise, tool-agnostic verification steps
the agentic console's agent works to. A Pydantic AI agent proposes a
:class:GeneratedGoal from the finding (ADR-0009 schema-gate pattern; model
chosen by REVALID_LLM_MODEL — FR-13). The old batch probe-plan path was
removed with the batch execution in FR-17 6b-iii.
GeneratedGoal
Bases: BaseModel
A short, tool-agnostic retest goal — the steps the agent works to (FR-17 6b-ii).
A few concise natural-language verification steps for any finding, not HTTP probes (the batch probe plan retired with the batch path in FR-17 6b-iii).
Source code in src/revalid/plan.py
24 25 26 27 28 29 30 31 32 33 | |
build_goal_agent(model=None)
Build the retest-goal agent (FR-17 6b-ii): a generic, tool-agnostic goal generator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Model | KnownModelName | str | None
|
A Pydantic AI model instance or name. When omitted, the configured
backend is used ( |
None
|
Returns:
| Type | Description |
|---|---|
Agent[None, GeneratedGoal]
|
An agent whose validated output is a :class: |
Source code in src/revalid/plan.py
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | |
finding_prompt(finding)
Render a finding as the model-facing context both retest prompts are built from.
One renderer, deliberately (issue #249): this used to exist twice — here and in
app.py — and the copies had drifted, so the goal generator saw the finding's
severity and attack vector while the retest agent, which acts on it, did not.
Every field is omitted when the finding does not state it, rather than rendered as a placeholder: a report ingested through the JSON or manual door often carries only a title and steps, and telling the model "Attack vector:" followed by nothing invites it to invent one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
finding
|
Finding
|
The finding to describe. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The finding's identity, classification and original reproduction steps as plain |
str
|
text — the shared basis for goal generation (FR-04) and for the retest agent's |
str
|
opening prompt (FR-17). |
Source code in src/revalid/plan.py
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | |
generate_goal(agent, finding)
Generate a generic retest goal for finding (FR-17 6b-ii).
Best-effort: on a model failure it returns an empty tuple so session start never blocks — the agent then falls back to the finding context alone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[None, GeneratedGoal]
|
The goal agent (from :func: |
required |
finding
|
Finding
|
The finding to derive a retest goal for. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, ...]
|
The generated goal steps, or |
Source code in src/revalid/plan.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |
Retest scope parsing (FR-17 / FR-06, issue #208).
The retest scope is written by the operator at launch as one or more target
endpoints (or defaulted from the finding's affected endpoints). The sandbox is
provisioned against the host of that scope, not the specific page: a finding
about https://domain.com/#/login retests domain.com at any path, so the
agent can follow the vulnerability wherever it lives under that host — not only
the one URL in the report.
This module holds the pure parsing: an endpoint string in, its host (host or
host:port) out. Provisioning (lab vs. online egress) lives in the sandbox.
scope_host(endpoint)
Parse one scope endpoint down to its host (host or host:port).
Keeps the port (it is part of the reachable target — the lab is
localhost:3000) but drops scheme, userinfo, path, query and fragment,
including SPA hash routes. Sub-domains are preserved (they are distinct
hosts); only the path is stripped.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
endpoint
|
str
|
A target string — a full URL, a scheme-less |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The lower-cased |
str | None
|
carries no parseable host. |
Examples:
>>> scope_host("https://domain.com/#/login")
'domain.com'
>>> scope_host("http://domain.com:8080/a/b?x=1")
'domain.com:8080'
>>> scope_host("domain.com/login")
'domain.com'
>>> scope_host("http://localhost:3000/rest/user/login")
'localhost:3000'
>>> scope_host(" ") is None
True
Source code in src/revalid/scope.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | |
scope_hosts(endpoints)
Parse a scope's endpoints to a de-duplicated, order-preserving host tuple.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
endpoints
|
tuple[str, ...]
|
The launch-time scope endpoints ( |
required |
Returns:
| Type | Description |
|---|---|
str
|
Each distinct parseable host, first-seen order preserved. Unparseable |
...
|
entries are dropped. |
Source code in src/revalid/scope.py
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | |
FR-17 / M6 egress-locked retest sandbox (ADR-0025, Slice 0).
An ephemeral Docker container in which the retest agent runs one approved command
at a time, provisioned into one of two topologies according to the session's scope
(ADR-0041): a lab target gets an --internal network with the target attached
as its only other member (no host/internet route at all), while an online target
gets a per-session L3 egress gateway whose network namespace the sandbox joins
and whose iptables allowlist it holds no capability to change (ADR-0045).
Either way containment is topology, never command inspection.
The pure surface (CommandResult, FakeSandbox, the mode/resolution/firewall
helpers) is unit-tested; the live DockerSandbox needs the optional sandbox
extra and is covered only by the nightly system test.
CommandResult
Bases: BaseModel
The captured result of one command run in the sandbox.
Source code in src/revalid/sandbox.py
55 56 57 58 59 60 61 62 63 | |
DockerSandbox
A real ephemeral Docker sandbox on an egress-locked --internal network.
Source code in src/revalid/sandbox.py
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 | |
__init__(session_id, *, image=None, lab_container=DEFAULT_LAB_CONTAINER)
Bind this sandbox to session_id (scopes its per-session resource names).
Source code in src/revalid/sandbox.py
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 | |
exec(command, *, timeout)
Run command inside the live container, capped at timeout seconds.
Docker's exec_run is blocking (the model always sees the complete
output before its next turn) but has no native timeout, so the cap is
enforced in-container by wrapping the command with timeout — present on
both the alpine/busybox base of the default image and a coreutils-based
Kali image (#105). A command that overruns is killed and exits non-zero
(124 on coreutils, 143/SIGTERM on busybox); run_command surfaces that to
the agent, which chose the limit and can retry with a narrower scope. Without
this, a hanging or unbounded command (e.g. an nmap sweep, or one blocked on
stdin) would wedge the session at working forever.
Source code in src/revalid/sandbox.py
512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 | |
start(scope_hosts=())
Provision the sandbox for this session's scope (ADR-0025/0041/0045).
Lab scope (empty, or every host is the lab host) keeps the unchanged
--internal network with the lab container attached — the target is a
named neighbour, reachable by construction and nothing else. Any other host
is an online target: provision a per-session egress gateway that holds an
L3 iptables allowlist for the scoped IP(s), and run the sandbox inside the
gateway's network namespace so every tool (not just HTTP) can reach the
scoped host and nothing else — and cannot alter that, because it holds no
NET_ADMIN (ADR-0045).
Source code in src/revalid/sandbox.py
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 | |
stop()
Tear down this session's containers and network by name (best-effort).
Removal is keyed on the session-scoped resource names, not on the
object's held references, so a freshly constructed DockerSandbox can
reap resources it never created — the case that matters when a report is
deleted after a backend restart has forgotten the live session. Every step
tolerates "already gone".
Source code in src/revalid/sandbox.py
541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 | |
FakeSandbox
A scripted in-memory sandbox for unit/integration tests (no Docker).
Source code in src/revalid/sandbox.py
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | |
__init__(script)
Store the scripted results (or callable) to replay in :meth:exec.
Source code in src/revalid/sandbox.py
266 267 268 269 270 271 272 273 274 275 276 277 | |
exec(command, *, timeout)
Return the next scripted result (or apply the callable).
Source code in src/revalid/sandbox.py
284 285 286 287 288 289 290 291 292 | |
start(scope_hosts=())
Mark the fake as started, recording the scope it was provisioned for.
Source code in src/revalid/sandbox.py
279 280 281 282 | |
stop()
Mark the fake as stopped.
Source code in src/revalid/sandbox.py
294 295 296 | |
Sandbox
Bases: Protocol
One ephemeral, egress-locked execution environment for a retest session.
Source code in src/revalid/sandbox.py
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | |
exec(command, *, timeout)
Run command and capture its result.
Source code in src/revalid/sandbox.py
76 77 | |
start(scope_hosts=())
Provision the environment for scope_hosts (idempotent).
scope_hosts is the session's parsed scope (ADR-0041): empty or the lab
host keeps lab provisioning; any other host provisions egress to it.
Source code in src/revalid/sandbox.py
69 70 71 72 73 74 | |
stop()
Tear the environment down; nothing persists.
Source code in src/revalid/sandbox.py
79 80 | |
SandboxUnavailableError
Bases: Exception
Raised when a sandbox is required but the runtime cannot provide one.
Source code in src/revalid/sandbox.py
88 89 | |
dns_resolver()
Return the allowed DNS resolver ($REVALID_DNS_RESOLVER or the default).
Source code in src/revalid/sandbox.py
120 121 122 | |
egress_firewall_script(scope_ips, dns_ip)
Build the gateway's iptables egress allowlist as a shell script (ADR-0045).
Default-drop OUTPUT (IPv4), then allow only: loopback, established/related
return traffic, DNS to the one permitted resolver, and each scoped IP (any
protocol, so ICMP/UDP/TCP scans all work). IPv6 is blanket-dropped — the
scope IPs are v4 (see :func:_getaddrinfo_ips) and Docker's bridge is v4-only,
so v6 is both unneeded and a leak to close; the drop is best-effort (|| true)
so a kernel without a v6 stack does not abort the script. Everything else is
dropped, so the sandbox sharing this namespace reaches the scoped host and
nothing else. The gateway then blocks on sleep infinity to keep the
namespace (and thus its rules) alive for the sandbox's lifetime.
Only resolved IPs and a resolver IP are interpolated — never a hostname — so there is no shell-injection surface (all inputs are numeric).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope_ips
|
tuple[str, ...]
|
The resolved (IPv4) scope IPs to permit. |
required |
dns_ip
|
str
|
The single DNS resolver to permit on port 53. |
required |
Returns:
| Type | Description |
|---|---|
str
|
A |
Source code in src/revalid/sandbox.py
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | |
gateway_container_name(session_id)
Return the per-session egress-gateway container name (ADR-0045).
The gateway owns the network namespace and the iptables egress allowlist; the
sandbox joins that namespace but cannot alter it (it holds no NET_ADMIN).
Source code in src/revalid/sandbox.py
135 136 137 138 139 140 141 | |
internal_network_name(session_id)
Return the per-session Docker network name.
One name for both topologies, because it is the same session resource either
way — created with internal=True for a lab scope (hence the function's
name), and as an ordinary routable bridge for an online one, where egress is
bounded by the gateway's firewall rather than by the network having no route
out (ADR-0045).
Source code in src/revalid/sandbox.py
92 93 94 95 96 97 98 99 100 101 | |
is_lab_scope(scope_hosts)
Whether a scope stays on the lab (empty, or every host is the lab host).
Lab scope keeps the unchanged --internal + attached-lab-container
provisioning; any other host is an online target that needs the L3 egress
gateway (ADR-0041, re-mechanised by ADR-0045). An empty scope defaults to
the lab.
Source code in src/revalid/sandbox.py
151 152 153 154 155 156 157 158 159 160 | |
lab_base_url()
Return the lab target base URL ($REVALID_LAB_BASE_URL or the default).
Source code in src/revalid/sandbox.py
104 105 106 | |
lab_host()
Return the lab target's host (host or host:port) from the lab base URL.
Source code in src/revalid/sandbox.py
144 145 146 147 148 | |
online_scope_hosts(scope_hosts)
The non-lab hosts in a scope — the online targets to allowlist (ADR-0041).
Source code in src/revalid/sandbox.py
163 164 165 166 | |
resolve_scope_ips(hosts, resolver=None)
Resolve each online scope host to its IP addresses (ADR-0045).
The IPs are pinned into the sandbox at launch — allowlisted in the egress
firewall and written to /etc/hosts — so the L3 gateway can permit exactly
the scoped host and nothing else. All A/AAAA records are taken, but they are a
snapshot: a target behind rotating CDN IPs may present addresses not seen
here (the inherent limit of an IP allowlist vs. the retired L7 proxy — stated
in ADR-0045).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hosts
|
tuple[str, ...]
|
The online scope hosts ( |
required |
resolver
|
Callable[[str], list[str]] | None
|
Injection seam for tests — maps a bare host to its IP strings.
Defaults to a real DNS lookup via :func: |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, tuple[str, ...]]
|
A mapping of bare host → its resolved IPs (in stable, de-duplicated order). |
dict[str, tuple[str, ...]]
|
A host that does not resolve maps to an empty tuple (the caller fails |
dict[str, tuple[str, ...]]
|
closed on it rather than opening egress to a guessed address). |
Source code in src/revalid/sandbox.py
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | |
sandbox_container_name(session_id)
Return the per-session sandbox container name (ADR-0045).
Named (rather than anonymous) so teardown can find and remove it by name even
when no live DockerSandbox object holds a reference — e.g. reaping a
session orphaned by a backend restart when its report is deleted.
Source code in src/revalid/sandbox.py
125 126 127 128 129 130 131 132 | |
sandbox_image()
Return the sandbox image to run ($REVALID_SANDBOX_IMAGE or the default).
The default is the locally-built Kali toolbox (issue #105); the override exists so an operator can point the agent at their own image without a code change — the egress lock is enforced by the network, not by the image, so swapping it changes the tools available and nothing about containment.
Source code in src/revalid/sandbox.py
109 110 111 112 113 114 115 116 117 | |
FR-17 / M6 agentic retest agent (ADR-0025, Slice 0).
Two tools — a gated run_command (Pydantic AI deferred approval) and an
ungated respond for prose — over a three-way output union: a
ConcludeOutput verdict, an AwaitOperator hand-back (ADR-0039), or a
DeferredToolRequests pause while a proposed command awaits approval. The
orchestrator (retest_session.py) runs the agent step-by-step, pausing on each
proposed command for human approval and resuming with
ToolApproved/ToolDenied. Its persona branches per turn on
deps.free_launch: guided (one action, then hand back) or autonomous
(drive to a verdict) — ADR-0040.
AwaitOperator
Bases: BaseModel
The agent replied and is handing control back to the operator (issue #204).
The turn ends without a command or a verdict: the agent answered the operator
conversationally (a greeting, a small-talk reply, an acknowledgement) and is
now waiting for them, sandbox kept alive. Lighter than an inconclusive
conclusion — it is not "I'm stuck, please guide me", just a pause: the agent has
said its piece and waits. The
orchestrator surfaces message as an agent chat bubble and parks the session
in awaiting_operator; the operator's next message resumes it.
Source code in src/revalid/retest_agent.py
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 | |
ConcludeOutput
Bases: BaseModel
The agent's terminal verdict for a retest session.
Source code in src/revalid/retest_agent.py
141 142 143 144 145 146 147 | |
RetestSessionDeps
dataclass
Runtime dependencies injected into the retest agent's tools.
Source code in src/revalid/retest_agent.py
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | |
build_retest_agent(model=None)
Build the FR-17 retest agent: a gated run_command tool + a verdict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Model | KnownModelName | str | None
|
A Pydantic AI model instance or name. When omitted, the
configured backend is used ( |
None
|
Returns:
| Type | Description |
|---|---|
RetestAgent
|
An agent whose output is a :class: |
RetestAgent
|
class: |
RetestAgent
|
|
RetestAgent
|
class: |
Source code in src/revalid/retest_agent.py
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | |
clamp_timeout(seconds)
Clamp an agent-requested per-command timeout to [1, MAX_COMMAND_TIMEOUT].
Source code in src/revalid/retest_agent.py
44 45 46 | |
format_observations(observations)
Render buffered operator activity as a block the agent reads on its next turn.
Returns an empty string when there is nothing to surface, so callers can append it unconditionally.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
observations
|
list[str]
|
Human-run command summaries buffered since the last turn. |
required |
Returns:
| Type | Description |
|---|---|
str
|
A labelled block to append to the next tool result, or |
Source code in src/revalid/retest_agent.py
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | |
FR-17 / M6 retest-session persistence + orchestration (ADR-0025, Slice 0).
An agentic retest session is a :class:~revalid.db.RetestSessionRecord row plus
its append-only transcript of :class:~revalid.db.SessionEventRecord rows,
symmetric with how :mod:revalid.findings splits identity from immutable
history (Task 3: create_session, append_event, load_events_after,
set_status, record_verdict).
Task 5 adds the orchestration layer that drives the Task 4 agent
(:mod:revalid.retest_agent) step-by-step: a process-local
:class:SessionRegistry of :class:LiveSession state, start_and_step/
apply_decision to pause on each proposed command for human approval and
resume it. When the agent exhausts the options it can think of, it hands back
to the operator (awaiting_operator, ADR-0034/0042) rather than running forever.
LiveSession
dataclass
In-memory live state for one active session (not restart-safe, Slice 0).
Deliberately does NOT cache a :class:~revalid.retest_agent.RetestSessionDeps:
start_and_step and apply_decision may run in separate background
tasks against separate DB sessions (Task 6's async driver), and deps'
emit_output closure captures whichever Session built it. Caching
deps here would let a later call write command_output events through
an already-closed session. Callers build deps fresh via _make_deps
immediately before each agent.run_sync.
Attributes:
| Name | Type | Description |
|---|---|---|
agent |
RetestAgent
|
The built retest agent driving this session. |
sandbox |
Sandbox
|
The persistent sandbox handle for this session's lifetime. |
messages |
list[ModelMessage]
|
The full Pydantic AI message history so far (resumed on
each step via |
pending_call_id |
str | None
|
The |
lock |
Lock
|
Guards the compare-and-swap on |
Source code in src/revalid/retest_session.py
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 | |
attach_run(loop, task)
Register the loop + task of the turn now starting (thread-safe).
Resets abort_retry so a stale flag from a prior turn never bleeds into
this one — each turn starts with clean cancellation state.
Source code in src/revalid/retest_session.py
509 510 511 512 513 514 515 516 517 518 | |
consume_restart()
Return + clear whether the aborted turn should be re-run (thread-safe).
Source code in src/revalid/retest_session.py
564 565 566 567 568 569 | |
detach_run()
Clear the in-flight-turn handle once the turn completes (thread-safe).
Source code in src/revalid/retest_session.py
520 521 522 523 524 | |
drain()
Atomically return and clear the buffered operator observations (thread-safe).
Source code in src/revalid/retest_session.py
443 444 445 446 447 448 | |
drain_goal()
Atomically return and clear the queued goal change (thread-safe).
Source code in src/revalid/retest_session.py
489 490 491 492 493 494 | |
drain_messages()
Atomically return and clear the queued operator chat messages (thread-safe).
Source code in src/revalid/retest_session.py
463 464 465 466 467 468 | |
has_queued_messages()
Whether operator chat messages are waiting for the next turn (thread-safe).
Peeked (not drained) by :func:_advance to decide whether to deliver them at
a turn boundary — the actual drain happens inside the resume it then runs.
Source code in src/revalid/retest_session.py
470 471 472 473 474 475 476 477 | |
observe(summary)
Buffer one operator-command summary for the agent's next turn (thread-safe).
Source code in src/revalid/retest_session.py
438 439 440 441 | |
receive_message(text)
Queue one operator chat message for the agent's next turn (thread-safe).
Source code in src/revalid/retest_session.py
458 459 460 461 | |
request_cancel()
Abort the in-flight turn WITHOUT a retry — teardown (issues #204/#205).
Like :meth:request_restart but leaves abort_retry False so the
cancellation propagates out of the run thread (which then settles the
already-terminal session) instead of re-running. Used when ending or
deleting a session so a wedged turn's thread does not linger on a hung call.
Source code in src/revalid/retest_session.py
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 | |
request_restart()
Abort the in-flight turn and mark it to be re-run — unstick (issue #204).
Cancels the run's asyncio task from this (foreign) thread and flags the run
thread to retry the same turn rather than fail. Returns whether a turn was
actually in flight to abort (False = nothing to unstick).
Source code in src/revalid/retest_session.py
526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 | |
set_pending_goal(steps)
Queue a goal change for the agent's next turn (thread-safe).
Source code in src/revalid/retest_session.py
484 485 486 487 | |
SessionRegistry
Process-local registry of live sessions (one per app instance).
Source code in src/revalid/retest_session.py
572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 | |
__init__()
Start with an empty registry.
Source code in src/revalid/retest_session.py
575 576 577 | |
drop(session_id)
Remove session_id from the registry (no-op if already absent).
Source code in src/revalid/retest_session.py
587 588 589 | |
get(session_id)
Return the live state for session_id, or None if not live.
Source code in src/revalid/retest_session.py
583 584 585 | |
put(session_id, live)
Register live as the active state for session_id.
Source code in src/revalid/retest_session.py
579 580 581 | |
adjudicate_verdict(session, session_id, status, rationale)
Record a human adjudication of a concluded session's verdict (FR-17 Slice 6a).
The human accepts or overrides the agent's conclusion. Either way this
appends a superseding agentic verdict (actor="operator") — the agent's
record is never mutated (append-only; FR-10 intact) — plus a
verdict_adjudicated transcript event, and updates the session row so its
view shows the final call. Latest-per-finding (highest verdict id) is
authoritative, so the operator record supersedes the agent's.
A pure DB operation: the session is already terminal (torn down), so the live registry is never touched. A no-op if the session doesn't exist or has no agent verdict yet (nothing to adjudicate) — the guard also makes a premature or duplicate call safe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
session_id
|
int
|
The concluded retest session being adjudicated. |
required |
status
|
VerdictStatus
|
The human's verdict (may equal or differ from the agent's). |
required |
rationale
|
str
|
The human's justification. |
required |
Source code in src/revalid/retest_session.py
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 | |
append_event(session, session_id, kind, payload)
Append one transcript event with the next seq and commit.
Source code in src/revalid/retest_session.py
127 128 129 130 131 132 133 134 135 136 137 | |
apply_decision(session, registry, session_id, *, approved, reason='', command_id)
Resume a paused run with a human decision on the pending command.
An approval runs the command and resumes the agent; when the agent later
exhausts the options it can think of it hands back for operator guidance
(:func:_dispatch_output, ADR-0034) rather than running forever.
command_id must match the session's currently pending tool_call_id
(validated + consumed atomically under live.lock, see
_consume_pending_call); a stale, duplicate, or mismatched decision is a
silent no-op. This closes a double-approve race: without it, two
concurrent decisions on the same command could both pass the guard, both
resume the agent (running the approved command twice in the sandbox) and
both append transcript events under colliding seq numbers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call — always freshly obtained by the caller, never held across separate orchestration calls. |
required |
registry
|
SessionRegistry
|
The live-session registry. |
required |
session_id
|
int
|
The retest session to resume. |
required |
approved
|
bool
|
Whether the pending command was approved. |
required |
reason
|
str
|
Optional human-supplied reason, recorded and (on rejection) surfaced back to the model as the tool's denial message. |
''
|
command_id
|
str
|
The |
required |
Source code in src/revalid/retest_session.py
1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 | |
conclude_session(session, registry, session_id, status, rationale)
Operator manually concludes a session with a determination — ADR-0034.
The operator's own verdict, recordable at ANY live point in the retest (issue
150) — not only at an awaiting_operator hand-back: while a command awaits approval,
or even while the agent is mid-step. Writes the verdict (actor="operator",
the only path that can record inconclusive) and tears down the sandbox. A
no-op if the session is already terminal. Works on an orphaned session too (the
verdict is recorded; the teardown no-ops). When invoked mid-step, the in-flight
agent turn's resulting failure is swallowed rather than clobbering this verdict
(see :func:_fail).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry. |
required |
session_id
|
int
|
The session to conclude. |
required |
status
|
VerdictStatus
|
The operator's determination. |
required |
rationale
|
str
|
The operator's justification. |
required |
Source code in src/revalid/retest_session.py
1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 | |
continue_session(session, registry, session_id)
Resume a session the agent handed back — ADR-0034 "Keep going" / reply (#204).
A no-op unless the session is handed back in :data:_RESUMABLE_ON_MESSAGE
(awaiting_operator) with a live agent — a handed-back session that outlived a
backend restart has no sandbox to resume, so the operator restarts it instead. The
agent only ever hands back between turns (never with a command still pending), so
continuing re-runs it, folding in any queued goal/chat steering, then drives the
boundary that follows (:func:_advance).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry. |
required |
session_id
|
int
|
The paused retest session to resume. |
required |
Source code in src/revalid/retest_session.py
1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 | |
create_session(session, *, finding_id, model, free_launch=False, deferred=False)
Insert a session row and return it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session. |
required |
finding_id
|
int
|
The finding identity (FR-16) this session retests. |
required |
model
|
str
|
The resolved LLM model string driving the agent. |
required |
free_launch
|
bool
|
Whether the agent's commands auto-run without a per-command
human approval. The gate only ever carries a |
False
|
deferred
|
bool
|
When |
False
|
Source code in src/revalid/retest_session.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | |
end_session(session, registry, session_id)
Operator-initiated end: tear down and mark ended (no-op if already terminal).
Acquires live.lock around the teardown for consistency with
apply_decision's registry-mutating critical section, even though
end_session doesn't touch pending_call_id itself. Cancels any in-flight
turn first (issue #204) so a wedged model call does not leave the run thread
lingering after the session is gone; the row is already ended (terminal), so
the cancelled turn's :class:_TurnAbortedError is swallowed by :func:_fail.
Source code in src/revalid/retest_session.py
1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 | |
is_terminal(status)
Return whether status is one of the terminal retest-session states.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
RetestSessionStatus
|
The status to test. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in src/revalid/retest_session.py
74 75 76 77 78 79 80 81 82 83 | |
load_events_after(session, session_id, after_seq)
Return transcript events with seq > after_seq in order, as plain dicts.
Source code in src/revalid/retest_session.py
140 141 142 143 144 145 146 147 | |
record_verdict(session, session_id, status, rationale, *, actor='agent', reason_code='agentic_conclusion')
Persist a determination on the session row + its transcript events.
Appends the VERDICT event, then a STATE_CHANGE event to
concluded, BOTH before committing the terminal row (event-before-
terminal-status invariant, cf. _fail which already appends its
ERROR event before set_status). The WS stream handler closes on
terminal AND no new events, polling concurrently with this function on
a separate DB session; without this ordering a poll landing between the
event appends and the row commit could observe the terminal status with
one of the events not yet visible, and close the stream without ever
sending it.
The STATE_CHANGE event is required because the frontend derives the
session's displayed status only from the latest such event, not from
polling the row directly — without it, a conclude would leave the UI showing
the pre-verdict status forever. Both terminal producers route here: the agent
concluding fixed/still_open (actor="agent") and the operator
manually concluding a paused session (actor="operator", the only path that
writes inconclusive under ADR-0034).
Source code in src/revalid/retest_session.py
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | |
reopen_session(session, session_id)
Reopen a concluded session so the operator can keep testing (issue #214).
Withdraws the recorded verdict and returns the session to idle, whose wake
path re-provisions the sandbox and continues from the transcript (goal + scope
reconstructed there). The verdict is kept in the transcript — the VERDICT
event plus a new VERDICT_CANCELLED event — which is the append-only audit
for an agentic session (ADR-0025); its row in the queryable verdicts
projection is removed because a withdrawn verdict is no longer a current
determination, so the finding stops showing an outcome the operator retracted.
A pure DB operation (the session is already torn down, so the live registry is
never touched). A no-op unless the session is concluded — so a premature or
duplicate call is safe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
session_id
|
int
|
The concluded retest session to reopen. |
required |
Source code in src/revalid/retest_session.py
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 | |
restart_model(session, registry, session_id)
Abort the in-flight turn and re-run it to unstick a wedged model (issue #204).
The "restart model" console action. Asks the live session to cancel its current
turn and re-run it (:meth:LiveSession.request_restart); a turn_restarted
marker is appended so the transcript shows the operator intervened. A no-op when
the session is not live or no turn is in flight (nothing to unstick) — the
console only offers the action while the agent is working.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry. |
required |
session_id
|
int
|
The retest session whose turn to restart. |
required |
Source code in src/revalid/retest_session.py
1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 | |
resume_session(session, registry, session_id)
Operator resumes a stopped session — Resume (issue #150).
Clears the stopped flag and continues where the pause left off: if a
command was held pending when the operator stopped, the gate re-opens
(awaiting_command, then the free-launch loop drives it if enabled);
otherwise the agent is re-run for its next step. A no-op unless the session
is in STOPPED with a live agent (a stopped session that outlived a backend
restart has no sandbox — the operator restarts it instead).
Source code in src/revalid/retest_session.py
1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 | |
resume_with_message_at_gate(session, registry, session_id)
Steer a command awaiting approval with an operator message (Claude-Code gate).
The message-routing rule's gate case (ADR-0042): a message sent instead of
approving withdraws the pending command and re-runs the agent with the message as
a first-class user turn, then drives the boundary that follows. A no-op if the
session is not live or a concurrent approve/reject already took the command — the
message stays buffered (recorded by :func:submit_message) for the next boundary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry. |
required |
session_id
|
int
|
The retest session whose gate the message steers. |
required |
Source code in src/revalid/retest_session.py
1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 | |
run_agent_step(agent, user_prompt, *, session_id, deps, message_history=None, deferred_tool_results=None, channel=DELTAS, live=None)
Run one agent turn, streaming its live tokens to the console (issue #140).
Replaces agent.run_sync at every step site. The turn's result is
unchanged — same output union, same message history, same deferred-tool
handling — so the orchestrator's state machine is untouched; the only
addition is that tokens are published to channel as they arrive.
What actually streams. This agent's output is structured
(ConcludeOutput) or a gated tool request, and its commands and prose
reach the operator as tool arguments, which the model emits whole rather
than incrementally. What it does stream token-by-token is its reasoning:
measured against a live ollama:qwen3:14b, one turn produced 746 thinking
deltas and zero text or tool-argument deltas. So the reasoning is what the
console shows while a turn is in flight — which is exactly the stretch the
operator previously spent watching a motionless spinner.
Text deltas are forwarded too, for models that narrate in plain parts rather
than a thinking part. Tool-argument deltas are deliberately not: they
arrive as partial JSON ({"rationale": "I will che), and rendering that
would show the operator half-escaped syntax rather than a sentence.
Runs the async stream on its own event loop via :func:_run_cancellable_turn,
which also carries the issue #204 cancel/retry: an operator unstick
(:meth:LiveSession.request_restart) re-runs the same turn from the top, while
a cancel for teardown surfaces as :class:_TurnAbortedError. Step sites are
already background/worker threads with no loop running, and the orchestrator
around them is synchronous — converting the whole state machine to async would
be a far larger change for no behavioural gain here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
RetestAgent
|
The retest agent to run. |
required |
user_prompt
|
str | None
|
The turn's prompt ( |
required |
session_id
|
int
|
The session whose console receives the tokens. |
required |
deps
|
RetestSessionDeps
|
The tool dependencies for this turn. |
required |
message_history
|
list[ModelMessage] | None
|
Prior turns, when continuing a run. |
None
|
deferred_tool_results
|
DeferredToolResults | None
|
Approvals/denials resuming a gated call. |
None
|
channel
|
DeltaChannel
|
The live-token channel (injectable for tests). |
DELTAS
|
live
|
LiveSession | None
|
The live session, when its turn should be cancellable (issue #204).
|
None
|
Returns:
| Type | Description |
|---|---|
AgentRunResult[RetestOutput]
|
The completed run result, exactly as |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the stream ends without producing a run result, which
would otherwise surface as a confusing |
_TurnAbortedError
|
If the turn was cancelled for teardown (not to retry). |
Source code in src/revalid/retest_session.py
851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 | |
session_goal(session, session_id)
Return the session's current goal steps (latest plan_updated), or empty.
Reads the goal back from the transcript so a deferred (idle) session's
Start (issue #150) reconstructs the goal recorded at create time, without any
in-memory carry that a backend restart would lose.
Source code in src/revalid/retest_session.py
159 160 161 162 163 164 165 166 167 168 169 170 | |
session_scope(session, session_id)
Return the session's retest scope (the launch target_set endpoints), or empty.
Source code in src/revalid/retest_session.py
173 174 175 176 177 178 179 | |
set_free_launch(session, registry, session_id, enabled)
Toggle free-launch on a live session (FR-17 Slice 5).
Updates the persisted mode + the live flag, records a free_launch_changed
transcript event, and — when enabling with a command already pending —
auto-approves it (and any that follow) via :func:_advance. A no-op if
the session is not live (already ended/concluded, or never started): there is
nothing to steer once torn down, and the persisted mode is fixed at that point.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry. |
required |
session_id
|
int
|
The retest session to toggle. |
required |
enabled
|
bool
|
The new free-launch state. |
required |
Source code in src/revalid/retest_session.py
1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 | |
set_goal(session, registry, session_id, steps)
Set the user-owned goal on a non-terminal session (FR-17 6b-ii).
Appends a plan_updated transcript event so the "Current goal" panel reflects
the edit (and it replays); when a live agent is attached, the goal is also queued
and delivered to it as a first-class user turn on the next approve/reject
(:func:_resume_with_decision) — pure-queue, never interrupting a run.
The event is emitted for any non-terminal session, live or not: the live orchestration state is process-local, so a session that outlives a backend restart is non-terminal yet has no live agent. Gating the panel update on liveness (the prior behaviour) made such an edit silently vanish — the endpoint still returned 202 but the panel kept the old steps. Emitting regardless keeps the edit visible; queuing stays conditional on a live agent existing to receive it. A no-op only when the session is unknown or already terminal.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry (holds the goal buffer). |
required |
session_id
|
int
|
The retest session whose goal to set. |
required |
steps
|
list[str]
|
The operator's goal steps (replaces the whole goal). |
required |
Source code in src/revalid/retest_session.py
1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 | |
set_status(session, session_id, status)
Move a session to status and record a state_change transcript event.
Source code in src/revalid/retest_session.py
182 183 184 185 186 187 188 189 | |
start_and_step(session, registry, session_id, agent, sandbox, finding_prompt, *, free_launch=False)
Start the sandbox and run the retest agent's first step.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry; a new :class: |
required |
session_id
|
int
|
The already-created ( |
required |
agent
|
RetestAgent
|
The built retest agent (Task 4). |
required |
sandbox
|
Sandbox
|
The not-yet-started sandbox for this session. |
required |
finding_prompt
|
str
|
The user prompt describing the finding to retest. |
required |
free_launch
|
bool
|
Whether the agent's commands auto-run without a per-command human approval (FR-17 Slice 5). The gate only ever carries a command. |
False
|
Source code in src/revalid/retest_session.py
933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 | |
stop_session(session, registry, session_id)
Operator pauses a running session — Stop (issue #150).
A cooperative pause: sets the live stopped flag and moves the row to the
non-terminal STOPPED state, keeping the sandbox alive. A command already
running finishes (its output is recorded) and an in-flight agent step, on
completion, parks in stopped rather than advancing (see
:func:_dispatch_output); the free-launch loop halts (:func:_advance).
A no-op if the session is not live or is already terminal or stopped.
Source code in src/revalid/retest_session.py
1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 | |
submit_human_command(session, registry, session_id, command, *, timeout=MAX_COMMAND_TIMEOUT)
Run a manual operator command (!) in the live session's sandbox (FR-17 Slice 2).
The human's own commands are ungated (single trusted user, ADR-0008) and
run through the same discrete sandbox.exec the agent uses — no shared
PTY (ADR-0026). The command + its result are recorded as a HUMAN_COMMAND
transcript event (so the terminal shows it) and buffered on the live session
so the agent observes it on its next turn.
A no-op if the session is not live (already ended/concluded, or never started) — there is no sandbox to run in once torn down.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry (holds the sandbox + observation buffer). |
required |
session_id
|
int
|
The retest session to run the command in. |
required |
command
|
str
|
The exact shell command the operator submitted (without the |
required |
timeout
|
int
|
Per-command cap in seconds. Defaults to the hard ceiling — the operator ran it deliberately (it may be a slow scan) — and is clamped to that ceiling so even a manual command can never wedge the sandbox. |
MAX_COMMAND_TIMEOUT
|
Source code in src/revalid/retest_session.py
1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 | |
submit_message(session, registry, session_id, text)
Record an operator chat message and buffer it for the agent's next turn (FR-17).
Always recorded as a HUMAN_MESSAGE transcript event (so the chat shows it and
it replays) — even for a session that outlived a backend restart and has no live
agent, so a message is never silently lost (ADR-0042). When the session is live it
is also buffered for delivery to the agent as a first-class user turn at the next
turn boundary (:func:_advance / :func:_resume_with_decision) — the operator's
voice, distinct from the ! command path (:func:submit_human_command).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Active DB session for this call. |
required |
registry
|
SessionRegistry
|
The live-session registry (holds the message buffer). |
required |
session_id
|
int
|
The retest session to message. |
required |
text
|
str
|
The exact operator message. |
required |
Source code in src/revalid/retest_session.py
1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 | |
Transient live-token channel for the FR-17 retest console (issue #140).
The retest transcript (session_events) is the durable, append-only record a
verdict is re-derived from (FR-10). This channel is the opposite: an in-memory,
per-session buffer of the tokens the model emits while it is thinking, so the
console can show the reasoning as it is written instead of a static spinner for
the length of an LLM call.
Deliberately not persisted. A half-finished thought is not evidence, and writing it to the transcript would put text into the audit trail that no verdict was ever derived from. Deltas are dropped as soon as the turn they belong to lands as a real transcript event, and the whole buffer is dropped when the session ends. A reader that misses them (a browser opened mid-turn) loses nothing that matters — the persisted events still tell the whole story.
DeltaChannel
Thread-safe fan-out of live model tokens, keyed by retest session id.
One writer (the agent step running on a worker thread) and one reader (the WebSocket loop on the event loop), so a plain lock is enough; readers pull with a cursor rather than being pushed to, which keeps the WebSocket handler a simple poll and avoids needing an async queue per connection.
Source code in src/revalid/deltas.py
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | |
__init__()
Create an empty channel.
Source code in src/revalid/deltas.py
48 49 50 51 | |
clear(session_id)
Drop a session's buffer — its turn landed, or the session ended.
Source code in src/revalid/deltas.py
88 89 90 91 | |
publish(session_id, chunk)
Append one token chunk for session_id (ignored when empty).
Source code in src/revalid/deltas.py
53 54 55 56 57 58 59 60 61 62 63 | |
read_after(session_id, cursor)
Return the text buffered after cursor, and the new cursor.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
int
|
The session to read. |
required |
cursor
|
int
|
The index returned by the previous call (0 to start). |
required |
Returns:
| Type | Description |
|---|---|
str
|
The concatenated new chunks (empty when there are none) and the |
int
|
cursor to pass next time. Chunks are joined here rather than sent |
tuple[str, int]
|
individually because the console appends them to one growing string |
tuple[str, int]
|
anyway, and one frame per token would flood the socket. |
Source code in src/revalid/deltas.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | |
Verdicts, audit and export
Audit-trail verdict re-derivation (FR-10, NFR-02, ADR-0025, ADR-0030).
A persisted (agentic) verdict must be reproducible from the audit trail alone,
with no re-execution. An agentic verdict (FR-17) is a human-adjudicated judgment
over a whole session, so its audit trail is that session's append-only transcript
(ADR-0025's NFR-02 shift). It re-derives by re-projecting the authoritative
transcript event — the verdict event for the agent's record, the latest
verdict_adjudicated event for an operator adjudication — and confirming the
stored row still equals it (a denormalization-integrity check).
:func:rederive_run recomputes every stored verdict and diffs it against
storage. A clean run proves reproducibility (FR-10 acceptance / NFR-02); any
discrepancy flags a verdict that has drifted from the transcript it was derived
from. (The old FR-04/05/07-09 batch verdict re-derivation was removed with the
batch path in FR-17 6b-iii.)
AuditReport
dataclass
Outcome of re-deriving every stored verdict (FR-10 acceptance).
Attributes:
| Name | Type | Description |
|---|---|---|
total |
int
|
Number of stored verdicts examined. |
reproduced |
int
|
How many re-derived identically to storage. |
discrepancies |
tuple[Discrepancy, ...]
|
The verdicts that did not (empty on a clean run). |
Source code in src/revalid/audit.py
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | |
ok
property
True iff every stored verdict re-derived exactly from its transcript.
Discrepancy
dataclass
A stored verdict whose re-derivation no longer matches the transcript.
Attributes:
| Name | Type | Description |
|---|---|---|
verdict_id |
int
|
The stored verdict's row id. |
finding_id |
int
|
The finding the verdict belongs to. |
stored |
str
|
The stored |
rederived |
str
|
The |
Source code in src/revalid/audit.py
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | |
rederive_run(session)
Re-derive every stored verdict from the audit trail and diff against storage.
Reproduces each agentic verdict from its session transcript (FR-10 AC) — no re-execution. Returns counts and any discrepancies (empty when the transcript fully reproduces the verdicts).
Source code in src/revalid/audit.py
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | |
Versioned, schema-validated run export (FR-12).
A run is the full state a revalidation produces — the uploaded reports, the
findings extracted from them, every retest-plan version, and every
evidence-backed verdict. :func:build_export assembles that state into a single
:class:RunExport: a self-contained, versioned JSON document the evaluation
harness (FR-15) consumes.
The document is versioned by :data:SCHEMA_VERSION — bumped whenever the
export shape changes — so a consumer can tell which contract a file follows.
:func:export_schema emits the JSON Schema the document validates against; it is
generated from these models (never hand-written) and published to
docs/reference/schemas/ via make export-schema, guarded against drift by
tests/unit/test_export.py.
FindingExport
Bases: BaseModel
A persisted finding: current content plus full version history and notes (FR-02/03/16).
finding is the current version's content (the field pre-FR-16 consumers
read); version names which version that is; versions is the complete
append-only history (oldest first, extraction = v1) and notes the
stage-tagged annotation log (chronological) — the audit-grade record (FR-10).
Source code in src/revalid/export.py
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | |
FindingVersionExport
Bases: BaseModel
One immutable version of a finding's content (FR-16).
origin is extraction for version 1 and edit for operator
revisions; edited_by/reason carry the edit lineage.
Source code in src/revalid/export.py
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | |
Generator
Bases: BaseModel
Provenance of the tool that produced the export (NFR-02 lineage).
Source code in src/revalid/export.py
41 42 43 44 45 46 47 | |
NoteExport
Bases: BaseModel
One stage-tagged operator note on a finding (FR-16).
Source code in src/revalid/export.py
63 64 65 66 67 68 69 70 71 72 | |
ReportExport
Bases: BaseModel
An uploaded report and its ingest outcome (FR-01).
Source code in src/revalid/export.py
50 51 52 53 54 55 56 57 58 59 60 | |
RunExport
Bases: BaseModel
A complete revalidation run as one versioned JSON document (FR-12).
Source code in src/revalid/export.py
153 154 155 156 157 158 159 160 161 162 163 164 | |
RunMetrics
Bases: BaseModel
Descriptive counts and timing over the exported run (FR-15 denominators).
Neutral facts about the run, not correctness scores: the tool has no ground
truth, so grading (correct/wrong) is the evaluation harness's job (FR-15).
verdicts_by_status always carries every :class:VerdictStatus key so the
shape is stable across runs.
Source code in src/revalid/export.py
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | |
VerdictExport
Bases: BaseModel
An agentic verdict with its audit stamps (FR-09/FR-10/FR-17).
Every verdict is a retest-session conclusion (the batch verdict path retired
in FR-17 6b-iii): session_id links the session, evidence is the
flexible :class:~revalid.domain.AgenticEvidence proof (or None for a
human adjudication), and actor is "agent"/"operator".
Source code in src/revalid/export.py
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | |
build_export(session, *, generated_at=None)
Assemble the full run — reports, findings, verdicts — for export (FR-12).
Reads every entity in id order (deterministic output) and derives the run metrics. Purely a read: it opens no network and mutates nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
Session over the database to export. |
required |
generated_at
|
datetime | None
|
Stamp for the document; defaults to the current UTC time (injectable so tests and re-runs are deterministic). |
None
|
Returns:
| Type | Description |
|---|---|
RunExport
|
The complete run as a :class: |
RunExport
|
func: |
Source code in src/revalid/export.py
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | |
export_schema()
Return the published JSON Schema the export validates against (FR-12).
Generated from :class:RunExport so the contract can never drift from the
document it describes.
Source code in src/revalid/export.py
282 283 284 285 286 287 288 | |
Evaluation harness: score a run's verdicts against ground truth (FR-15 / NFR-01).
The harness consumes an FR-12 run export (:class:revalid.export.RunExport) and a
ground-truth file — one expected verdict per evaluation-set finding, plus an
ambiguous flag marking findings whose only defensible outcome is
inconclusive. For each ground-truth finding it takes the system's latest
verdict from the export and classifies it:
- correct — the verdict matches the expected verdict.
- inconclusive — the system hedged (returned
inconclusive) where the truth was a definite verdict: a safe miss, not a confident error. - wrong — the system returned a confident verdict that contradicts the
truth. This is the dangerous case; per NFR-01 it counts double in the analysis,
and on an
ambiguousfinding it violates the hard constraint.
:func:evaluate returns an :class:EvalReport with the per-finding rows, the
totals, timing, and the NFR-01 pass decision; :func:format_table renders the
metrics table for the thesis Results chapter. Nothing here touches the network —
scoring is a pure function of the export and the ground truth.
Classification
Bases: StrEnum
How a finding's actual verdict scored against its expected verdict.
Source code in src/revalid/eval.py
83 84 85 86 87 88 89 | |
EvalReport
dataclass
The scored evaluation run (FR-15 metrics table / NFR-01 decision).
Attributes:
| Name | Type | Description |
|---|---|---|
rows |
tuple[EvalRow, ...]
|
One scored row per matched ground-truth finding. |
unmatched_findings |
tuple[str, ...]
|
Export finding titles with no ground-truth entry. |
unmatched_ground_truth |
tuple[str, ...]
|
Ground-truth titles absent from the export. |
Source code in src/revalid/eval.py
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | |
confidently_wrong
property
Count of confident, contradicting verdicts (NFR-01 counts these double).
correct
property
Findings whose verdict matched the expected verdict.
correct_pct
property
Fraction of scored findings that were correct (0.0 when none).
inconclusive
property
Findings where the system safely hedged (returned inconclusive).
mean_elapsed_ms
property
Mean round-trip time across the scored verdicts (0.0 when none).
nfr01_pass
property
NFR-01: ≥70% correct AND no ambiguous finding given a confident verdict.
no_verdict
property
Ground-truth findings that were never retested in this run.
total
property
Number of scored findings.
total_elapsed_ms
property
Total round-trip time across the scored verdicts.
weighted_error
property
Confident errors weighted double, per the NFR-01 analysis rule.
wrong
property
Findings given a confident verdict that contradicts the truth.
wrong_on_ambiguous
property
Confident verdicts on ambiguous findings — the NFR-01 hard-constraint breaches.
EvalRow
dataclass
One scored evaluation-set finding.
Attributes:
| Name | Type | Description |
|---|---|---|
finding |
str
|
The ground-truth finding title. |
expected |
VerdictStatus
|
The expected verdict. |
actual |
VerdictStatus | None
|
The system's latest verdict, or |
ambiguous |
bool
|
Whether the finding is an NFR-01 hard-constraint case. |
classification |
Classification
|
The scoring bucket this finding fell into. |
elapsed_ms |
float
|
Round-trip time of the scored verdict's evidence. |
Source code in src/revalid/eval.py
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | |
confidently_wrong
property
True when the system gave a confident verdict that contradicts truth.
GroundTruth
Bases: BaseModel
The evaluation set's expected verdicts (FR-15).
Attributes:
| Name | Type | Description |
|---|---|---|
target |
str
|
The system under retest the verdicts are expected against (e.g. the pinned vulnerable Juice Shop version). |
source_report |
str
|
Provenance of the pentest report the findings came from. |
findings |
tuple[GroundTruthEntry, ...]
|
One expected verdict per evaluation-set finding. |
Source code in src/revalid/eval.py
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | |
GroundTruthEntry
Bases: BaseModel
The expected verdict for one evaluation-set finding (FR-15 ground truth).
Attributes:
| Name | Type | Description |
|---|---|---|
finding |
str
|
The finding title, matched to the export case-insensitively and
whitespace-normalized (see :func: |
expected |
VerdictStatus
|
The verdict a correct system should return. For an
|
ambiguous |
bool
|
Whether this finding's only defensible outcome is
|
note |
str
|
Free-text rationale for the expected verdict (kept in the report). |
Source code in src/revalid/eval.py
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | |
classify(expected, actual, *, ambiguous)
Score one finding's actual verdict against its expected verdict (NFR-01).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
expected
|
VerdictStatus
|
The verdict a correct system should return. |
required |
actual
|
VerdictStatus | None
|
The system's verdict, or |
required |
ambiguous
|
bool
|
Whether the finding's only defensible outcome is inconclusive
(unused in the logic — an ambiguous entry simply has |
required |
Returns:
| Type | Description |
|---|---|
Classification
|
The scoring bucket: |
Classification
|
match, |
Source code in src/revalid/eval.py
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | |
evaluate(export, ground_truth)
Score every ground-truth finding against the run export (FR-15).
Matches ground-truth entries to export findings by normalized title, takes
each finding's latest verdict, and classifies it. Findings present on only
one side are surfaced (unmatched_*) rather than silently dropped — an
unmatched entry means the ground truth and the run disagree on the finding
set and the score would otherwise be quietly wrong.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
export
|
RunExport
|
The FR-12 run export to score. |
required |
ground_truth
|
GroundTruth
|
The evaluation set's expected verdicts. |
required |
Returns:
| Type | Description |
|---|---|
EvalReport
|
The scored :class: |
Source code in src/revalid/eval.py
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | |
format_table(report)
Render the FR-15 metrics table (correct / wrong / inconclusive, totals, timing).
Source code in src/revalid/eval.py
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | |
ground_truth_skeleton(export)
Build a fill-in-the-blanks ground-truth skeleton from a run export (FR-15 aid).
Emits one entry per export finding, its finding title already keyed so it
matches the run exactly, with expected set to :data:GROUND_TRUTH_TODO for
the author to replace. Because that sentinel is not a valid verdict, an
unfilled skeleton won't load — the author cannot accidentally score a run
against placeholders.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
export
|
RunExport
|
The FR-12 run export to seed the ground truth from. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A |
tuple[str, ...]
|
and shaped like :class: |
tuple[dict[str, Any], tuple[str, ...]]
|
titles that collapse to the same match key (they would collide in scoring, |
tuple[dict[str, Any], tuple[str, ...]]
|
so the author must disambiguate them). |
Source code in src/revalid/eval.py
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 | |
latest_verdict_by_finding(export)
Map each finding id to its latest verdict in the export (highest verdict id).
Source code in src/revalid/eval.py
233 234 235 236 237 238 239 240 | |
load_export(path)
Load and validate an FR-12 run export from disk.
Source code in src/revalid/eval.py
306 307 308 | |
load_ground_truth(path)
Load and validate a ground-truth file (FR-15).
Source code in src/revalid/eval.py
301 302 303 | |
normalize_title(title)
Normalize a finding title to its match key (lowercased, whitespace-collapsed).
The single key both scoring (:func:evaluate) and the ground-truth authoring
aid (:func:ground_truth_skeleton) use to line findings up, so they can never
disagree on what counts as the same finding.
Source code in src/revalid/eval.py
223 224 225 226 227 228 229 230 | |
Corpus chat
FR-18 reports chat: a read-only analytics agent over the report corpus.
A Pydantic AI agent with typed, read-only DB query tools (corpus counts, report list, finding search, finding detail) so it answers natural-language questions about ingested reports, findings, and verdicts with exact data pulled from SQLite — never mutating anything and never launching a retest. It reuses the FR-13 configured backend like every other agent.
The tools are the source of truth, so the agent re-queries the DB on every turn
and only the prose turns of a conversation are persisted
(:class:~revalid.db.ChatSessionRecord / :class:~revalid.db.ChatMessageRecord).
The query functions here are plain, session-taking helpers — unit-testable
without an LLM — that the agent's tools wrap thinly.
CorpusOverview
Bases: BaseModel
Whole-corpus counts (reports, findings, verdicts) for the assistant.
Source code in src/revalid/reports_chat.py
62 63 64 65 66 67 68 69 70 | |
FindingBrief
Bases: BaseModel
A compact finding row (current version) for search results.
Source code in src/revalid/reports_chat.py
83 84 85 86 87 88 89 90 | |
FindingDetail
Bases: BaseModel
Full current content of one finding plus its latest verdict, if any.
Source code in src/revalid/reports_chat.py
101 102 103 104 105 106 107 108 109 110 111 112 113 114 | |
FindingSearch
Bases: BaseModel
A finding search result: the exact total plus a (possibly capped) list.
Source code in src/revalid/reports_chat.py
93 94 95 96 97 98 | |
ReportBrief
Bases: BaseModel
A compact report row for the assistant's report list.
Source code in src/revalid/reports_chat.py
73 74 75 76 77 78 79 80 | |
ReportsChatDeps
dataclass
Runtime dependency injected into the reports agent's tools: a DB session.
Attributes:
| Name | Type | Description |
|---|---|---|
session |
Session
|
The read-only DB session every tool queries through. |
lock |
AbstractContextManager[bool]
|
Serialises the tool bodies that share |
Source code in src/revalid/reports_chat.py
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | |
answer_question(agent, session, chat, question)
Record the user turn, answer it with the read-only agent, persist the reply (FR-18).
The prior turns become the agent's message history; the agent answers by
calling its read-only DB tools over session and the reply is appended to
the thread. The first question also sets the thread title. All in one session,
committed once the reply is stored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[ReportsChatDeps, str]
|
The FR-18 reports agent (a stand-in model in tests). |
required |
session
|
Session
|
The active DB session (shared by persistence and the tools). |
required |
chat
|
ChatSessionRecord
|
The thread to append to. |
required |
question
|
str
|
The operator's new message. |
required |
Returns:
| Type | Description |
|---|---|
ChatMessageRecord
|
The persisted assistant reply row. |
Source code in src/revalid/reports_chat.py
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 | |
build_reports_agent(model=None)
Build the FR-18 reports assistant: read-only corpus-query tools + a prose answer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Model | KnownModelName | str | None
|
A Pydantic AI model instance or name. When omitted, the configured
backend is used ( |
None
|
Returns:
| Type | Description |
|---|---|
Agent[ReportsChatDeps, str]
|
An agent whose output is the plain-text answer to show the operator. Its |
Agent[ReportsChatDeps, str]
|
tools query the DB carried in :class: |
Source code in src/revalid/reports_chat.py
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 | |
corpus_overview(session)
Summarise the whole corpus: report/finding/verdict counts (FR-18).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
An active read-only DB session. |
required |
Returns:
| Type | Description |
|---|---|
CorpusOverview
|
Totals plus per-status / per-severity breakdowns. Verdicts are counted |
CorpusOverview
|
latest-per-finding (see :func: |
Source code in src/revalid/reports_chat.py
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | |
create_chat(session)
Create an empty chat thread and return it (committed).
Source code in src/revalid/reports_chat.py
400 401 402 403 404 405 406 | |
delete_chat(session, chat_id)
Delete a thread and all its messages (committed); a no-op if it's gone.
Source code in src/revalid/reports_chat.py
431 432 433 434 435 436 437 438 | |
find_findings(session, *, query='', severity=None, report_id=None)
Search current findings by keyword / severity / report (FR-18, read-only).
A finding matches when the (case-insensitive) query occurs anywhere in its
title, description, impact, attack vector, endpoints, or reproduction steps —
so "how many findings relate to SQL injection?" is answerable. severity and
report_id further constrain the set.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
An active read-only DB session. |
required |
query
|
str
|
Case-insensitive substring to match; empty matches everything. |
''
|
severity
|
str | None
|
Exact severity to filter by ( |
None
|
report_id
|
int | None
|
Restrict to one report, or |
None
|
Returns:
| Type | Description |
|---|---|
FindingSearch
|
The exact |
Source code in src/revalid/reports_chat.py
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | |
get_finding(session, finding_id)
Return one finding's full current content + latest verdict, or None (FR-18).
Source code in src/revalid/reports_chat.py
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | |
list_chats(session)
Return chat threads, most-recently-updated first.
Source code in src/revalid/reports_chat.py
409 410 411 412 413 414 415 416 417 | |
list_messages(session, chat_id)
Return a thread's messages in insert order (oldest first).
Source code in src/revalid/reports_chat.py
420 421 422 423 424 425 426 427 428 | |
list_reports(session, *, include_archived=False)
List reports newest first; active only unless include_archived (FR-18).
Source code in src/revalid/reports_chat.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | |
stream_answer(agent, session, chat, question)
async
Answer like :func:answer_question but yield the reply's text as it's generated.
Same contract — record the user turn (title on the first), run the read-only
agent over session, append the completed reply — but the reply's tokens are
yielded as they stream from the model so a streaming transport can show the
answer live. Persistence and title-setting happen after the stream drains, so
the stored thread is identical to the blocking path.
This is an async generator so it runs in the request's own event loop: the
sync run_stream_sync binds its worker to the calling thread, which breaks
when a streaming response iterates the generator across threadpool threads, so
the async :meth:~pydantic_ai.Agent.run_stream is used instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
Agent[ReportsChatDeps, str]
|
The FR-18 reports agent (a stand-in model in tests). |
required |
session
|
Session
|
The active DB session (shared by persistence and the tools). |
required |
chat
|
ChatSessionRecord
|
The thread to append to. |
required |
question
|
str
|
The operator's new message. |
required |
Yields:
| Type | Description |
|---|---|
AsyncIterator[str]
|
Successive text deltas of the assistant's reply, in order. |
Source code in src/revalid/reports_chat.py
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 | |
Application and configuration
Persisted, runtime-editable model/provider setting (FR-13, ADR-0021).
A single settings row is the source of truth for LLM backend selection. On
a fresh database it is seeded once from the environment (REVALID_LLM_MODEL /
OLLAMA_BASE_URL) or the local-first default; thereafter the stored row is
authoritative and the environment no longer overrides it.
ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models'
module-attribute
Anthropic model-list endpoint (authenticated differently from OpenAI's).
ANTHROPIC_VERSION = '2023-06-01'
module-attribute
Required anthropic-version header for the Anthropic REST API.
SETTINGS_ID = 1
module-attribute
Primary key of the singleton settings row.
ProbeResult
Bases: BaseModel
Outcome of a provider connection probe / model discovery (ADR-0021).
Source code in src/revalid/settings.py
31 32 33 34 35 36 | |
discover_models(provider, base_url, api_key, *, client=None)
Discover a provider's models, dispatching on its authentication scheme.
anthropic uses the Anthropic model list (:func:probe_anthropic); every
other provider (ollama, openai, or any OpenAI-compatible host) uses
the OpenAI-compatible {base_url}/models endpoint (:func:probe_provider).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
provider
|
str | None
|
The selected provider id ( |
required |
base_url
|
str | None
|
Provider base URL (used by the OpenAI-compatible path). |
required |
api_key
|
str | None
|
Provider API key (required for Anthropic/OpenAI). |
required |
client
|
Client | None
|
Injectable HTTP client (tests pass a |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
ProbeResult
|
class: |
Source code in src/revalid/settings.py
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | |
load_or_seed(session)
Return the current setting, seeding the singleton row on first use.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
An open SQLAlchemy session. |
required |
Returns:
| Type | Description |
|---|---|
Settings
|
The persisted :class: |
Source code in src/revalid/settings.py
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | |
probe_anthropic(api_key, *, client=None)
Discover Claude models from the Anthropic API (ADR-0021).
Anthropic's model list authenticates with x-api-key + anthropic-version
headers rather than OpenAI's bearer scheme, so it needs its own probe. A key
is required — without one this reports unreachable rather than calling out.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api_key
|
str | None
|
The Anthropic API key. |
required |
client
|
Client | None
|
Injectable HTTP client (tests pass a |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
ProbeResult
|
class: |
ProbeResult
|
on any failure (no exception escapes). |
Source code in src/revalid/settings.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | |
probe_provider(base_url, api_key=None, *, client=None)
Probe an OpenAI-compatible provider and list its models (ADR-0021).
Hits {base_url}/models (e.g. Ollama's or OpenAI's OpenAI-compatible
endpoint). This deliberately bypasses the FR-06 allowlist: the LLM host is
infrastructure the operator configures, not a pentest target (ADR-0008).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
The provider base URL (must already include any |
required |
api_key
|
str | None
|
Optional bearer token for hosts that require one. |
None
|
client
|
Client | None
|
Injectable HTTP client (tests pass a |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
ProbeResult
|
class: |
ProbeResult
|
on any failure (no exception escapes). |
Source code in src/revalid/settings.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | |
save(session, *, model, base_url, api_key, clear_key=False)
Persist an updated setting and return it.
The API key is sticky: a blank/None api_key leaves the stored key
unchanged (so the UI never has to re-enter it); clear_key explicitly
removes it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
An open SQLAlchemy session. |
required |
model
|
str
|
The Pydantic AI |
required |
base_url
|
str | None
|
Provider base URL, or |
required |
api_key
|
str | None
|
A new key to store, or blank/ |
required |
clear_key
|
bool
|
When true, delete the stored key. |
False
|
Returns:
| Type | Description |
|---|---|
Settings
|
The persisted :class: |
Source code in src/revalid/settings.py
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | |
SQLite persistence layer via SQLAlchemy 2.0 (ADR-0002).
Single-file zero-ops storage. Findings, retest sessions, verdicts, and the audit trail (FR-10) all live here; only findings exist in the walking skeleton.
Base
Bases: DeclarativeBase
Declarative base for all ORM models.
Source code in src/revalid/db.py
39 40 | |
ChatMessageRecord
Bases: Base
One append-only turn in a reports-chat thread (FR-18).
role is "user" or "assistant"; content is the plain text.
Rows are ordered by id (monotonic insert order). The assistant re-queries
the DB via its read-only tools on every turn, so only the prose is stored — no
tool-call parts — and nothing here is ever mutated or deleted in place.
Source code in src/revalid/db.py
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 | |
ChatSessionRecord
Bases: Base
A persisted reports-chat conversation thread (FR-18).
The read-only reports assistant answers natural-language questions about the
whole corpus (reports, findings, verdicts). A thread is a lightweight
container for an append-only sequence of :class:ChatMessageRecord turns,
persisted so a conversation survives a page reload. title is a short
human label (the first question, truncated) shown in the thread list;
model records the LLM backend that answered (NFR-02 lineage).
Source code in src/revalid/db.py
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 | |
FindingNoteRecord
Bases: Base
One append-only, stage-tagged note on a finding (FR-16, ADR-0024).
Notes are the operator's reasoning trail: free text, tagged with the pipeline
stage it was written on (:class:~revalid.domain.FindingStage) and never
edited or deleted — history is kept, like every other record here.
Source code in src/revalid/db.py
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | |
FindingRecord
Bases: Base
Stable identity of a finding (FR-16, ADR-0024).
The finding's content lives in append-only :class:FindingVersionRecord
rows; this row is the stable handle that :attr:VerdictRecord.finding_id
references, so amending a finding (appending a new version) never orphans its
verdicts. Notes link back via :attr:FindingNoteRecord.finding_id.
Source code in src/revalid/db.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 | |
FindingVersionRecord
Bases: Base
One immutable version of a finding's content (FR-16, ADR-0024).
Extraction/import lands version 1 (origin=extraction); each operator edit
appends a new version (origin=edit). The current version is the highest
version — older ones are kept, never mutated (append-only version history,
FR-16). edited_by/reason capture the edit lineage (FR-10); they stay
None/empty on the extraction version.
Source code in src/revalid/db.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | |
from_domain(finding_id, finding, *, version, origin, edited_by=None, reason='')
classmethod
Build a version row from a domain finding (extraction or an edit).
Source code in src/revalid/db.py
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
to_domain()
Convert this version's content back to a domain finding.
Source code in src/revalid/db.py
146 147 148 149 150 151 152 153 154 155 156 157 158 159 | |
ReportRecord
Bases: Base
An uploaded pentest report and its ingest-job status (FR-01/FR-11).
One row per uploaded PDF; it doubles as the ingest job the UI polls
(:class:~revalid.domain.ReportStatus). model records the LLM backend
used (NFR-02 lineage); error holds the failure message when
status is failed. Its findings link back via
:attr:FindingRecord.report_id.
Source code in src/revalid/db.py
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | |
RetestSessionRecord
Bases: Base
An FR-17 agentic retest session (parent of its append-only transcript).
Mirrors :class:VerdictRecord's finding link (finding_id FK) but tracks a
live, in-progress agent run rather than a concluded outcome: status moves
through :class:~revalid.domain.RetestSessionStatus until a terminal state,
at which point ended_at is set. verdict_status/verdict_rationale
are populated only once the session concludes with a verdict.
Source code in src/revalid/db.py
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | |
SessionEventRecord
Bases: Base
One append-only transcript event for a retest session (FR-17 audit).
The full record of what an agentic session did: each proposed/approved/
rejected command, its output, state transitions, and the final verdict are
all rows here, ordered by seq (monotonic per session, assigned by
:func:revalid.retest_session.append_event) so the transcript replays
deterministically regardless of wall-clock timestamp resolution.
Source code in src/revalid/db.py
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | |
SettingsRecord
Bases: Base
The single-row persisted model/provider setting (FR-13 / ADR-0021).
One row (id == 1) holds the runtime backend selection. The API key is
stored here in the gitignored SQLite file (ADR-0008) but is never returned
by the API (write-only, masked on read).
Source code in src/revalid/db.py
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | |
from_domain(cfg)
classmethod
Build the singleton row from a domain settings object.
Source code in src/revalid/db.py
299 300 301 302 303 304 305 306 | |
to_domain()
Convert this row back to a domain settings object.
Source code in src/revalid/db.py
308 309 310 311 312 313 314 | |
VerdictRecord
Bases: Base
A persisted agentic retest verdict, linked to its finding + session (FR-09/FR-17).
Every verdict is the conclusion of an agentic retest session (the batch
verdict path retired in FR-17 6b-iii): session_id links the session whose
append-only transcript justifies it, and evidence is the flexible
:class:~revalid.domain.AgenticEvidence proof the agent pinned on conclude
(6b-i), or NULL for a human adjudication that ran no command. actor is
"agent" for the auto-persisted conclusion or "operator" for an
adjudication that supersedes it (latest id wins, FR-10 append-only).
Source code in src/revalid/db.py
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | |
agentic(*, finding_id, session_id, status, rationale, actor, reason_code, evidence=None)
classmethod
Build a verdict row for a retest session's conclusion (FR-17).
evidence is the flexible :class:~revalid.domain.AgenticEvidence proof
the agent pinned on conclude (6b-i), or None when unavailable (e.g. a
human adjudication). actor is "agent" for the auto-persisted
conclusion or "operator" for a human adjudication that supersedes it.
Source code in src/revalid/db.py
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | |
create_db_engine(path='revalid.db')
Create the SQLite engine and ensure the schema exists.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Database file path, or :data: |
'revalid.db'
|
Returns:
| Type | Description |
|---|---|
Engine
|
A ready-to-use engine with all tables created. |
Source code in src/revalid/db.py
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 | |
session_factory(engine)
Return a sessionmaker bound to the given engine.
Source code in src/revalid/db.py
427 428 429 | |
FastAPI application factory (ADR-0002: local single-user web app).
Run locally with::
uv run uvicorn --factory revalid.app:create_app --host 127.0.0.1
The app must only ever bind to 127.0.0.1 (NFR-03); there is no authentication in TFG scope.
AdjudicateRequest
Bases: BaseModel
Body for a human verdict adjudication of a concluded session (FR-17 Slice 6a).
status is the human's call — equal to the agent's when accepting, or a
different value when overriding; rationale is their justification.
Source code in src/revalid/app.py
464 465 466 467 468 469 470 471 472 | |
AuditOut
Bases: BaseModel
Result of re-deriving every verdict from the stored audit trail (FR-10).
Source code in src/revalid/app.py
343 344 345 346 347 348 349 | |
BackendStatusOut
Bases: BaseModel
Live LLM-backend reachability + active model, for the sidebar status pill.
Source code in src/revalid/app.py
536 537 538 539 540 541 542 | |
ChatDetailOut
Bases: ChatOut
A chat thread plus its full ordered transcript (FR-18).
Source code in src/revalid/app.py
630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 | |
of(record, messages)
classmethod
Build the thread + messages view from a chat row and its turns.
Source code in src/revalid/app.py
635 636 637 638 639 640 641 642 643 644 645 | |
ChatMessageIn
Bases: BaseModel
Body for posting an operator question to a chat thread (FR-18).
Source code in src/revalid/app.py
648 649 650 651 | |
ChatMessageOut
Bases: BaseModel
One persisted chat turn as returned by the API (FR-18).
Source code in src/revalid/app.py
611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 | |
from_record(record)
classmethod
Build the message view from a persisted chat-message row.
Source code in src/revalid/app.py
619 620 621 622 623 624 625 626 627 | |
ChatOut
Bases: BaseModel
A reports-chat thread summary as returned by the API (FR-18).
Source code in src/revalid/app.py
590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 | |
from_record(record)
classmethod
Build the thread-summary view from a persisted chat row.
Source code in src/revalid/app.py
599 600 601 602 603 604 605 606 607 608 | |
ConcludeRequest
Bases: BaseModel
Body for an operator's manual conclusion of a paused session (ADR-0034).
Source code in src/revalid/app.py
475 476 477 478 479 | |
CvssIn
Bases: BaseModel
An operator-supplied CVSS code on a finding edit (FR-19).
Carries no inferred flag: provenance is not the client's to assert. The
server derives it by comparing against the current version — a value the
operator changed is author-stated, not model-derived.
Source code in src/revalid/app.py
189 190 191 192 193 194 195 196 197 198 | |
DiscrepancyOut
Bases: BaseModel
A stored verdict that no longer re-derives from its evidence (FR-10).
Source code in src/revalid/app.py
334 335 336 337 338 339 340 | |
FindingEditIn
Bases: BaseModel
An operator edit of a finding's content → a new immutable version (FR-16).
Source code in src/revalid/app.py
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | |
to_finding(current)
Build the domain finding for this edit, carrying forward prior lineage.
current is the version being edited. Its raw (extraction lineage)
is kept so editing never discards how the finding was originally produced
(FR-10), and so are its CVSS/ATT&CK values when the payload omits them —
without that, every edit silently wiped the taxonomy an extraction had
derived, because the fields simply were not carried across.
Source code in src/revalid/app.py
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | |
FindingOut
Bases: Finding
A persisted finding as returned by the API — the current version's content.
version is the current version number (extraction = 1); it bumps on every
operator edit (FR-16). id is the stable finding identity verdicts and
retest sessions reference.
Source code in src/revalid/app.py
154 155 156 157 158 159 160 161 162 163 164 | |
FindingVersionOut
Bases: Finding
One immutable finding version as returned by the API (FR-16).
Source code in src/revalid/app.py
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | |
from_record(record)
classmethod
Build the API view from a persisted finding-version row.
Source code in src/revalid/app.py
176 177 178 179 180 181 182 183 184 185 186 | |
FreeLaunchRequest
Bases: BaseModel
Body for the live free-launch toggle (FR-17 Slice 5).
Source code in src/revalid/app.py
446 447 448 449 | |
GoalDraftOut
Bases: BaseModel
A generated retest-goal draft for a finding, pre-session (FR-17 6b-iii-b).
Source code in src/revalid/app.py
458 459 460 461 | |
GoalRequest
Bases: BaseModel
Body for a user-owned goal edit (FR-17 6b-ii).
Source code in src/revalid/app.py
452 453 454 455 | |
HumanCommandRequest
Bases: BaseModel
Body for a manual operator command (!): the exact command to run (FR-17).
Source code in src/revalid/app.py
416 417 418 419 | |
ImportResult
Bases: BaseModel
Outcome of a findings import.
enriched/enrichment_failed are 0 unless the caller asked for the
opt-in FR-19 taxonomy pass (issue #233). A non-zero enrichment_failed
means the import itself succeeded but some findings came back without a
taxonomy — reported rather than swallowed, so a partially-enriched import is
never mistaken for a complete one.
Source code in src/revalid/app.py
482 483 484 485 486 487 488 489 490 491 492 493 494 | |
MessageRequest
Bases: BaseModel
Body for an operator chat message to the agent (FR-17 Slice 4).
Source code in src/revalid/app.py
422 423 424 425 | |
MitreIn
Bases: BaseModel
An operator-supplied MITRE ATT&CK mapping on a finding edit (FR-19).
Source code in src/revalid/app.py
201 202 203 204 | |
NoteIn
Bases: BaseModel
An operator note on a finding, tagged with the stage it was written on (FR-16).
Source code in src/revalid/app.py
271 272 273 274 275 | |
NoteOut
Bases: BaseModel
A persisted finding note as returned by the API (FR-16).
Source code in src/revalid/app.py
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | |
from_record(record)
classmethod
Build the API view from a persisted note row.
Source code in src/revalid/app.py
288 289 290 291 292 293 294 295 296 297 298 | |
ProbeIn
Bases: BaseModel
Probe request: which provider/endpoint (and optional key) to discover from.
provider selects the discovery scheme (anthropic uses the Anthropic
model list; anything else uses the OpenAI-compatible {base_url}/models).
Source code in src/revalid/app.py
578 579 580 581 582 583 584 585 586 587 | |
RejectRequest
Bases: BaseModel
Optional body for a command rejection: the operator's reason (FR-17).
Source code in src/revalid/app.py
410 411 412 413 | |
ReportOut
Bases: BaseModel
A persisted report / ingest job as returned by the API (FR-01/FR-11).
Source code in src/revalid/app.py
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 | |
from_record(record)
classmethod
Build the API view from a persisted report row.
Source code in src/revalid/app.py
511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 | |
ReportPatchIn
Bases: BaseModel
Body for archiving / unarchiving a report (FR-11, #128).
Source code in src/revalid/app.py
530 531 532 533 | |
RetestSessionOut
Bases: BaseModel
A persisted agentic retest session + its transcript as returned by the API (FR-17).
Source code in src/revalid/app.py
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | |
from_record(record, events)
classmethod
Build the API view from a session row and its ordered transcript events.
Source code in src/revalid/app.py
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | |
RetestSessionSummary
Bases: BaseModel
A compact retest-session row for a finding's session list (FR-17 6b-iii-b).
Source code in src/revalid/app.py
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | |
from_record(record)
classmethod
Build the compact list-row view from a full session record.
Source code in src/revalid/app.py
398 399 400 401 402 403 404 405 406 407 | |
SessionEventOut
Bases: BaseModel
One append-only transcript event as returned by the API (FR-17).
Source code in src/revalid/app.py
352 353 354 355 356 357 | |
SettingsOut
Bases: BaseModel
Public view of the model/provider setting; the key is write-only (ADR-0021).
Source code in src/revalid/app.py
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 | |
from_domain(cfg)
classmethod
Build the masked view: the key becomes a boolean + last-4 hint only.
Source code in src/revalid/app.py
555 556 557 558 559 560 561 562 563 564 | |
SettingsUpdateIn
Bases: BaseModel
Settings update payload; a blank api_key keeps the stored one (ADR-0021).
Source code in src/revalid/app.py
567 568 569 570 571 572 573 574 575 | |
StartSessionRequest
Bases: BaseModel
Optional body for starting a session: free-launch + seed goal (FR-17 Slice 5).
Source code in src/revalid/app.py
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 | |
VerdictOut
Bases: BaseModel
An agentic verdict as returned by the API (FR-09/FR-17).
Every verdict is a retest-session conclusion (the batch verdict path retired
in FR-17 6b-iii); the shape mirrors the FR-12 :class:~revalid.export.VerdictExport.
Source code in src/revalid/app.py
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | |
from_record(record)
classmethod
Build the API view from a stored verdict row.
Source code in src/revalid/app.py
318 319 320 321 322 323 324 325 326 327 328 329 330 331 | |
create_app(db_path='revalid.db', engine=None)
Build the application with its own database engine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db_path
|
str
|
SQLite file backing this instance; ignored when |
'revalid.db'
|
engine
|
Engine | None
|
Pre-built engine to use instead of opening |
None
|
Returns:
| Type | Description |
|---|---|
FastAPI
|
The configured FastAPI application. |
Source code in src/revalid/app.py
2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 | |
get_extraction_agent(settings)
Yield the FR-03 extraction agent built from the persisted setting (ADR-0021).
Source code in src/revalid/app.py
669 670 671 | |
get_goal_agent(settings)
Yield the FR-17 retest-goal agent built from the persisted setting (ADR-0021).
Source code in src/revalid/app.py
664 665 666 | |
get_metadata_agent(settings)
Yield the FR-03 document-metadata agent built from the persisted setting (#133).
Source code in src/revalid/app.py
674 675 676 | |
get_reports_agent(settings)
Yield the FR-18 read-only reports assistant built from the persisted setting.
Source code in src/revalid/app.py
694 695 696 | |
get_retest_agent(settings)
Yield the FR-17 agentic retest agent built from the persisted setting (ADR-0021).
Source code in src/revalid/app.py
689 690 691 | |
get_sandbox_factory()
Yield the production sandbox factory: a fresh egress-locked Docker sandbox per session.
The returned factory is bound to the retest session's id (which scopes the
sandbox's internal Docker network, FR-06). Tests override this with a factory
that returns a :class:~revalid.sandbox.FakeSandbox, so the HTTP flow runs
without Docker.
Source code in src/revalid/app.py
699 700 701 702 703 704 705 706 707 | |
get_settings_dep(request)
Load the persisted model/provider setting, seeding a fresh DB (ADR-0021).
Source code in src/revalid/app.py
654 655 656 657 658 | |
get_taxonomy_agent(settings)
Yield the FR-19 opt-in taxonomy-enrichment agent (issue #233).
Constructed per request like every other agent, but only invoked when the
caller passed enrich — building it costs no model call, so the LLM-free
doors stay LLM-free by default (ADR-0021 for the backend selection).
Source code in src/revalid/app.py
679 680 681 682 683 684 685 686 | |
run_conclude(sessions, registry, session_id, status, rationale)
Record the operator's manual conclusion + tear down (ADR-0034 background task).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The session to conclude. |
required |
status
|
VerdictStatus
|
The operator's determination. |
required |
rationale
|
str
|
The operator's justification. |
required |
Source code in src/revalid/app.py
1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 | |
run_continue(sessions, registry, session_id)
Resume a paused session (ADR-0034 "Keep going", background task).
Runs in the background because resuming drives further agent turns. A no-op
unless the session is parked in awaiting_operator with a live agent
(needs_guidance folded into that state in ADR-0042).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The paused retest session to resume. |
required |
Source code in src/revalid/app.py
1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 | |
run_decision(sessions, registry, session_id, approved, reason, command_id)
Resume a paused session with the operator's decision (FR-17 background task).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The retest session to resume. |
required |
approved
|
bool
|
Whether the pending command was approved. |
required |
reason
|
str
|
Optional operator reason (surfaced to the model on rejection). |
required |
command_id
|
str
|
The |
required |
Source code in src/revalid/app.py
993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 | |
run_extraction(sessions, report_id, data, agent, metadata_agent, extractions)
Extract findings from an uploaded PDF and persist them (FR-01/FR-03/FR-11).
Runs as a FastAPI background task — a sync function Starlette dispatches to
its threadpool, so it must open its own session (the request session is
already closed once the 202 was sent) and it never blocks the event
loop. The report is always moved out of extracting: to ready with its
findings persisted, failed with the error recorded, or — when the operator
stopped it (issue #205) — cancelled, so the UI's status poll always
terminates.
Cancellation (issue #205): a Stop or delete interrupts the single in-flight
model call via extractions. Because extraction is one whole-document call, a
Stop lands cancelled with no findings; a report delete flags "deleted" so
this persists nothing into the row being removed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
report_id
|
int
|
The |
required |
data
|
bytes
|
The uploaded PDF bytes. |
required |
agent
|
Agent[None, list[ExtractedFinding]]
|
The extraction agent (a stand-in model in tests). |
required |
metadata_agent
|
Agent[None, ReportMetadata]
|
The document-metadata agent (a stand-in model in tests). |
required |
extractions
|
ExtractionRegistry
|
The process-local extraction cancel registry. |
required |
Source code in src/revalid/app.py
749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 | |
run_first_step(sessions, registry, session_id, agent, make_sandbox, finding, goal_agent, initial_goal=None, target_endpoints=None)
Build the sandbox and run the retest agent's first step (FR-17 background task).
Runs as a FastAPI background task — a sync function on Starlette's threadpool
— so it opens its own session (the request session closed with the
202). It must let no exception escape: make_sandbox may raise
(SandboxUnavailableError when the sandbox extra is absent, or a real
Docker error) and :func:~revalid.retest_session.start_and_step calls
sandbox.start() outside its own guard, so both are wrapped here. On any
failure the sandbox (if one was created) is best-effort torn down and the
session is settled to error — never stranded in working.
It also seeds the goal (FR-17 6b-ii): a caller-supplied initial_goal
(a goal drafted before the session started, FR-17 6b-iii-b) is used verbatim;
otherwise the goal agent generates a generic retest goal. Either way the
result is emitted as the initial plan_updated (the "Current goal" panel)
and prepended to the agent's prompt. Goal generation is best-effort — a
failure degrades to an empty goal, never blocking start.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The already-created ( |
required |
agent
|
RetestAgent
|
The built retest agent (a stand-in model in tests). |
required |
make_sandbox
|
SandboxFactory
|
The session-scoped sandbox factory. |
required |
finding
|
Finding
|
The finding to retest — the agent's goal is derived from it. |
required |
goal_agent
|
Agent[None, GeneratedGoal]
|
The FR-17 goal agent (a stand-in model in tests). |
required |
initial_goal
|
tuple[str, ...] | None
|
A pre-start goal drafted by the user (FR-17 6b-iii-b).
|
None
|
target_endpoints
|
tuple[str, ...] | None
|
The launch-time retest scope (FR-17) — the exact URL(s) the agent may hit; defaults to the finding's endpoints when omitted. |
None
|
Source code in src/revalid/app.py
908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 | |
run_free_launch(sessions, registry, session_id, enabled)
Toggle free-launch on a session (FR-17 Slice 5 background task).
Runs in the background because enabling may drive the auto-approve loop (successive agent turns). A no-op if the session is no longer live.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The retest session to toggle. |
required |
enabled
|
bool
|
The new free-launch state. |
required |
Source code in src/revalid/app.py
1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 | |
run_goal(sessions, registry, session_id, steps)
Set the user-owned goal on a session (FR-17 6b-ii background task).
Source code in src/revalid/app.py
1037 1038 1039 1040 1041 1042 | |
run_human_command(sessions, registry, session_id, command)
Run a manual operator command (!) in the session's sandbox (FR-17 background task).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The retest session to run the command in. |
required |
command
|
str
|
The exact shell command the operator submitted (without the |
required |
Source code in src/revalid/app.py
1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 | |
run_message(sessions, registry, session_id, text, agent, make_sandbox)
Deliver an operator chat message to the one agent — the Claude-Code model (#163).
The chat is the lifecycle control: talking to a parked agent is how you start or continue it, so there is no separate Resume/Wake/Keep-going button and no second voice (ADR-0042 removed the parallel read-only Q&A). What the message does depends only on where the session is:
idle— provision the sandbox and run the first turn, message folded into the opening prompt.awaiting_operator— the agent handed back (a reply, a guided report, "I've exhausted my options"); resume it with the message.stopped— resume the paused session with the message.awaiting_command— a command awaits approval; the message withdraws it and steers the agent (Claude Code's "type at the permission prompt").working— the agent is mid-turn; the message is queued and the same agent answers it at the next turn boundary (:func:~revalid.retest_session._advance).- terminal — a finished session cannot be messaged; a no-op.
Whether the message is a question or an instruction is the agent's call: it
answers with the ungated respond tool and acts with the approval-gated
run_command, so waking it can never execute anything the operator has not
approved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The retest session to message. |
required |
text
|
str
|
The exact operator message. |
required |
agent
|
RetestAgent
|
The built retest agent, to wake an |
required |
make_sandbox
|
SandboxFactory
|
The session-scoped sandbox factory, likewise. |
required |
Source code in src/revalid/app.py
1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 | |
run_regenerate_goal(sessions, registry, session_id, goal_agent, finding)
Regenerate + set the goal for a session (FR-17 6b-ii background task).
Source code in src/revalid/app.py
1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 | |
run_reopen(sessions, session_id)
Reopen a concluded session so testing can continue (issue #214 background task).
A pure DB op (the session is already torn down); the operator wakes the now-idle session to re-provision the sandbox and continue.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
session_id
|
int
|
The concluded session to reopen. |
required |
Source code in src/revalid/app.py
1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 | |
run_restart_model(sessions, registry, session_id)
Abort + re-run the in-flight turn to unstick a wedged model (issue #204).
Runs as a background task: it cancels the current turn from a fresh thread while the wedged turn's own thread re-runs it. A no-op when nothing is in flight.
Source code in src/revalid/app.py
1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 | |
run_resume(sessions, registry, session_id)
Resume a stopped session — Resume (issue #150, background task).
Runs in the background because resuming may drive further agent turns.
Source code in src/revalid/app.py
1329 1330 1331 1332 1333 1334 1335 | |
run_start(sessions, registry, session_id, agent, make_sandbox)
Provision the sandbox and run the first step of an idle session (issue #150).
The deferred-start counterpart of :func:run_first_step: a Restart opened
the session idle with its goal + scope already recorded, so this reads them
back from the transcript (never re-recording — the target_set invariant is
"emitted once") and drives the first agent turn. A no-op unless the session is
still idle, so a double-click Start cannot double-provision. Lets no
exception escape: on any failure the sandbox is torn down and the session
settles to error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sessions
|
sessionmaker[Session]
|
The app's session factory (each task opens a fresh session). |
required |
registry
|
SessionRegistry
|
The process-local live-session registry. |
required |
session_id
|
int
|
The |
required |
agent
|
RetestAgent
|
The built retest agent (a stand-in model in tests). |
required |
make_sandbox
|
SandboxFactory
|
The session-scoped sandbox factory. |
required |
Source code in src/revalid/app.py
1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 | |
run_stop(sessions, registry, session_id)
Pause a running session — Stop (issue #150, background task).
Source code in src/revalid/app.py
1323 1324 1325 1326 | |