Reference for controlling the visual evidence every run leaves behind, and why the pipeline is shaped this way.
Every run leaves visual evidence behind: redacted, step-synchronized frames
you can open beside the verdict, and — on request — a GIF of the whole
execution. This page is the reference for controlling that, followed by the
design record for why the pipeline is shaped the way it is.
Capture density and GIF assembly are execution-time choices, available on both
record and run:
Flag
Default
What it does
--recording-detail full
yes
A frame before each step, after each step, and on failure.
--recording-detail low
Initial state, every fifth completed step, and final state.
--recording-detail off
No screenshots and no GIF.
--video
off
Assemble the captured frames into a recording.gif.
--highlight-cursor
off
Draw a visible cursor and a bright click halo into the frames.
# Default: keep screenshots and skip GIF assembly.flowproof run checkout.flow.yaml# Opt in to GIF/video assembly.flowproof run checkout.flow.yaml --video# Initial state, every fifth completed step, and final state.flowproof run checkout.flow.yaml --recording-detail low# Add a visible cursor and bright click halo at every pointer action.flowproof run checkout.flow.yaml --highlight-cursor# Fastest path: no screenshots and no GIF.flowproof run checkout.flow.yaml --recording-detail off
--recording-detail full remains the screenshot-density default, while GIF
assembly is disabled unless --video is present. low deliberately lets
several steps share one visual checkpoint, producing a less fluent but much
cheaper review artifact. off changes artifacts only: the same actions,
assertions, redaction-independent safety checks, and verdict still execute.
--video is independent of detail, so low --video creates a sparse
animation. --highlight-cursor adds a pre-action checkpoint for
click, right-click, double-click, and hover actions; drag actions mark both
ends. The bright halo appears on the event checkpoint, while later frames
retain the cursor at its last known position. Because the cursor is rendered
after redaction, it cannot reveal pixels hidden by a mask. Pointer checkpoints
are retained in low-detail mode so important clicks are not lost.
The SDK and the MCP tools take the same three controls with the same
defaults, so an agent does not have to shell out to the CLI to choose them:
from flowproof import Flowflow = Flow("checkout.flow.yaml")flow.record(recording_detail="low")flow.run(video=True, highlight_cursor=True)
report.html (generated from result.json) carries a step-synchronized
viewer: the step table is clickable, showing that step's frames
(before/after, failure frame highlighted). Self-contained — frames are
referenced relatively from the bundle, with no external resources. This is
the "jump to the assert step" experience, driven entirely by the structured
timeline, never by scrubbing.
Two things worth knowing before you read a bundle, both detailed below:
frames are named by capture offset and content hash rather than step number
(Artifact bundle layout), and masks are applied in memory before any PNG is
encoded, so there is no unredacted intermediate to clean up (Redaction).
flowproof's primary human interaction is oversight: reviewing and approving
agent-authored and agent-healed tests. The visual recording of an execution
is therefore a first-class review surface, not a debug artifact. The bar:
A reviewer can watch exactly what a test did, jump to any step directly,
and never see data that should have been masked.
Principles this design enforces:
One source of truth. Step→time mappings live inside the artifact that
already describes the steps of that execution — the trace for the
recording (authoring) run, result.json for replay runs. No side-channel
timing files that can drift.
Structured data is the machine surface; video is the human surface.
Agents reason over JSON and (later) scene-graph snapshots. Nothing
programmatic ever parses pixels. The visual timeline is generated from
the same run data as an additional view.
Redaction is part of the capture path. Frames are masked before they
are persisted, by the same rule set that will govern stored screenshots.
There is no unredacted intermediate on disk, and no post-processing step
that could be skipped.
Both record and run execute the same step loop against an AppDriver.
This design introduces one shared component, the RunRecorder, that both
loops drive:
step loop ──▶ RunRecorder ├─ FrameSource (where pixels come from) ├─ Redactor (masks applied in-memory, pre-persist) └─ Bundle (frames on disk + timeline entries)
Per step, the executor tells the RunRecorder step_started(id) /
step_finished(id); the RunRecorder captures and timestamps frames and
produces a Timeline: for each step, {start_ms, end_ms} offsets from the
execution start, plus the persisted frame offsets falling in that range.
Timestamps are captured once, by the RunRecorder — the executor and the
recorder cannot disagree, because the executor doesn't keep its own clock.
FrameSource abstraction (in flowproof-driver): produces timestamped
raw frames. Two implementations planned; both feed the identical
redact→persist path, so upgrading capture never touches sync or redaction:
v1 — keyframe source (shipped): captures a full frame before each
step, after each step, and on failure, via the driver:
Web: Tab::capture_screenshot (already available in headless_chrome).
Windows: GDI BitBlt screen grab behind the existing Capture trait
(deliberately simple; correctness over frame rate).
Keyframes make step sync exact by construction and keep the bundle
small. The visual result is a step-synchronized filmstrip, not 30fps
video — an accepted v1 tradeoff, stated in the artifact format so
consumers can distinguish it. With --video, the bundle also carries a
ready-to-play recording.gif: the keyframes as one animation, each frame
shown for the real gap to the next (clamped to stay watchable), so a whole
run reviews like a video without continuous capture.
Later — continuous source (follow-up PRs): DXGI desktop duplication on
Windows, CDP screencast for web, feeding the same sink at N fps and
assembled into WebM. The bundle format below already carries a format
discriminator (filmstrip/1 now, webm/1 later) so this lands without
schema changes.
The user-facing controls over this pipeline are documented under Controlling
what a run records, above.
Each execution's bundle is self-contained (stateless; safe for future
parallel runs):
.flowproof/runs/<run-id>/ # replay bundle (exists today) result.json # + recording + per-step timing (§6) report.html # + step-synchronized viewer recording/ frame-<offset_ms>-<sha256-8>.png # redacted BEFORE write; content-named.flowproof/recordings/<trace_id>/ # authoring bundle, referenced by trace recording/frame-...png # same layout, same code path
Frame files are named by capture offset + content hash, so the mapping from
timeline entry → file is derivable from the structured data alone and files
are tamper-evident. There is deliberately notimeline.json in the
bundle: the timeline lives in result.json / the trace (§5–6).
Rationale for putting timing in the trace rather than a sidecar: the trace
is already the single reviewed, diffed, healed artifact; its schema is the
one place a step and its evidence can't drift apart.
Redaction is the single implementation for all persisted pixels (video
frames now, trace screenshots when they land):
Rules (flowproof-driver::redact): {target, mode} where target is
a selector (css / automation_id) or a fixed rect, mode: mask (solid
fill). Declared in the spec under redact:, copied into the trace header
at record time (§5).
Automatic, non-optional rule: password fields are always masked —
web input[type=password], UIA IsPassword elements — regardless of
spec. Not configurable off.
Application point: the RunRecorder resolves rule targets to screen
rects via the driver at capture time (elements move; rects are resolved
per frame batch, not once) and fills them in the in-memory frame before
the PNG is encoded. No unmasked bytes ever reach disk.
Fail closed: if a rule's target cannot be resolved while the element
is known to be on screen, the affected frames are dropped (not persisted
unmasked) and the timeline entry records frames_dropped: "redaction".
Heal already produces a proposed trace whose steps are diffed against the
original by position with per-field changes. Because every authoring
execution gets its own self-contained bundle keyed by trace_id, a healed
trace that is re-authored gets a new trace_id + new bundle while the
original keeps its own. A before/after review view is then pure composition:
for each changed step id, show old trace bundle[step range] beside
new trace bundle[step range] — both sides already exist with exact
step-time mappings. Nothing in this design (content-addressed frames,
per-trace bundles, step-keyed ranges) needed rework for that; heal now
renders exactly this composition as a self-contained <name>.heal.html
next to the trace (path surfaced as diff_html in the heal report).
Sync correctness: mock FrameSource emitting deterministic frames;
assert every step's [start_ms, end_ms] brackets exactly its frames and
ranges are monotonic and non-overlapping.
Redaction proof: capture against the web greeter page extended with a
password field + a css-masked region; decode the persisted PNGs and
assert the masked rects are uniform fill (and the same test passes through
the real browser path in the ubuntu live-app CI job). A second test proves the
fail-closed path drops frames when a rule can't resolve.
Schema conformance: extended fixture trace with recording +
redaction blocks validates against the updated JSON Schema; round-trip
stability as usual.
Playback UI beyond the report viewer; continuous-capture sources
(DXGI/screencast) and video-file assembly; any agent-facing video parsing
(never planned). The healing diff view was scoped here as a seam only; it has
since been built, and §8 describes what shipped.