Venom
RFC-0021venom-spec

Checkpoints and rollback in venom

Field Value
Status Accepted
Accepted 2026-11-09, authorised in advance by the maintainer for the unattended run that wrote it
Authors commixon (commixon), with Claude
Created 2026-11-09
Issue venom-org/venom-spec#114; implementation venom-org/venom#155 (capture), #156 (rollback), #157 (conversation), #158 (TUI)
Amended 2026-11-10 (venom-org/venom-spec#141, compatible addition): section 8, claude-code’s rewind row: a take-over of a rewind fork that has not started is refused while fork_at is set, and fork_source ends as RFC-0019 section 4 says; “What RFC-0019 gains” adds the resume record’s fork_at
Supersedes None; adds the rewind operation and capability to RFC-0019, which said “checkpoints are P4”
Superseded by None

Summary

venom records the state of an agent’s files as checkpoints: git commits that no branch holds, kept under refs/venom/checkpoints/, taken before each turn, after each turn and on request, without touching the agent’s branch or anyone’s index. Rollback restores a chosen checkpoint’s files and, where the harness can, the conversation as it stood then. This RFC defines when a checkpoint is taken, what it holds, where it lives and when it goes, how a rollback runs in a worktree and in a tree shared with a person, which states refuse it, how it relates to agent.fork, and the verbs, events, grants and error codes. venom implements it. Harness adapters implement one new operation.

Motivation

An agent that makes a mess today can only be stopped. RFC-0019 dropped t3code’s rollback for v1, and venom-org/venom#155 to #158 are blocked until this is written. Three facts shape the design:

  • Most turns change files through commands. sed -i, formatters and code generators leave no tool call a harness could undo. Claude Code’s own file checkpointing doesn’t undo what a Bash command did, and Codex has nothing similar, so venom looks at the tree itself.
  • An in-place agent shares a tree with a person. RFC-0018 section 7 allows isolation = in_place. A person’s edits there sit beside the agent’s, and a careless rollback would destroy them without a trace. Everything in section 7 exists for this case.
  • The harness owns the conversation. Codex 0.167.0 has no call that shortens a thread (thread/rollback is refused as an unknown method), so “undo the last turns” can only mean continuing from a copy.

A note on words: venom has two other things called checkpoints: RFC-0003’s stream.checkpoint control item and the compaction of venom’s event log. Neither is related to this RFC. Here, checkpoint always means a saved state of an agent’s files.

Design

1. Overview by example

Agent a2 works in a worktree of venom-std. Each turn leaves checkpoints:

$ venom agent checkpoints --agent=a2 --json
{"agent":"a2","pending":false,"checkpoints":[{"checkpoint":"k3","reason":"turn_end","turn":"t2","through_turn":"t2","at":"2026-11-09T12:04:10.120Z","mid_turn":false,"overlap":false,"conversation":true,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"3b7e9d10c2a4f6815e7d9b0c1a2f3e4d5c6b7a89","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]},{"checkpoint":"k2","reason":"turn_end","turn":"t1","through_turn":"t1","at":"2026-11-09T12:01:02.300Z","mid_turn":false,"overlap":false,"conversation":true,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"c41d07ae96b3528f10e7c6d5b4a39281f0e1d2c3","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]},{"checkpoint":"k1","reason":"turn_start","turn":"t1","through_turn":null,"at":"2026-11-09T12:00:00.040Z","mid_turn":false,"overlap":false,"conversation":true,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"5e20f8b7d1c6a3940e8f7a6b5c4d3e2f1a0b9c8d","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]}]}

They are ordinary git objects, and git’s own tools can read them:

$ git -C ~/venom-org/venom-std for-each-ref refs/venom/checkpoints/a2
5e20f8b7d1c6a3940e8f7a6b5c4d3e2f1a0b9c8d commit	refs/venom/checkpoints/a2/1
c41d07ae96b3528f10e7c6d5b4a39281f0e1d2c3 commit	refs/venom/checkpoints/a2/2
3b7e9d10c2a4f6815e7d9b0c1a2f3e4d5c6b7a89 commit	refs/venom/checkpoints/a2/3
$ git -C ~/venom-org/venom-std diff --stat refs/venom/checkpoints/a2/2 refs/venom/checkpoints/a2/3
 src/proto/frame.zig | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

Turn t2 went wrong. The user asks what going back to k2 would change. A dry run changes no file and returns a plan, and the real call must quote it:

$ venom agent rollback --agent=a2 --checkpoint=k2 --dry-run=true --json
{"agent":"a2","checkpoint":"k2","dry_run":true,"plan":"p1-6d0f4a2be19c7735","restore":"all","guarded":false,"conversation":{"rewinds":true,"through_turn":"t1","drops":["t2"]},"shared_with":[],"children":[],"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","head":{"moves":false,"from":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","to":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","commits":0},"files":[{"path":"src/proto/frame.zig","change":"modify","class":"agent"}],"left":[],"ignored_edits":[]}]}
$ venom agent rollback --agent=a2 --checkpoint=k2 --plan=p1-6d0f4a2be19c7735 --json
{"agent":"a2","checkpoint":"k2","dry_run":false,"restore":"all","safety":"k4","after":"k5","exact":true,"conversation":{"rewinds":true,"through_turn":"t1","drops":["t2"],"harness_session":"db5b5038-2eca-45a0-bff2-ef5647c506fb"},"shared_with":[],"children":[],"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","head":{"moves":false,"from":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","to":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","commits":0},"files":[{"path":"src/proto/frame.zig","change":"modify","class":"agent"}],"left":[],"ignored_edits":[]}]}

k4 holds the tree as it was before the rollback, so the rollback can itself be rolled back. The event stream shows it:

$ venom events --types '["agent.checkpoint","agent.rollback_*"]' --resources '["agent:a2"]' --json
{"seq":312,"ts":"2026-11-09T12:06:00.410Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.checkpoint","data":{"agent":"a2","checkpoint":"k4","reason":"pre_rollback","through_turn":"t2","repos":[{"repo":"venom-std","commit":"a90b18c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f201"}]}}
{"seq":313,"ts":"2026-11-09T12:06:00.415Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.rollback_started","data":{"agent":"a2","checkpoint":"k2","safety":"k4","restore":"all"}}
{"seq":314,"ts":"2026-11-09T12:06:00.700Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.checkpoint","data":{"agent":"a2","checkpoint":"k5","reason":"rollback","through_turn":"t1","repos":[{"repo":"venom-std","commit":"71fe02d3c4b5a69788f9e0d1c2b3a4f5e6d7c8b9"}]}}
{"seq":315,"ts":"2026-11-09T12:06:00.702Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.rollback_ended","data":{"agent":"a2","checkpoint":"k2","safety":"k4","restore":"all","ok":true,"after":"k5","drops":["t2"]}}

(RFC-0003’s type selectors are exact names or prefix.*, so the command above would have to name agent.rollback_started and agent.rollback_ended, or use agent.*. It is written short here.)

The same rollback with an in-place agent, where a person edited README.md between turns, is refused until the person says what to do with that file:

$ venom agent rollback --agent=a5 --checkpoint=k2 --restore=files --dry-run=true --json
{"error":{"code":"conflict.outside_edits","message":"2 paths changed outside the agent's turns; choose --outside=keep or --outside=overwrite","data":{"repos":[{"repo":".","paths":["README.md","notes/todo.md"]}]}}}
$ venom agent rollback --agent=a5 --checkpoint=k2 --restore=files --outside=keep --dry-run=true --json
{"agent":"a5","checkpoint":"k2","dry_run":true,"plan":"p1-0b7c91e4a35d2f68","restore":"files","guarded":true,"conversation":{"rewinds":false,"through_turn":"t1","drops":[]},"shared_with":[],"children":[],"repos":[{"repo":".","path":"/home/u/scratch","head":{"moves":false,"from":"e17a60f2b3c4d5968778695a4b3c2d1e0f9a8b7c","to":"e17a60f2b3c4d5968778695a4b3c2d1e0f9a8b7c","commits":0},"files":[{"path":"src/main.zig","change":"modify","class":"agent"},{"path":"gen/out.txt","change":"delete","class":"turn"}],"left":[{"path":"README.md","reason":"outside"},{"path":"notes/todo.md","reason":"outside"}],"ignored_edits":[]}]}

2. Terms and invariants

Term Meaning
checkpoint A saved state of the files of every repo in an agent’s scope, with an id k<n> per agent, like t<n> and c<n> (RFC-0019).
tree The folder one repo of an agent’s scope is checked out in: a worktree, a copy, or the repo itself for in_place.
capture Making one checkpoint commit for one tree (section 4).
guarded tree A tree where a rollback could overwrite somebody else’s work: an in_place tree, or any tree another non-archived agent also uses (a fork, an in-place child). Section 6.
sharer Another non-archived agent using the same tree, compared by canonical path.
live An agent in queued, running, needs_you or handed_over.
safety checkpoint The checkpoint (reason: pre_rollback) every rollback makes before it writes anything.
window Two consecutive captures of one tree, including ones that were deduplicated (3.2).

Invariants. An implementation MUST keep all of these, and each has a test in venom-org/venom#155 or #156:

  1. A capture changes nothing the user can see in the tree. Not the working files, the index, HEAD, a branch or a stash. It adds objects and one ref.
  2. Nothing is written before a safety checkpoint exists. If it can’t be made, the rollback fails with nothing changed.
  3. A rollback never touches a file that isn’t in its plan. Ignored files, above all, are neither captured nor deleted nor overwritten.
  4. In a guarded tree, venom never moves HEAD or a branch and never writes the index. Those belong to the person.
  5. Files and conversation go together or not at all, unless the caller asked for files alone.
  6. A checkpoint is protection against accidents, not a security boundary. An agent that can run commands can delete refs too (section 13).

3. When a checkpoint is taken

The issue offered three moments: venom takes all three. Each one answers a question the others can’t.

reason When Why
turn_start Before venom delivers a send of mode = "turn" that starts a turn What was in the tree when the agent began, and so what changed between turns. Without it, a person’s edit can’t be told from the agent’s (7.3).
turn_end After turn.ended, whatever its reason (completed, interrupted, failed, limit). Also after the synthetic end of RFC-0019 section 9.2 What this turn produced. The usual rollback target.
manual agent.checkpoint A point the user wants to keep, for example before approving something risky. May be taken mid-turn.
takeover After release, before the take-over split opens (RFC-0019 section 7) The start of a stretch venom can’t see into.
takeback After catch_up, before reattach (RFC-0019 section 7.4) All the work done in the harness’s own TUI, as one checkpoint.
fork When agent.fork succeeds (section 10) Lets a fork go back to the files of the turn it forked at.
pre_rollback First step of a rollback The safety checkpoint.
rollback Last step of a rollback What the tree looks like afterwards, and the proof of what happened (7.4).

3.1 Ordering and the reactor

Captures run as checkpoint.capture outbox effects, keyed checkpoint:<agent>:<n>, like harness.fork (RFC-0019 section 4). They MUST NOT hold the reactor: every git command is a child process the reactor polls, and the effect steps with error.Retry-style calls until it ends. isolation.zig’s synchronous git MUST NOT be reused here, since a capture of a large repo can take seconds.

  • An agent’s capture and rollback effects run one at a time, oldest first. Effects on one tree run one at a time across agents.
  • turn_start holds the send until the capture ends or fails. The agent is already running, so the input box doesn’t wait. The hold is bounded by checkpoints.timeout, and a capture that ran out of time lets the turn go.
  • turn_end doesn’t hold settled. A send that arrives while it is pending waits behind it, by the first rule. agent.rollback fails with conflict.checkpoint_pending meanwhile, so the plan is never computed against a turn not yet recorded.
  • Mid-turn steering (mode = "steer") starts no turn, and no capture.
  • A turn the harness starts by itself (origin: harness), or a message Claude Code queued (steer = "queue"), starts without a turn_start. If such a turn began before the previous turn_end capture finished, that checkpoint has overlap: true: it may hold part of the next turn, and it can’t rewind the conversation (section 8).
  • Turns imported by catch_up have no checkpoints of their own. takeback covers them all.

A failed capture never stops a turn. It logs checkpoint.failed, publishes agent.checkpoint_failed and leaves a row in the transcript. Stopping the work to protect the work would be the wrong trade, and rollback handles the gap conservatively: the window around it counts as outside the agent’s turns (7.3).

3.2 Deduplication

Most turn_start captures find what the previous turn_end left. When every repo’s tree and HEAD equal the latest checkpoint’s, venom makes no new ref and no new id. It logs checkpoint.reused {agent, checkpoint, reason, turn}, which is internal and not published, so the window logic of 7.3 still sees the capture. agent.checkpoint with a label sets the label on the checkpoint it reused. The dedup never applies to pre_rollback’s successor (rollback always gets its own record) or to fork.

3.3 through_turn

Each checkpoint records through_turn: the last turn that had ended when it was taken, or null before the first. It says what conversation goes with these files. A turn_start of t5 and a turn_end of t4 both have through_turn: "t4". A mid-turn manual checkpoint has the last finished turn too, plus mid_turn: true, so its files don’t match any point in the conversation.

4. What a checkpoint captures

What Captured Rollback
Tracked files, staged or not Working-tree content Restored
Untracked files not ignored Yes Restored, and those created since are deleted
Deleted files Absent from the tree Restored or deleted to match
Modes and symlinks Yes, as git records them Restored
Ignored files (.gitignore, .git/info/exclude, core.excludesFile) No Never touched
Submodules and nested repos The commit they are at (a gitlink), nothing inside Never touched; a difference is reported in left
Files over checkpoints.max_file_bytes, new or changed No; listed in skipped Never touched; reported in left
Paths marked assume-unchanged Yes, the working-tree content Restored
Paths marked skip-worktree (sparse checkout) The index’s content; listed in skipped Never touched; reported in left
LFS and other clean filters What the filter gives (a pointer) The smudge filter runs on restore
The index itself, stashes, in-progress merge state No No
Files outside any repo in scope No No

The reasoning for the three “no”s:

  • Ignored files are build output and caches, often huge, and capturing them every turn is what makes this kind of feature unusable. They also often hold secrets on purpose. The price: an agent’s edit to an ignored file can’t be undone, so the plan lists any such edit it knows of (ignored_edits, from edit tool calls).
  • Submodules are other repositories with their own history. A rollback that reached into one could lose commits nothing else holds. Only the pointer is recorded, and a dirty submodule is flagged in submodules[].dirty so the plan can warn about it.
  • Big files are skipped per file instead of failing the capture, because one stray core.dump shouldn’t cost the user every other file. The checkpoint’s tree keeps what the index had for that path.

A capture whose new and changed files together pass checkpoints.max_total_bytes, after the skips, fails with failed.checkpoint_too_large, and data.largest names the five largest paths. That usually means a build directory nobody ignored.

4.1 The capture recipe

For one tree. These commands were run against git 2.56.0, on a throwaway repo, with the results listed in invariant 1; $RT is venom’s runtime directory. Every git command runs with the configuration pinned, from the tree, with hooks off. reference-transaction hooks would otherwise fire on update-ref, and an fsmonitor is a program too:

git() { command git -c core.hooksPath=/dev/null -c core.fsmonitor=false \
  -c core.untrackedCache=false -c core.quotePath=false \
  -c status.showUntrackedFiles=normal "$@"; }

# 1. Start from a copy of the real index, so the stat cache spares hashing the
#    tree. git replaces its index by rename, so the copy is never half written.
#    With no index yet (a repo with no commit), start from none.
cp "$(git rev-parse --path-format=absolute --git-path index)" "$RT/checkpoint/a2-3.index"
export GIT_INDEX_FILE="$RT/checkpoint/a2-3.index"

# 2. In the copy only, clear assume-unchanged, so those edits are seen.
git ls-files -v -z | python3 -c 'import sys
for e in sys.stdin.buffer.read().split(b"\0"):
    if e[:1].islower(): sys.stdout.buffer.write(e[2:] + b"\0")' \
  | git update-index -z --no-assume-unchanged --stdin

# 3. Find what is over the size limit: stat each path this lists.
git status --porcelain=v1 -z --no-renames --untracked-files=all

# 4. Stage everything but the skipped paths, into the copy.
printf ':(exclude,literal)big.bin\0.\0' \
  | git add -A --pathspec-from-file=- --pathspec-file-nul
tree=$(git write-tree)
unset GIT_INDEX_FILE; rm -f "$RT/checkpoint/a2-3.index"

# 5. Make the commit. commit-tree doesn't read commit.gpgsign, and needs no
#    identity from the user. Leave out -p HEAD when HEAD has no commit.
export GIT_AUTHOR_NAME=venom GIT_AUTHOR_EMAIL=venom@localhost
export GIT_COMMITTER_NAME=venom GIT_COMMITTER_EMAIL=venom@localhost
commit=$(git commit-tree -p HEAD -F - "$tree" <<'MSG'
venom checkpoint a2/k3

Venom-Agent: a2
Venom-Checkpoint: k3
Venom-Reason: turn_end
Venom-Turn: t2
MSG
)

# 6. Create the ref. The empty old value makes this fail if the ref exists.
git update-ref "refs/venom/checkpoints/a2/3" "$commit" ""

(The Python in step 2 stands for venom’s own filter; there is no Python in venom. git status in step 3 and the . pathspec keep to the tree, and venom MUST check the tree is a git root of its own first, as isolation.zig does, since git walks up into an enclosing repo otherwise.)

The commit’s parent is HEAD at capture time, so head is recoverable from the commit too, and commits the agent made stay reachable from the checkpoint.

A repo with no commit yet can be captured when it is in_place: the commit has no parent and head is null, and HEAD stays unborn.

4.2 What goes in the log

checkpoint.taken is the record, published as agent.checkpoint. Refs hold the objects, and the log holds the truth: venom lists checkpoints from the log, and a rollback checks that the ref still resolves to the logged commit. A ref that is gone or has moved makes that checkpoint not_found.checkpoint. A ref no log record names is ignored by every verb, and doctor reports it.

{"type":"checkpoint.taken","agent":"a2","checkpoint":"k3","reason":"turn_end","turn":"t2","through_turn":"t2","at":"2026-11-09T12:04:10.120Z","mid_turn":false,"overlap":false,"repos":[{"repo":"venom-std","path":"/home/u/venom-org/venom-std-246","commit":"3b7e9d10c2a4f6815e7d9b0c1a2f3e4d5c6b7a89","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f","branch":"246-frame","skipped":[],"submodules":[]}]}
Member of repos[] Meaning
repo Relative to the workspace, as in worktrees (RFC-0018)
path The tree’s absolute path
commit The checkpoint commit
head HEAD’s commit, or null when the repo has none
branch The branch HEAD is on; absent when detached
skipped [{path, bytes, reason}], reason being too_big or skip_worktree
submodules [{path, commit, dirty}], for gitlinks in the tree, nested repos included

An effect runs at least once. A daemon that stopped between the ref and the log record finds its ref on the retry: if the ref’s commit has this agent and id in its trailers and is not older than the effect’s request, venom logs it as it is. Anything else means the id was used by a log this daemon no longer has (the user deleted it), and venom takes the next free n.

5. Storage

5.1 Namespace

refs/venom/checkpoints/<agent>/<n>

<n> is the number in k<n>. Every repo in scope gets one ref per checkpoint, pointing at the same commit as before when that repo didn’t change. So a checkpoint is complete in every repo it names, and pruning has no dependencies to follow.

Isolation Where the refs are Survives the tree’s removal
worktree The source repo. Refs under refs/venom/ are shared across linked worktrees, and git keeps no reflog for them Yes, until agent.clean
in_place The repo itself Yes
copy The copy’s own .git No: they go with the folder

A workspace with no git repos has nowhere to keep them. Automatic capture does nothing there, and agent.checkpoint and agent.rollback fail with unsupported.checkpoint_no_repo. Open question 3.

Things a user can notice: git log --all and gitk --all show these commits. git log --branches --tags doesn’t. git push and git clone don’t transfer them, but git push --mirror does, including any untracked file that wasn’t ignored, so doctor warns when a remote has mirror set (checkpoints.mirror).

5.2 Retention

checkpoint.prune effects run after a turn_end, at daemon start and after agent.clean. They delete refs with git update-ref --stdin, in one transaction per repo, and log checkpoint.pruned {agent, checkpoints}. The rules, per agent:

Checkpoint Goes when
The newest one Never, by pruning
turn_start, turn_end, takeover, takeback, fork, rollback Beyond the newest checkpoints.keep, or older than checkpoints.keep_for
manual Older than checkpoints.keep_for
pre_rollback Older than checkpoints.keep_safety

Pruning a checkpoint merges the two windows around it, which makes 7.3 more cautious, never less: a merged window spans more than one turn, so it counts as outside the agent’s turns.

venom MUST NOT run git gc, git prune or git maintenance. The objects of a deleted ref go when the repo’s own gc gets to them.

5.3 agent.clean

agent.clean (RFC-0018 section 2) removes an agent’s checkpoints as follows, and its result gains checkpoints: [{agent, repo, removed, kept}], counting refs.

  • worktree: when the worktree’s entry is removed or gone, all the refs of that agent and of forks that borrowed the worktree go. When it is kept, they all stay: there’s still work there that may want them.
  • copy: they go with the folder.
  • in_place: there is no tree to remove, so cleaning an archived in-place agent removes its refs, except pre_rollback checkpoints younger than checkpoints.keep_safety. Those may hold the only copy of a person’s edits. They stay, are counted in kept, and pruning removes them later. So the agent’s checkpoints are all gone within keep_safety at the latest.

A failed deletion is reported and isn’t an error, and pruning retries it.

A checkpoint is never evidence that work is safe. cleanup.zig decides whether a worktree’s commits exist elsewhere, and it MUST leave refs/venom/ out of that decision, both ways: copyUnsaved MUST NOT count these refs as work to save (a copy’s checkpoints would make it impossible to clean), and inLocalRefs MUST NOT count them as a place the commits are saved (the commits would vanish when the checkpoint is pruned). countUnsaved uses --branches and is already correct.

6. Scope types

Unguarded worktree or copy Guarded tree (in_place, or shared)
Whose work is in it The agent’s Somebody else’s too
Restores files Yes Yes, path by path
Paths changed outside the agent’s turns (7.3) Restored; shown in the plan Refused until the caller chooses (--outside)
Moves HEAD and the branch Yes, back to the checkpoint’s, unless --keep-head (7.5) Never
Resets the index Yes, to the new HEAD Never
Who may call it write on the agent, agents included (section 11.4) A person only

A sharer is any other non-archived agent using the same tree, so a fork makes its source’s worktree guarded as well as its own (section 10). This is on purpose: a rollback of one would overwrite the other’s work just as it would a person’s.

7. Rollback

7.1 Preflight

Both the dry run and the real call run these checks in this order, before any capture. The first that applies is the error. Section 9 explains them.

  1. not_found.agent; not_found.checkpoint (never existed, pruned, or its ref is gone or moved in any repo).
  2. conflict.invalid_transition: the agent is archived.
  3. conflict.handed_over: the agent is handed_over.
  4. conflict.turn_running: it is queued, running or needs_you, a turn or compaction was asked for and hasn’t started, or a fork is running (RFC-0019 section 4).
  5. conflict.rollback_running: a rollback of this agent or on one of its trees is running.
  6. conflict.checkpoint_pending: a capture of this agent is pending.
  7. conflict.stale_requests: it has stale inbox items (RFC-0019 section 9.3).
  8. conflict.live_children: a descendant is live.
  9. conflict.tree_shared: a sharer is live.
  10. conflict.git_operation: a merge, rebase, cherry-pick, revert or bisect is under way in a tree (the state files cleanup.zig already knows).
  11. unsupported.harness_capability and conflict.checkpoint_files_only for restore = "all" (section 8).
  12. conflict.head_moved and conflict.commits_pushed (7.5).
  13. conflict.outside_edits (7.3).

7.2 The plan

Rollback is two calls, dry run and real, and a real call without a plan fails validation (invalid_params.plan). The plan is what makes “what you were shown is what happens” enforceable, in a tree that can change under a person’s editor.

A dry run captures the current state of each tree (recipe 4.1 through step 5, with no ref) as S, computes what would change, and returns plan: p1- followed by the first 16 hex digits of the SHA-256 of this JSON, with no spaces:

{"checkpoint":"k2","restore":"all","outside":null,"keep_head":false,"repos":[{"repo":"venom-std","tree":"6a1f0c2d3e4b5a69788f9e0d1c2b3a4f5e6d7c8b","head":"8f3a1c9e5d7b2046a1c3e5f708192a3b4c5d6e7f"}]}

repos is sorted by repo, and tree and head are S’s. p1 is this format’s version. Nothing is stored between the calls: the real call repeats the preflight and capture and recomputes the digest. A different digest fails with conflict.rollback_stale. Since the plan is only a digest, the error can’t say which paths changed; a new dry run shows the new state. A client MAY retry the dry run and show it again. It MUST NOT retry the real call with the fresh plan unless a person has seen it.

7.3 Who changed what

In a guarded tree venom needs to know which paths are someone else’s. Git can’t say, but the captures of section 3 can.

Let T be the target. Walk the tree’s captures in time order, from T to S, as pairs (windows):

  • A turn window: the same turn of this agent was running at both ends. A turn_start/turn_end pair is one, and so is either half split by a mid-turn manual capture. No other agent using this tree may have had a turn running during it.
  • A rollback window: its later end has reason: rollback. Those are venom’s own writes.
  • Every other window is a gap: between turns, across a take-over, across a failed capture, across a pruned checkpoint, across a turn with no turn_start, or shared with another agent’s turn. The last window, from the newest capture to S, is always a gap.

Then for each path that differs between S and T:

Class When Meaning
outside A gap changed it Not the agent’s, or venom can’t show it was
agent Not outside, and an edit tool call of a turn window names it The agent did it
turn Anything else It changed while the agent’s turn ran, but no edit call names it: a command, usually

--outside says what happens to outside paths:

Value Effect Default for
refuse conflict.outside_edits, with the paths. Nothing happens Guarded trees
keep Those paths stay as they are, in left with reason: outside. The rest is restored
overwrite They are restored like the rest. Their content is in the safety checkpoint Unguarded trees

What this does not catch. A person who edits a file during a running turn looks like the turn: class agent or turn, and it gets restored. Watching the files during a turn wouldn’t help, since a write can’t be traced to a process. So the protection for this case is the safety checkpoint and the plan, which shows every path with its class before anything happens. A person’s edit in this window can always be recovered: git show refs/venom/checkpoints/a5/9:src/main.zig.

7.4 The real call

Each step logs what it did, so a crash leaves evidence. Repos are handled in repos order, and S is each tree’s state as step 1 captures it:

  1. Check the plan (7.2): repeat the preflight, capture S, compare digests.
  2. Safety. Make pre_rollback from S, the same commits: new refs, logged, published. Then log rollback.started. Any failure up to here changes nothing.
  3. Ask for the conversation, if restore = "all" will drop a turn (section 8): the adapter’s rewind. It makes a copy, or for claude-code names one the next start makes, and changes nothing in the source. A failure ends the rollback here with the harness’s code, with no file touched.
  4. Apply the files, by the recipe below.
  5. Move HEAD, in an unguarded tree (7.5).
  6. Capture again with reason: rollback, which never dedups, and compare its tree with the expected one (below). A difference means something wrote during the rollback: the result says exact: false, and the plan’s paths are reported as they are. It isn’t a failure.
  7. Commit in one log commit: rollback.ended, the turns dropped, and the new agent.binding. Delete nothing. Every later checkpoint stays, so the user can go forward again; section 8 says which ones can bring the conversation back.

The recipe, for paths = the differences between S and T, less everything in left. It writes the working files and no index the user has:

export GIT_INDEX_FILE="$RT/checkpoint/a2-rollback.index"
git read-tree "$T"

# 1. The list, with its status: D for what T does not have, else a write.
git diff-tree -r -z --no-renames --name-status "$S" "$T"

# 2. Deletions first, longest paths first, each with unlink, then rmdir on its
#    empty parents up to the tree's root. So a file can replace a folder.

# 3. Then every write. This runs smudge filters and sets modes and symlinks.
printf '%s\0' src/proto/frame.zig | git checkout-index -f -z --stdin
unset GIT_INDEX_FILE; rm -f "$RT/checkpoint/a2-rollback.index"

These go in left instead of files, and venom does not touch them:

reason When
outside --outside=keep, and the class is outside
too_big In skipped of S or T
skip_worktree In skipped of S or T
gitlink Mode 160000 in S or T: a submodule or nested repo, and T’s commit is in detail
ignored_in_the_way A write would replace something S doesn’t hold: lstat of the path or of a parent finds a file or a non-empty folder that isn’t in S. It’s ignored, so it exists nowhere else

The expected tree of step 6 starts from S, and for each path in files takes T’s entry or removes the path (git update-index -z --index-info, with a 0 0000000000000000000000000000000000000000\t<path> line to remove).

A rollback’s own checks stop at the last possible moment but can’t close the race with a person’s editor: a save between the plan check and that path’s write is overwritten. That window is milliseconds, and step 6 reports it (exact: false).

7.5 HEAD in an unguarded tree

An agent often commits. Restoring only the files would leave HEAD ahead of them, and every dropped change would show as a reversal waiting to be committed. So in an unguarded tree a rollback also moves the branch, when it safely can:

git merge-base --is-ancestor "$T_HEAD" "$HEAD"       # else conflict.head_moved
git update-ref -m "venom rollback a2" "refs/heads/$BRANCH" "$T_HEAD" "$HEAD"
git read-tree "$T_HEAD"                              # the index follows HEAD
git update-index -q --refresh

update-ref takes the old value, so it fails if the branch moved after the plan. A detached HEAD uses git update-ref --no-deref HEAD. Nothing is lost: the safety checkpoint’s parent is the old HEAD, so those commits stay reachable until it is pruned.

Situation Code Way out
head equals the current HEAD None; nothing moves
HEAD is on another branch than the checkpoint recorded, or the checkpoint’s head isn’t an ancestor (the agent rebased) conflict.head_moved --keep-head
A commit that would be dropped is on a remote-tracking ref (git for-each-ref --contains <commit> refs/remotes) conflict.commits_pushed --keep-head
Checkpoint’s head is null Treated as conflict.head_moved --keep-head

Moving back a branch that has been pushed would make the next push a force push, which RFC-0019 section 6.4.2 never lets an “always” grant cover. No network call is made: a remote-tracking ref is enough evidence to ask. With --keep-head, the files are restored and HEAD stays, so the dropped changes show as uncommitted reversals, and the user commits them or not. A guarded tree behaves like this always, without the flag.

7.6 Failures and crashes

What happened Code State
Git failed while applying or moving HEAD failed.rollback_undone venom ran the recipe back from the current state to S, for each repo it had touched, and S’s capture matched again. As if it hadn’t happened
The way back failed too failed.rollback_partial Mixed. data.repos says which repo is in which state. The safety checkpoint is whole, and the agent stays as it was. A person can roll back to it
The daemon stopped between rollback.started and rollback.ended failed.rollback_interrupted Unknown

A daemon that starts and finds rollback.started with no end does not run the effect again and does not write anything. It logs rollback.ended {ok: false, code: "failed.rollback_interrupted"} and raises a review attention whose reason names the safety checkpoint. Writing to a person’s tree at start-up, unasked, on the memory of a plan is exactly what this RFC is against.

A copy of the conversation made in a failed rollback stays with the harness, and nothing uses it (RFC-0019 section 4 accepts the same for a fork).

A rollback never changes the agent’s state. settled stays settled, and failed stays failed, since RFC-0018’s state diagram has no such edge. A later send works from either.

8. Conversation

--restore takes all (the default) or files.

Files and conversation are both the state of an agent: rewinding only the files leaves a model that remembers edits that no longer exist, and it will act on them. So all is the default, and leaving the conversation behind has to be asked for.

What decides it. The checkpoint’s through_turn (3.3) names the conversation to go back to. Its conversation field (in agent.checkpoints) is true when it can bring it back:

  • It isn’t mid_turn or overlap, and its reason isn’t fork.
  • through_turn isn’t a dropped turn, and its turn has a harness id (harness_turn or harness_end, RFC-0019). A takeback after a failed catch_up has conversation: false, since venom doesn’t know what the harness holds.
  • Rewinding is needed and the adapter declares rewind, or no rewind is needed.

With restore = "all":

Case Result
through_turn is the agent’s latest turn Nothing to rewind. Works on any harness. conversation.rewinds is false
through_turn is null: back to before the first turn venom clears harness_session from the binding and the next send starts a new conversation. No adapter call, so it works on any harness
The adapter declares rewind RFC-0019’s rewind operation (below)
The adapter doesn’t unsupported.harness_capability, and data.fix says --restore=files. Nothing is changed.
The checkpoint can’t (conversation: false) conflict.checkpoint_files_only, and data.why says which rule. Nothing is changed.

rewind (RFC-0019 section 4). {through_turn} returns {harness_session}: a new conversation holding the turns through that one. It MUST NOT change the source conversation, which is what lets invariant 5 hold. It can be made before a single file moves, and if the files then fail, nothing refers to it. Only step 7’s log commit switches the agent to it. It has the same rules as fork: it never holds the reactor and is bounded by the adapter (timeout.harness_request, and conflict.harness_fork_refused for a refusal).

What RFC-0019 gains, all optional and compatible (its section 14):

Addition RFC-0019 section Meaning
capability rewind 3 bool. The adapter implements rewind. Missing means false
operation rewind 4 Between turns, {through_turn} with the harness’s ids for that turn (below), result {harness_session}. Preconditions and errors as for fork; any operation in TUI mode fails conflict.handed_over
start’s fork_at 4 With fork_source: the harness’s id of the point to copy through. An adapter that copies at the first start uses it, like fork_source
resume record’s fork_at 7.1 With fork_source, until the copy exists. A take-over of such a fork is refused (RFC-0019 section 4)
turn.ended’s harness_end 5.2 Optional: the harness’s id of the turn’s last message, where the harness has one. Claude Code: the uuid of the turn’s last assistant line. Kept in the log and in catch_up’s events

The ids the adapter gets for through_turn are the turn’s harness_turn and harness_end. A turn logged without the one its adapter needs has no harness id for this purpose (conflict.checkpoint_files_only, why: no_harness_id).

Harness rewind
claude-code Like its fork ("head"): venom chooses the new id at once, with no process, and logs fork_source and fork_at with the binding. The next start runs claude -p … --resume <fork_source> --resume-session-at <harness_end> --fork-session --session-id <id>, then as usual. The binding keeps fork_source and fork_at until the copy exists (RFC-0019 section 4: that start’s session arrives, or a take-back finds the copy’s transcript), so a restart repeats it. A take-over before that start is refused with unsupported.harness_capability while fork_at is set (RFC-0019 sections 4 and 7.2); the agent is unchanged and its first start still makes the copy. A start that fails with not_found.harness_session fails the agent as RFC-0019 says for reattach; the source conversation is still there, and rolling back to a later checkpoint with --restore=files is the way out. Declared only up to tested_version: open question 1
codex thread/fork {threadId, lastTurnId: <harness_turn>, excludeTurns: true} in a live app-server, then thread/unsubscribe, exactly as its fork (RFC-0019 section 12.2). The new thread.id is the result
ACP false until its adapter’s issue says otherwise

Neither harness needs anything else, and turn ids stay valid across it. Checked on the versions RFC-0019 names:

$ claude -p --model haiku --resume c52b349d-f73b-4aa6-baf9-00281f89d3a1 --resume-session-at a136cb0e-902b-4f76-957a-e34a2115a2db --fork-session --session-id db5b5038-2eca-45a0-bff2-ef5647c506fb --output-format json "List every word I asked you to remember, comma separated, nothing else. Do not use tools."

The source conversation had been told APPLE in its first turn and BANANA in its second, and the rewind was to the first turn’s last assistant line. The copy answered APPLE. The source still answered APPLE, BANANA, and its transcript had not grown. On Codex, thread/fork {threadId, lastTurnId, excludeTurns: true} gave the same answers, and thread/turns/list on the copy returned the first turn under its old id.

After a rewind:

  • venom’s turn numbering goes on. After dropping t4 and t5, the next turn is t6. Ids are never reused, and every event already published stays true.
  • The log keeps everything. agent.show marks dropped turns with dropped: true, and the transcript shows them folded under the row of section 12. Their tokens stay in usage: they were spent.
  • agent.fork --through-turn on a dropped turn fails with not_found.turn.
  • Usage totals re-base, as after reattach (RFC-0019 section 8.1).
  • Pending one-shot decisions can’t exist, because of preflight 7.

Going forward again. A later checkpoint keeps its files. Its conversation is gone from the agent’s binding, so it has conversation: false: “undo the undo” restores the files with --restore=files, and the note below tells the model. Rewinding to a safety checkpoint’s dropped turns isn’t offered, even though the old conversation is still on disk, because a rollback that swaps whole conversations in place is a different feature nobody asked for yet.

restore = "files": the defined result. Files go back, and the conversation stays as it is. Since the model now remembers things that aren’t true, venom prepends checkpoints.rollback_note to the text of the agent’s next send, once, and to the next send of each sharer. The note is part of that prompt, so turn.prompt records it and the transcript shows it:

Note: the files were rolled back to their state after turn t3. Changes made after that point are gone, and what you remember of them may no longer be true. Read the files before you rely on them.

With through_turn: null it says “to their state before your first turn” instead. This is the only result a harness without rewind can get, and it is why venom-org/venom#157 can’t leave a mismatched state silently.

9. Conflicts

Situation Decision Why
A turn is running, or needs_you conflict.turn_running. Stop it first; a stopped turn gets its turn_end checkpoint, which is then a target The harness is writing, and any restored file could be overwritten a moment later. Interrupting on the caller’s behalf would turn a question about files into a side effect
queued conflict.turn_running It is about to run
handed_over conflict.handed_over, as RFC-0019 section 4 has for every operation in TUI mode. Take back first The harness TUI has the tree and the conversation, and venom can’t stop it writing (RFC-0019 section 7.4, “only one writer”)
Capture while handed_over Allowed: agent.checkpoint reads only
Changes made during take-over One takeback checkpoint, with the window from takeover to it being a gap, so those paths are outside (7.3). A person drove them. Imported turns have no checkpoint of their own, so going back to the middle of a take-over isn’t possible venom sees no turn boundaries while it isn’t driving
Stale requests (RFC-0019 section 9.3) conflict.stale_requests, data.requests names them. Answer them; cancel_turn closes one and resumes nothing A one-shot decision answers a call by the digest of its input and cwd, and the call was planned against the files being replaced
A descendant is live conflict.live_children, data.children. agent.stop --tree first Dropping the turn that started a child leaves a parent that doesn’t know about a child still writing
Settled children Allowed. Rollback never touches their trees and doesn’t roll them back. They are listed in children, so the UI can offer the same for them Each agent’s state is its own, and a settled child’s result is what the user may want to keep
A sharer is live conflict.tree_shared, data.agents Its next write would land on the restored files
A sharer has settled Allowed in a guarded tree (section 6). Listed in shared_with; it gets the note
A merge or rebase is in progress conflict.git_operation A half-resolved index and state files don’t belong to any checkpoint
Running agent.send, agent.fork, agent.compact, agent.takeover or agent.mcp_off during a rollback conflict.rollback_running One rollback owns the agent until it ends
A second rollback on a tree conflict.rollback_running, for the agent or a sharer
archived conflict.invalid_transition, as for agent.fork. The commit is in agent.checkpoints for anyone who wants it through git Its worktree may be gone
Native sub-agents Part of the parent’s turn and the parent’s tree. A background one that outlives turn.ended is out of venom’s sight (RFC-0019 section 5.2 closes only what it knows); its writes after that land in a gap

10. Fork

agent.fork copies a conversation and does not touch files. That doesn’t change. A fork runs in its source’s folder because that’s the only place its harness can resume the copy (RFC-0019), so a source and its fork always see one set of files, and through_turn names a point in the conversation only. So after a fork through t3, the fork remembers up to t3 and the files are as they are now.

This RFC makes that visible, and fixable, without changing it:

  • agent.fork_ended gains checkpoint: the source’s newest checkpoint whose through_turn is the fork’s point (the latest turn if none was named), leaving out mid_turn and overlap ones. It is absent when there is none. forks[] on agent.show gains it too.

  • The fork gets its own first checkpoint, k1 with reason: fork, pointing at the same commit under its own namespace, with conversation: false:

    git update-ref refs/venom/checkpoints/a7/1 "$SOURCE_COMMIT" ""
  • To line the files up with the fork’s conversation, the user rolls the fork back to it. The tree is guarded, since the source shares it, and every path counts as outside, because the changes predate any turn of the fork. So it takes a person, and it’s explicit:

    $ venom agent rollback --agent=a7 --checkpoint=k1 --restore=files --outside=overwrite --dry-run=true --json

    The source then has a tree that moved under it, and it gets the note.

Why not restore the files during agent.fork? Because through_turn is commonly used to look back with the code left alone, and a fork that silently rewrites a tree another agent is using would break invariant 3. A fork with its own tree at a checkpoint is what users would really like, and open question 2 keeps it for later: the harnesses tie a conversation to its folder.

11. Contract

11.1 Verbs

All have --json. The two that change or capture hold their call until they finish, like agent.wait, and neither holds the reactor (3.1).

Verb Grant Params Result
agent.checkpoints read agent, turn?, before?, limit? (default 50) {agent, pending, checkpoints}, newest first. pending: a capture hasn’t finished. turn filters on the checkpoint’s turn, and before is a checkpoint id
agent.checkpoint write agent, label? {agent, checkpoint, reused}
agent.checkpoint_diff read agent, from, to?, repo?, path? {files, patch?}. from and to are checkpoint ids; a missing to means the current files. files[]: {repo, path, change, added, removed, binary}, change being create, modify or delete. With path, patch is {text, truncated, bytes}, cut at harness.body_max
agent.rollback write, and section 11.4 agent, checkpoint, restore?, outside?, keep_head?, dry_run?, plan? Below

Each item of checkpoints is the record in 4.2, without type and agent, plus conversation (section 8) and label if set.

agent.rollback’s result:

Member Meaning
agent, checkpoint, restore, dry_run As asked
plan Dry run only
guarded Any tree of this agent is guarded; dry run only
conversation {rewinds, through_turn, drops, harness_session?}; harness_session is the new one, when rewound
shared_with, children Agent ids it doesn’t touch, and which it will note or list (section 9)
repos[] {repo, path, head: {moves, from, to, commits}, files, left, ignored_edits}
repos[].files[] {path, change, class}
repos[].left[] {path, reason, detail?} (7.4)
repos[].ignored_edits[] Paths an edit call changed since the checkpoint that are ignored, so can’t be restored
safety, after The pre_rollback and rollback checkpoints; real call only
exact The tree came out as expected (7.4 step 6); real call only

dry_run and plan go in exactly one combination each: dry_run=true with no plan, or a plan with no dry_run. Both, or neither, fail with invalid_params.plan. The parameter fragment, in RFC-0004’s format:

.{
    .name = "agent.rollback",
    .summary = "Restore a checkpoint's files, and the conversation where the harness can",
    .params = .{
        .{ .name = "agent", .summary = "Agent ID", .type = .{ .kind = .string }, .required = true },
        .{ .name = "checkpoint", .summary = "The checkpoint to go back to", .type = .{ .kind = .string }, .required = true },
        .{ .name = "restore", .summary = "all: files and conversation, or refuse; files: files only", .type = .{ .kind = .string, .choices = .{ "all", "files" } }, .default = "all" },
        .{ .name = "outside", .summary = "What to do with paths changed outside the agent's turns; refuse in a guarded tree and overwrite elsewhere when omitted", .type = .{ .kind = .string, .choices = .{ "refuse", "keep", "overwrite" } } },
        .{ .name = "keep_head", .summary = "Restore the files and leave HEAD where it is", .type = .{ .kind = .boolean }, .default = false },
        .{ .name = "dry_run", .summary = "Say what would change, and return the plan the real call must quote", .type = .{ .kind = .boolean }, .default = false },
        .{ .name = "plan", .summary = "The plan from the dry run", .type = .{ .kind = .string } },
    },
    .grant = .write,
}

11.2 Events

Published on venom events --json, resource agent:<agent>. None has a snapshot entity: agent.checkpoints is the current list, and a client that starts from a snapshot reads it.

Type data From log record
agent.checkpoint {agent, checkpoint, reason, turn?, through_turn, label?, repos: [{repo, commit}]} checkpoint.taken
agent.checkpoint_failed {agent, reason, code, turn?, repo?}. code is from 11.3 checkpoint.failed
agent.rollback_started {agent, checkpoint, safety, restore} rollback.started
agent.rollback_ended {agent, checkpoint, safety, restore, ok, code?, after?, drops?} rollback.ended

agent.fork_ended gains checkpoint? (section 10). The event agent.checkpoint shares its name with the verb that takes one, as agent.context does in RFC-0019; the event is published for every capture, not only the ones the verb asks for.

checkpoint.reused and checkpoint.pruned are internal log records, and so is checkpoint.removed {agent, repo, removed, kept} from agent.clean. A dry run logs nothing. Effects are checkpoint.capture, checkpoint.prune and checkpoint.rollback, which stay unpublished, like every effect.*.

11.3 Errors

The class fixes the exit code (RFC-0002 section 7.2).

Code Exit When data
not_found.checkpoint 3 Never existed, pruned, or its ref no longer holds the logged commit {checkpoint, repo?}
invalid_params.plan 2 Both or neither of dry_run and plan
denied.agent_caller 4 An agent asked for a real rollback of a guarded tree, or of anything but its own descendant (11.4)
conflict.turn_running 5 7.1 item 4 (RFC-0019)
conflict.handed_over 5 7.1 item 3 (RFC-0019)
conflict.invalid_transition 5 Archived, as agent.fork
conflict.rollback_running 5 A rollback owns the agent or tree {agent}
conflict.checkpoint_pending 5 A capture of this agent hasn’t finished. Retry
conflict.stale_requests 5 Stale inbox items {requests}
conflict.live_children 5 A descendant is live {children}
conflict.tree_shared 5 A sharer is live {agents}
conflict.git_operation 5 A merge or similar is under way {repo, operation}
conflict.checkpoint_files_only 5 restore = "all", on a checkpoint that can’t {why}: mid_turn, overlap, fork, dropped_turn, no_harness_id, catch_up_failed
conflict.head_moved 5 7.5 {repo, expected, actual, branch?}
conflict.commits_pushed 5 7.5 {repo, commits, refs}
conflict.outside_edits 5 7.3 {repos: [{repo, paths}]}
conflict.rollback_stale 5 The tree, HEAD or a choice differs from the plan {checkpoint}
unsupported.harness_capability 10 restore = "all" needs rewind {fix}
unsupported.checkpoint_no_repo 10 The agent’s scope has no git repo
failed.checkpoint_git 1 A git command of the capture failed, or the tree is missing {repo, reason}
failed.checkpoint_too_large 1 Past checkpoints.max_total_bytes {repo, bytes, largest}
failed.rollback_undone 1 7.6 {repo, reason}
failed.rollback_partial 1 7.6 {repos, safety}
failed.rollback_interrupted 1 7.6. Only on agent.rollback_ended {safety}
timeout.checkpoint 7 A capture passed checkpoints.timeout {repo}
timeout.harness_request, conflict.harness_fork_refused, not_found.harness_session From rewind (RFC-0019)

agent.send, agent.fork, agent.compact, agent.takeover and agent.mcp_off gain conflict.rollback_running.

11.4 Grants

Every verb needs its grant on the agent, as RFC-0005 section 5 scopes it. Rollback writes files in a tree the agent already had write over, and worktree isolation already made those trees under write (RFC-0018), so the verb has no files right of its own. Two rules go further, and the daemon applies them from the connection’s resolved principal, never from params:

  • An agent never rolls back a guarded tree. The real call fails with denied.agent_caller. A person’s work can only be overwritten by a person. This covers --outside=keep too: a dry run is allowed.
  • An agent may roll back only its own descendants’ unguarded trees. Itself is impossible (its own turn is running, so conflict.turn_running), and through another agent is a way round the rule above. An agent calling for anything else gets denied.agent_caller.

11.5 Config

checkpoints.* are user-only keys (RFC-0018 section 7), so a cloned repo can’t weaken them. They can be changed with config set.

.{
    .checkpoints = .{
        .enabled = true,
        .max_file_bytes = 52428800,
        .max_total_bytes = 536870912,
        .keep = 100,
        .keep_for = "14d",
        .keep_safety = "30d",
        .timeout = "30s",
        .rollback_note = "Note: the files were rolled back to their state {when}. Changes made after that point are gone, and what you remember of them may no longer be true. Read the files before you rely on them.",
    },
}

enabled = false stops automatic capture only. agent.checkpoint and agent.rollback are explicit asks, and they still work. {when} becomes after turn t3, or before your first turn.

checkpoints.timeout applies to one capture. A rollback’s writes have no time limit, like git worktree remove in cleanup.zig, since stopping halfway would be worse than waiting.

12. TUI

venom-org/venom#158 builds this. Every screen is a view over the verbs (RFC-0018).

  • space a b opens the checkpoint list of the selected agent, newest first: id, reason, turn, age, and files changed since the previous one. ⏎ shows agent.checkpoint_diff. space a k takes a manual checkpoint.

  • R on a checkpoint runs the dry run and shows its result as a confirmation screen: files by class, then left, then what happens to HEAD and the conversation. outside paths come first, in gold. y confirms by quoting the plan.

  • A conflict.outside_edits refusal offers keep those files (default) and overwrite them, then shows the new dry run. There is no way to say yes without seeing a plan.

  • A harness without rewind offers files only, and says the model will be told.

  • Transcript row, always open, with dropped turns folded under it:

    ↺ Rolled back to k2 (after t1) · 1 file · conversation too · undo: k4

The new keys join RFC-0018 section 13.4’s tree through its configurable leader table. They don’t change any existing binding.

13. Security and limits

  • Not a security boundary. Refs and objects live in a repo the agent can write. It can delete them, or move a ref, which venom notices (4.2) and reports as not_found.checkpoint, but it can’t restore what’s lost. What’s in the log can’t be forged from there.
  • Untracked secrets. A .env that nobody ignored goes into the repo’s objects, under a ref, for at least as long as retention says, and stays until gc after that. It doesn’t leave the machine unless something pushes refs/venom/ (5.1).
  • Repo code still runs. Hooks are off, but clean and smudge filters are the repo’s configuration and run during capture and restore, exactly as they do for git status and git checkout. venom doesn’t run on repos the user hasn’t added.
  • Disk. Every capture of a changed file writes a blob. max_file_bytes and max_total_bytes bound one capture, and keep bounds the count, but a deleted ref’s objects stay until the repo’s gc runs. doctor reports the ref count per repo.
  • Files outside the repos in scope aren’t captured. A whole-workspace agent that edits a file beside the repos can’t get it back.

Agent-contract impact

  • Verbs: agent.checkpoints, agent.checkpoint, agent.checkpoint_diff, agent.rollback (11.1). agent.clean’s result gains checkpoints. No change to existing params.

  • --json and exit codes: the codes in 11.3, all in existing classes, so no new exit codes.

  • Events: four new agent.* types and checkpoint on agent.fork_ended (11.2).

  • skill: venom’s skill documents rollback’s two calls, and that a person must run a real rollback of a guarded tree.

  • doctor:

    Check id ok when Fix
    checkpoints.git git on PATH, at 2.31 or newer (--path-format) The system’s update command
    checkpoints.refs No repo has refs no log record names, or more than keep per agent (warn) venom agent clean
    checkpoints.mirror No remote of a repo in a workspace has mirror set (warn) git config --unset remote.<name>.mirror
    harness.<h>.rewind warn when an adapter withholds rewind for a harness newer than tested_version The harness’s or venom’s update command
  • config: the checkpoints.* keys (11.5), all user-only.

  • Grants: 11.4. The default stays read-only.

  • Headless, OSC 7501 / OSC 133: no change; all of this works without the TUI.

  • RFC-0019: amended by this RFC, with a line in its header: the rewind capability and operation, start’s fork_at, turn.ended’s harness_end, and checkpoint on agent.fork_ended. All are optional and compatible (RFC-0019 section 14), so adapter stays 1.

Alternatives

  • Only at turn end. One ref per turn, simplest, and what t3code does. Rejected: it can’t tell a person’s edit between turns from the agent’s, and that distinction is the whole of 7.3. Thanks to 3.2 the extra capture usually makes no ref at all.
  • Only on request. Cheap, and useless at the moment you need one, because you didn’t ask.
  • Capture with git stash create. It leaves untracked files out, and -u writes to the real index’s state and refs/stash. Rejected.
  • git worktree add a clean tree and rsync. Would avoid touching the index by a different route, at a far higher cost per turn.
  • A private object store (GIT_OBJECT_DIRECTORY under venom’s state, with alternates). It would keep the user’s repo entirely clean, with no git log --all noise and no secret in the repo’s objects. Rejected for v1: restore, diff and the “just use git” story all get harder, and the ecosystem doc already chose hidden refs. Worth revisiting if the noise annoys anyone.
  • Capture ignored files, or a list of them. See section 4. A later, opt-in list per repo wouldn’t break anything here.
  • git checkout <commit> -- . or git reset --hard to restore. They rewrite the real index, and reset moves HEAD. In a guarded tree that breaks invariant 4, and neither deletes files created since. Rejected for the path-by-path recipe, which the guarded case needs anyway. Using one recipe in both scopes also means one thing to test.
  • Refuse an in-place rollback outright. Safest and simplest, and it would leave in-place agents, where a bad turn hurts most, with nothing. The classes and the safety checkpoint make it safe enough.
  • Attribute paths by watching the filesystem (inotify during turns). It can’t name the process either, misses events on overflow, and is a daemon-wide watcher per tree. Rejected.
  • Make rollback one call with --yes. Rejected: a confirmation that doesn’t bind to what was shown protects nothing in a tree that changes. The plan does.
  • Automatically interrupt a running turn on rollback. Rejected: see section 9.
  • Harness-native file undo (Claude Code’s rewindFiles, --rewind-files). Its own documentation says it doesn’t undo what a Bash command did, it tracks only tool edits, and Codex has no equivalent. Rejected as the mechanism.
  • Shorten the conversation in place. Codex 0.167.0 has no such call. Claude Code’s --resume-session-at without --fork-session would continue on the same id. Rejected: a copy is what lets a failed rollback change nothing.
  • Do nothing. Users would use git reset by hand, in a tree shared with an agent’s conversation that wouldn’t know.

Open questions

  1. Is --resume-session-at a fair channel for claude-code’s rewind? Decided for now, with doubts recorded: #120. The Agent SDK reference documents the resumeSessionAt option, but the CLI flag behind it isn’t in the CLI reference or in claude --help, and RFC-0019 asks for documented channels. It’s the vendor’s own binary under the user’s own sign-in, and a flag the SDK itself passes, so this isn’t spoofing of any kind, and venom bundles no SDK. Until the question is settled, the adapter declares rewind only for versions up to its tested_version. doctor warns when it withholds it, and --restore=files keeps working. The maintainer may prefer to wait for documentation, in which case claude-code declares rewind: false, and nothing else in this RFC changes.
  2. A fork in its own tree at a checkpoint. Deferred: #121. It is what “try again from here, and keep what I have” wants, and it needs a worktree created from a checkpoint commit and a conversation resumable from another folder. Codex’s thread/fork takes a cwd. Claude Code keeps a session per folder, and moving one isn’t an official operation.
  3. Checkpoints in a workspace with no git. Deferred: #122. A hidden GIT_DIR under venom’s state could capture such a folder, but ignore rules and size limits have nothing to go by there. Until then, section 5.1’s unsupported.checkpoint_no_repo is the defined result.