# Shipped: Handing a File Off Without Abandoning It

> In v0.15.0 an agent can hand a file to a named successor, and on a single host the Python coordinator refuses the giver's own writes until the handoff ends.

Published: 2026-10-14 · Canonical: https://agent-coherence.dev/blog/shipped-grant-handoff/

---
Agents that split a job pass files to each other all day. Getting the first agent to actually let go of one is the hard part.

Take two Claude Code sessions in one repository. A drafting agent writes `plans/rollout.md` up to version 4, and a reviewing agent takes it from there. Until this release the drafter's only way to say it was done was to release its grant, which the Claude Code plugin does on its own at the end of every turn.

Twenty minutes later the drafter's user asks for one more change to the canary percentages, and the drafter makes it. To the coordinator that is an ordinary edit: a fresh read, a fresh grant, a commit at version 5. The reviewer's next read gets a stale warning, which is [invalidation](/blog/shipped-write-side/) doing its job. What no warning can say is that the drafter was supposed to be finished, because nothing recorded it. A release looks exactly like a session that wandered off.

## The key idea: a handoff names who comes next

The drafter hands `plans/rollout.md` to the reviewer by name, and the coordinator fences the drafter's writes to it until the handoff ends.

The transfer stores a record per path: the giver, the successor, the version at transfer, the kind of claim the giver gave up and a status that starts as `pending`. The state log records the giver's move under a new trigger, `handoff`, rather than as a release. The reviewer is granted nothing. It reads and acquires the file by the ordinary rules, and its acquire, its optimistic commit or an explicit accept marks the record `completed`.

In Claude Code the drafter runs the transfer in the same turn as its edit, because a session's write grant ends with its turn. It names the reviewer by its session-level agent id, which the guide shows how to print from the reviewer's shell:

```bash
agent-coherence-transfer --successor 6e271ee6-5a71-5508-bedf-ffd46d8898ce plans/rollout.md
```

The Python package's `agent-coherence-status` then lists `plans/rollout.md: bd35b34c → 6e271ee6 at version 4 (pending)` under Handoffs.

## What happens when the drafter tries to write again?

The coordinator refuses it. While the record is live, every write route answers the drafter's session `handed_off`, naming the successor and the version at transfer, and lands nothing. The fence is keyed on the session, so the drafter's subagents and fresh attempts are refused alike, and no retry or reacquire clears it. No timer lifts it. Exactly three things do: a commit moves the version, the successor declines, or the giver withdraws.

| What happens next | Record status | Can the drafter write? |
|---|---|---|
| The reviewer reads the file | `pending` | No |
| The reviewer accepts, or starts editing | `completed` | No, until a commit moves the version |
| The reviewer commits version 5 | `completed`, ended | Yes, by the ordinary rules |
| The reviewer declines | `declined` | Yes, by the ordinary rules |
| The drafter withdraws, on its user's instruction | `withdrawn` | Yes, by the ordinary rules |
| A third session edits first | `overtaken` | No, until that session's commit moves the version |

Accepting leaves the fence up on purpose, so a late drafter write cannot land at version 4 ahead of a reviewer that has not written yet. It is a different fence from [the token that stops a zombie write](/blog/lock-lease-fence-shared-write/), which asks whether ownership changed since you read. This one asks whether you gave the file away.

In Claude Code the refusal is a deny on the drafter's next Edit or Write, in warn mode as well as strict, opening with "Edit denied: you handed plans/rollout.md to agent 6e271ee6 at v4, so this session can no longer write it." It tells the session to stop and report, and names no command, because a refusal that hands a model the command that lifts it invites the model to run it. Recognized shell writes, such as a redirection, `tee`, `sed -i` or a `cp` onto the file, get the same deny.

We measured it with Haiku 4.5, Sonnet 5.5 and Opus 5.5 on Claude Code 2.1.291. Every session that met the deny stopped after that one refusal and told its user the file had been handed off. Without the shell deny, one warn-mode setup saw Sonnet append to the file through the shell in 4 of 10 runs. With it, that setup changed the file in none of 10.

## Four ways to hand a file on

| Surface | Hand a path on | The giver's next write gets |
|---|---|---|
| Claude Code session | `agent-coherence-transfer` | a deny on Edit, Write and recognized shell writes |
| `CoherentVolume` | `vol.transfer(paths, successor=...)` | `GiverFenced`, never retried |
| `stale-write-guard-fs` MCP server | `swg_transfer` | `handed_off`, with `recover: stop_and_report` |
| HTTP | `POST /handoff/transfer` | `{"ok": false, "reason": "handed_off"}` |

Each surface has matching accept, decline and withdraw verbs, and a third session is told its edits overtake the handoff. The routes are Python coordinator only, so the Claude Code plugin's Node coordinator, the default for a fresh plugin workspace, answers them 404. And a model using both the plugin's hooks and the MCP server is two sessions, so a handoff fences only the one that gave it.

## A caller principal, and what it is not

The coordinator authenticates the workspace, not the caller, and the session a request acts as is a field in its body. A copied request or a stale session id could release another writer's grants or commit under its name.

A caller principal binds a value to a session on its first `POST /principal/claim`, and every bundled client claims and sends one automatically. From then on, the hook routes that take, release or commit that session's grants, the effect fence, the checkpoint and restore routes and the four handoff routes answer HTTP 400 to a request naming the session without it. The snapshot-session routes are the exception. `/session/commit` and `/session/commit_all` still admit a request with no principal and commit as the session it names. The coordinator admits sessions that never claimed a principal as before.

A long-lived client such as `CoherentVolume` or the MCP server presents only its own principal, so the coordinator refuses a request it sends naming another session. Between the drafter and the reviewer, two Claude Code sessions, the principal does not tell one from the other. The hook client and the handoff commands find a principal in `.coherence/` by session id, so the reviewer's session could still run `agent-coherence-withdraw --session` with the drafter's session id and lift the fence. On that surface it catches only a client that never claimed a principal or presents the wrong one. Principals come from the Python coordinator only, and the plugin's Node coordinator issues none.

It is not a security boundary. The bearer secret still grants full authority, and every principal sits in `.coherence/state.db`, readable by any process running as your OS user.

## The effect fence, over plain HTTP

Once the reviewer approves the plan, it opens the rollout PR, an effect that cannot be called back. Until now only `gate()` in Python and `swg_gate` over MCP could ask the fence whether the input or its grant moved first. `POST /hooks/effect-fence` lets any HTTP client ask, behind the same loopback check and bearer token as every route. It takes one artifact with the `session_id`, `path`, `expected_version`, `expected_generation` and `content_hash` the caller captured, and answers `{"verdict": "proceed"}` or a hold with a published `reason`. A missing field is a 400, not a hold. Anything else that is not a recognized verdict counts as a hold, the Node coordinator's 404 included, since it does not serve the route.

It also holds a case the other surfaces cannot see, an artifact whose content the coordinator never recorded, under an eighth reason, `content_claim_absent`, which a coordinated re-read clears. The fence orders an effect and never rolls one back. It narrows the window between the check and the effect without closing it, and it holds only effects that ask.

## Who may restore a checkpoint?

Say the drafter checkpointed `plans/rollout.md` before the handoff. A restore's registration used to check only that the manifest existed. Now the coordinator checks it against the checkpoint's members and captured fingerprints, with typed refusals that change nothing. A checkpoint can also name a receiver, the one controller allowed to register it, through `WorkspaceVersioner.checkpoint(name, receiver=...)` or `receiver_session_id` over HTTP. The first controller to register claims the checkpoint for good, so one that stops partway leaves it registrable by no one else. Like the principal, this separates writers that follow the protocol and is not a security boundary.

A restore also reports what it overwrote, with `observed_differs` naming the discarded version and a digest of its content, though it is still [a forward commit, not a merge](/blog/shipped-workspace-versioning/). And `WorkspaceVersioner.release_checkpoint` drops the S3 legal holds a checkpoint placed. It keeps a hold only while another checkpoint in the same registry still uses it, so use one registry per bucket.

## What can an operator read from /status now?

The operator view, `?detail=full` with the `Coherence-Local-Operator: true` header, now shows which grants the sweep reclaimed and when, each write grant's start time and each session's last heartbeat beside the sweep's thresholds, and who last wrote each artifact. That last field is attribution, not authentication: it names the session the committing request named. Every view reports `registry_schema_version`, 10 in this release. The default view carries each path's handoff record and no longer publishes raw session ids or the absolute root, and a busy registry answers `"degraded": true` with null lists, which mean "cannot tell", never "nothing tracked". This is the Python coordinator's `/status`. The plugin's Node coordinator answers the operator view with 501 and serves the other views in a shape of its own.

## Also in this release

- Strict mode no longer lets a session write a file it was just refused a read of, on both coordinator backends.
- A first read that a peer's commit overtook no longer lets `write_cas` win over that peer's update with no exception.
- The coordinator writes policy files as quoted YAML, so tracking a glob such as `*.md` no longer empties the policy file while the request answers `ok`.
- On the Python coordinator, untracking a strict path is a deliberate HTTP 409, `untrack_strict_path`. Remove its entry from `.coherence/strict_mode.yaml` and restart the coordinator instead. The plugin's Node coordinator does not refuse it yet, so there an untrack still takes the path out of strict mode.
- Coordinator requests no longer go through a configured HTTP proxy. If one carried your bearer token under an earlier release, replace the workspace's bearer as the release notes describe.
- A new offline demo, `python -m examples.session_handoff.main`, passes work between two session processes on one host through a shared file and a checkpoint restore, without the transfer verb. Run it from the root of a repository clone after `pip install -e .`, since the examples do not ship in the wheel.

## Upgrading from 0.14.1

The registry moves from schema 7 to 10 on first open, the workspace CLI's `workspace.db` included, and 0.14.1 refuses a store this release has opened. Back up first: stop every session, volume and MCP server, run `agent-coherence-coordinator --prepare-for-migration`, and copy `.coherence/` with its permissions. Without that copy, the only way back is to move `state.db` aside and lose its retained content, fence state, principals, handoff records and checkpoint claims.

Upgrade the coordinator, its clients and the plugin together, with Claude Code plugin 0.6.0 or later. A plugin 0.5.0 hook client still works but claims no principal. Once a 0.15.0 client such as `agent-coherence-transfer` claims one for its session, the coordinator refuses that hook client's edits, commits and releases with HTTP 400, and its edits go ahead uncoordinated. The guide's [Upgrading from 0.14.1](https://github.com/cohexa-ai/agent-coherence/blob/v0.15.0/docs/guide.md#upgrading-from-0141) section lists every changed answer.

## Scope, stated plainly

Single host, one coordinator, writers that go through it. That has not moved, and production cross-host fencing still has not shipped.

A handoff fences the giver and reserves nothing. It cannot stop a write that bypasses the coordinator, such as an editor or a script outside the hooks, and it denies a Claude Code giver's shell write only in the forms the Bash hook recognizes. A write to a path built from a variable, a write through a writer tool the hook does not know, and an edit already in flight at the transfer all still land on disk, without a version. The known limits you are most likely to meet:

- When all eight of the Python coordinator's handler slots are busy it answers 503, which both hook clients turn into an empty answer. A stale warning is lost, a strict-mode deny becomes an allow, and so does the handoff giver's deny, which lets the giver's edit land without a version.
- The Bash hook resolves relative paths from the coordinator root, so after a `cd` in an earlier Bash call, and in every `claude --worktree` session, the giver's shell-write refusal can miss a relative path.
- A `CoherentVolume` or MCP session's handoff outlives its process, and nothing can withdraw it after the process exits.
- After a decline or withdraw, the coordinator refuses a new transfer of the same path to the same successor as `handoff_ended` until the version moves or it evicts the ended record, 24 hours later by default.
- A Claude Code session whose principal is refused and cannot be claimed again fails open, and its later edits run uncoordinated.

The full list is under Known limitations in the v0.15.0 release notes, and [the taxonomy](/blog/silent-data-loss-taxonomy/) covers which failures are the runtime's to prevent at all.

## Get it running

A release says "I am done." A handoff also says who is next, to the giver, the successor, every bystander and `/status`, until the work moves on. That record turns a file passed between agents from an assumption into something the coordinator enforces on a single host.

```bash
pip install agent-coherence
```

For Claude Code, use plugin 0.6.0 or later on a workspace the Python coordinator serves. A workspace stays on the backend that created its store, and no tool converts one, so do not switch a workspace the Node coordinator already serves. A new workspace with no store yet can select Python before the first session:

```bash
mkdir -p .coherence && printf 'python\n' > .coherence/coordinator_backend
```

The guide's [Targeted grant handoff](https://github.com/cohexa-ai/agent-coherence/blob/v0.15.0/docs/guide.md#targeted-grant-handoff) section walks two Claude Code sessions through it step by step. Start on one host, with the file your agents already pass between them.
