Research / Effects, recovery and replay

P18 · full · draft

Time Travel over Typed Effects: Backwards Replay, Forward Replay and Execution Bisect

partial full Effects, recovery and replay

State of the work. Step-back, forward replay, bisect and the MCP verbs are shipped on the Python tier with 49 tests; no overhead or user-study data, and inverse correctness never reaches the bisect report.

This is a draft, not peer reviewed. Repository references link to revl at commit 45bec431f.

Planned to be combined with P19 (Counterfactual Incident Replay: Asking What a Different Policy or Action Would Have Changed, With Zero Live Effects by Import Shape), P20 (Replaying Agent Runs: A Write-Ahead Log That Seals Each Model Decision and States Which Replay Modes the Log Can Support), P21 (Branching Agent Sessions With Rewound Side Effects: Scope-Gated Rewind and a Frozen Parent) into a single paper.

Pitch

A language that requires every effect to declare an inverse, or be classed as an emission, already holds the data for a reverse debugger. The checker verifies an inverse's presence and shape, not its correctness, and the honest version of that debugger makes one claim: the inverses ran newest first, and nothing about state being restored.

Abstract

Reverse debuggers usually capture every memory write or require an invertible language. We observe that a runtime with inverse-or-emit effects already keeps, per live component, an ordered accumulator of registered inverses and a compiler-derived classification of which steps cannot be inverted. We build time travel on that accumulator in revl's Python reference tier: a captured timeline of effects, provisions, emissions, compensations and boundaries; step-back, which runs the registered inverses newest first down to a chosen step while leaving the component alive; forward replay, which re-invokes the provided-service calls that produced the tail; and bisect, a binary search over the timeline for the first step at which a predicate flips, in at most ceil(log2 N) + 2 evaluations (the test checks this on one 32-insert timeline). Each inverse is wrapped in a single-flight cell shared with the runtime's own teardown, so an early step-back and a later unload run each inverse once and unload still reports no residue. The system states one guarantee, a guarantee field carried in its step-back, timeline and bisect reports, and refuses by default to unwind across an uncompensated emission. We state what it cannot promise: an inverse is not an undo of the world, compensation is not inversion, forward replay re-runs rather than restores, and bisect trusts the registered inverses and labels its answer unverified. The engine has 49 tests, 13 of them against the real runtime. We discuss the design, the refusals, and the gap between a replay viewport and a state-restoration claim.

Problem and claim

Stepping back normally costs a full trace or an invertible language. Debuggers that offer reverse execution rarely say what they do not restore, and tools that undo effects on a live system risk reporting success while a committed external write still stands.

The claim has two parts. First, a runtime whose effects register inverses in a LIFO accumulator (G4, G7 in the docs) gets partial unwinding, inspection of the unwound prefix, and a log-time search for free, without changing emitted code. Second, the useful contribution is the discipline: a two-sentence guarantee, a refusal that moves irreversibility from a footnote to a decision, and a bisect report that carries its own trust bound.

This is not a claim that stepping back restores program state, and not a claim about the other five tiers.

Contributions

  1. Step-back over the inverse accumulator newest first, leaving the component live, with a single-flight cell per inverse shared with teardown so unload after step-back remains residue-free (docs/replay.md:61-64,82-91; test_real_cordis_step_back_then_unload_still_leaves_no_residue, tests/test_replay.py:929).
  2. A two-sentence guarantee, replay.GUARANTEE, returned in the guarantee field of reports, with a test that no step-back report key contains "restor" (backends/python/replay.py:256-260; tests/test_replay.py:697).
  3. A refusal rule: step-back refuses by default when the range contains an uncompensated emission, refuses totally (nothing runs), and force is the explicit override; compensation is reported as its own step kind and appends a new emission step (docs/replay.md:130-177; tests/test_replay.py:445,474,946,963).
  4. Forward replay that re-invokes provided-service calls and reports activation-body steps as notReplayable instead of faking them (docs/replay.md:179-202; tests/test_replay.py:522,540).
  5. Execution bisect: a git-bisect-style search for the first step at which a predicate over the inspect view flips, with a report that carries a verified or unverified status for the effects on the path (backends/python/replay.py:853-1000; tests/test_replay.py:585-683; docs/replay.md:297-349).
  6. Trace capture that the docs say leaves emitted output byte-identical (the golden files are cited, not opened here): a wrapper around each component's apply passes a delegating context (docs/replay.md:215-232).

Evidence on main

Tree: revl main at commit 45bec431f.

Claimpath:line or testStatusNote
Timeline, inspect, step_back, replay_forward operationsdocs/replay.md:54-66; backends/python/replay.py:825 (inspect), backends/python/replay.py:1087 (step_back); src/revl/mcp/session.py:9209 (bisect), src/revl/mcp/session.py:9245 (replay_forward; backends/python/replay.py has no such method)shippedPython tier only
Two-sentence guarantee, no "restor" report keybackends/python/replay.py:256-260; tests/test_replay.py:697shippedChecks report keys, not values
LIFO order and once-only shared with teardowndocs/replay.md:82-91; tests/test_replay.py:929shippedThe "sharpest result" per the doc (docs/replay.md:380-383); needs real cordis-py, so skipped without it
Refusal across uncompensated emission; forced overridetests/test_replay.py:445,946shipped
Compensation appends an emission, never reported as inversetests/test_replay.py:474,963shippedClassified by source adjacency of lines (docs/replay.md:240-247), a stated emitter-shape assumption
Forward replay re-runs service calls; activation-body steps notReplayabletests/test_replay.py:522,540,556,1003shipped
Bisect in at most ceil(log2 N) + 2 evaluationstest_bisect_finds_the_flip_in_log2_evaluations (tests/test_replay.py:585)shippedOne 32-insert timeline (tests/test_replay.py:585-612); the bound is a test on one N, not a general result
Bisect edge cases (never flips, flips at 0, empty, bad predicate, no mutation)tests/test_replay.py:648-683shipped
MCP verbs revl_timeline, revl_inspect_step, revl_step_back, revl_replay_forward, revl_replay_bisectsrc/revl/mcp/server.py:5280-5372; docs/mcp-bridge.md:549-564; tests/test_replay.py:1043shippedrevl_step_back with no arguments is a separate session-undo (docs/replay.md:270)
CLI trace capture with :timeline, :back, :bisectdocs/replay.md:284-295; src/revl/cli/parser.py:1587 (the capture flag)shipped (docs)The :timeline, :back and :bisect verbs were not opened
Capture leaves emitted output unchangeddocs/replay.md:217-219documentedGolden files are the stated evidence; not opened
Timers are revertible coeffects (every/after acquire a schedule whose inverse is cancellation)docs/time-coeffect.md:30-69; test_unload_cancels_an_every_timer_no_residue_no_orphan_firing (tests/test_time_coeffect.py:219)shippedTimers run on py, ts, go and rust; java and wasm refuse timer (docs/time-coeffect.md:239-240 Scope, :270-277 wasm)
Clock is a harness-advanced coeffectdocs/time-coeffect.md:93-115shippedbackends/python/replay.py has no reference to the clock or timers, so replay and the clock are two mechanisms; their integration is not shown
Random as a declared coeffect for replaynone foundnot foundRandomness in src/revl/fault.py is generator seeding for tests, not a replay coeffect
Bisect reports verified status from verified effect markersbackends/python/replay.py:974-998partialbackends/python/replay.py:874-875, :978-979 and src/revl/mcp/server.py:5353-5354 say verified effect "is not built", which is stale: src/revl/fault.py:1550-1600 and run_roundtrip_units (src/revl/fault.py:2208) build the round-trip runner (9 tests in tests/test_verified_method_effect_1897.py). The gap is that no marker reaches replay steps; backends/python/replay.py reads verified only at :984, so status is always unverified
Multi-generation replay across revl_swap; other tiersdocs/replay.md:407-412not shippedStated as uncovered

Numbers that exist

Paper outline

  1. Introduction: reverse execution without a trace, and the cost of overclaiming restoration.
  2. Background: inverse-or-emit effects, the LIFO accumulator, compensation (A5).
  3. Model: step kinds, timeline, the four operations.
  4. The guarantee and its refusals: the two sentences, force, compensation versus inversion.
  5. Forward replay and what it cannot re-enter.
  6. Bisect: predicate scope, complexity, monotonicity, unverified status.
  7. Trace capture without changing emitted code; classification assumptions.
  8. Evaluation: overhead, scale, case study or user study (to be run).
  9. Related work.
  10. Limits and the WAL-based, cross-process half (see P19).

Threats to validity and limits

Fact-check

Claims in this draft were checked against revl main at commit 45bec431f: 30 checked, 13 corrected.

Cite this draft

@misc{revlp18,
  author       = {Thomas Moussajee},
  title        = {Time Travel over Typed Effects: Backwards Replay, Forward Replay and Execution Bisect},
  year         = {2026},
  howpublished = {\url{https://inso1337.github.io/revl/research/P18.html}},
  note         = {Research draft, not peer reviewed. Written against revl commit 45bec431f. Licensed CC BY 4.0.}
}

Text licensed CC BY 4.0, author Thomas Moussajee.

← All research drafts