# Shipped: A Checkpoint That Tells You What It Cannot Restore

> v0.14.0 brings workspace checkpoints with per-member honesty, an effect fence on the MCP surface, and a session that re-anchors after compaction.

Published: 2026-09-09 · Canonical: https://agent-coherence.dev/blog/shipped-workspace-versioning/

---
Checkpointing a workspace is easy if you are willing to lie about it. The hard part is telling the truth about the members you cannot bring back.

Take a run that goes wrong. An agent revises `plans/rollout.md`, writes a results object to S3, and posts a summary to a Slack webhook. Twenty minutes later it is clear the whole pass was working from a bad premise, and someone asks the reasonable question: can we put it back the way it was?

Two of those three can go back. The third is gone, and it was gone the instant it left. What shipped in v0.14.0 is a checkpoint that says which is which, out loud, per member, before you ask.

## The key idea: a checkpoint grades what it cannot restore

`WorkspaceVersioner` checkpoints a workspace whose members live in different backends: files on disk through the coordinator's version retention, S3 objects through `CoherentObject`, and declared forward-only action surfaces. A checkpoint is a named manifest capturing, per member, a restore pointer (an S3 `versionId` or a file content-state version), a fingerprint, and an honest restore tier. The manifest is pointers, not a second copy of your bytes: an S3 member's history stays in your own bucket, and a file member's recoverable versions live in the coordinator's bounded retention window.

| Member from the run above | Tier | What that means |
|---|---|---|
| `plans/rollout.md` | `restorable` | the pointer was captured and the member can be driven back |
| the S3 results object | `restorable-unpinned` | pointer captured, but the bucket has no Object Lock to hold that version |
| the Slack post | `forward_only` | already left, and the manifest says so |

Tier and pin state are separate axes, which is why `status` renders them as a pair. A member can sit at `(restorable, unpinned)`, which the CLI labels claimed-but-not-yet-backed: drivable in principle, with nothing holding the version for you.

That middle row is the one worth dwelling on. A bucket without Object Lock durably downgrades its member to `restorable-unpinned` with `pin_state="pin_unavailable"`, rather than claiming a pin it does not have. A file member's pin verifies the captured version against the bounded retention window, which it cannot extend, and an expired window surfaces as `target_lost` rather than silently. Every checkpoint prints the file-retention caveat, because a file tier is a claim about a window that can close.

The capture is a skew-declared cut. A workspace with many members cannot be photographed in an instant, so rather than hide that, the window `[window_min, window_max]` is recorded and a verification pass flags any member that moved inside it as `dirty_during_window`. Absence is captured as a fact, distinct from present-and-empty, which is why restore includes delete legs.

## What restore promises, per member

Restore drives one conditional write per member under a termination contract. Every member reaches exactly one terminal outcome: `restored`, `converged`, `conflict`, `target_lost`, `forward_only_skipped`, or `held_unconfirmed`. Progress is durable and crash-resumable, and a member whose live state already matches the manifest concludes `converged` without a write, so a crash between a landed leg and its durable outcome record cannot double-apply.

The arbitration is not uniform across members, and flattening it would be the easy lie. The S3 leg is native CAS, an `If-Match` put where the substrate itself arbitrates a racing foreign writer. The file leg is no-arbiter: a version-checked CAS whose foreign-edit signal is adapter-local detection only, and every file outcome is labelled as such rather than presented as substrate arbitration.

Back to the run. Suppose someone edited `plans/rollout.md` by hand after the checkpoint was cut. If that edit landed and stopped, the restore wins and writes over it, and the edit survives only as history. If they keep editing, the contended leg re-drives under a bounded budget and the sustained foreign writer wins honestly, recorded as `conflict`. Never a livelock, never a silent clobber.

Two properties matter more than the feature. Restore is a forward commit carrying old bytes, so versions strictly increase: you get the earlier content back as a new version, and nothing rewrites the record of what happened in between. And a member the engine cannot drive, an unreadable path or one that became a symlink, a hardlink with an outside co-owner, or a non-regular file, is absorbed as that member's `target_lost` so the run concludes and reports honestly instead of stalling mid-restore.

The boundary this series keeps returning to is now enforced rather than asserted: restore is over artifacts, never effects. The Slack post has no tier that brings it back. Naming it forward-only in the manifest, instead of quietly leaving it out of a restore count, is the same discipline as [saying what is verifiable and labelling the rest](/blog/self-review-corrupted-question/).

The operator surface is `agent-coherence-workspace`, with `checkpoint` / `list` / `status` / `restore`, `--root` and `--json` on every verb, and a four-way exit-code contract where `3` means the restore concluded with absorbed outcomes and the per-member report on stdout is the truth. It covers file and forward-only members. S3 members ride the Python API, because the bindings carry credentials, and the CLI refuses cleanly and points you there rather than half-working.

```
python -m examples.workspace_versioning.main
```

Offline and deterministic. Add `--baseline` to watch the loss happen first.

## The effect fence learns to ask about ownership

The bigger half of this release is a fix, and it is worth stating plainly because the gap was real.

`gate()` re-validated by comparing versions alone. A version answers only whether the value is still the one the decision saw. It never asks whether the grant it was read under is still standing, and a coordinator sweep that reclaims a stalled holder advances the ownership generation without moving the version. So a reclaimed holder's escaping effect, the webhook or the deploy or the opened PR, fired straight through the gate on revoked authority. In strict mode it fired through the deny.

`gate()` now captures the `(version, owner_generation)` pair at decision time as one pair-atomic registry snapshot, so a concurrent sweep cannot tear it, re-reads the pair at the effect boundary, and holds if either moved. It is fail-closed on both comparands, an unconfirmed version or an unconfirmed generation holds rather than proceeds, and `formal/tla/EffectGate.tla` model-checks the property as `NoStaleAdmit`. This is the same distinction the v0.9.1 read-generation fence already drew at the commit seam, now applied at the effect boundary.

## The same fence, for MCP clients

`gate()` wraps a decision and an effect as Python callables, which makes it useless to an agent whose decision and effect happen between tool calls, outside the process. So the `stale-write-guard-fs` server gained a sixth tool.

An MCP agent carries the `(version, owner_generation)` pair that `swg_read` now returns, and calls `swg_gate(path, expected_version, expected_generation)` immediately before anything irreversible: a webhook, a deploy, an opened PR, a posted message. It answers `decision: "proceed"`, or denies with the surface's existing `reason: "stale_view"` carrying a typed `hold_cause` that names which class fired, so the agent branches on a value rather than parsing prose. Omitting the generation comparand is refused as a typed agent error rather than a retryable deny, so a cooperating agent cannot loop on advice that could never clear.

The generation leg is the one a version check cannot replace, and it is worth being exact about when it moves. The epoch bumps when the coordinator's crash-recovery sweep reclaims a grant, on a heartbeat timeout or the max-hold ceiling. In that case the stalled agent's file may be untouched, so the version still matches and only the generation moved. That is [the distinction the fence draws at the commit seam](/blog/lock-lease-fence-shared-write/), now available one step earlier, at the effect boundary. Same honest boundary as the Python gate: the verdict is true as of that call, and the dispatch after it is still the agent's own step.

## A session that knows what it was holding

When Claude Code compacts a session, the model's summary can silently drop what the session held and what peers changed around that boundary. It reads as the model degrading. It is closer to amnesia with confidence intact.

The coordinator now answers `POST /hooks/session-start` with a bounded re-grounding payload: the grants the session held at compaction, event-anchored, and for each artifact it touched, the current coordinated version with a stale flag when a peer advanced it. The comparand is durable, a nullable `agent_states.last_observed_version` column recorded in the same transaction as the grant or commit that observed it. The `agent-coherence-hook-client` gains a `session-start` subcommand for Claude Code's `SessionStart` hook, gated client-side on `source: "compact"`, so ordinary starts, resumes, and clears never reach the coordinator. Plugin v0.5.0 is the release that wires it into a Claude Code install.

It is fail-open by design. A coordinator that is down at the compact boundary emits `{}` and that compaction's re-grounding is lost, because coordination should never block the session.

## Also in this release

- `CoherentObject` version-axis extensions: `read_versioned` and `cas_write_versioned`, pinned historical reads, unconditional delete, and legal-hold operations with typed errors for unversioned buckets.
- Packaged conformance corpus: `ccs.testing.substrate_conformance` moved out of the test tree into the installable package, with a new `WorkspaceConformanceBinding`. Implement it, declare your capabilities honestly, and the suite runs the scenarios every implementation must reproduce, judged by observable outcome rather than by mimicking internals.
- `formal/tla/WorkspaceVersion.tla`: restore registration is model-checked, joining the eight specs that run under TLC in CI on every push, each carrying a documented mutant that must fail.
- Registry schema v5 and v6: durable workspace-checkpoint tables, and the `last_observed_version` comparand behind re-grounding.

## Scope, stated plainly

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

Restore covers artifacts and never effects. No tier of this engine reaches a message that was already sent, a webhook that already fired, or a deploy that already ran, and the forward-only tier exists so the manifest says so rather than implying otherwise. Those belong to your outbox and your idempotency keys, and [the taxonomy](/blog/silent-data-loss-taxonomy/) covers which failure classes are the runtime's to prevent and which are the application's. The TLA+ specs model-check the protocol under bounded exploration, which is a different and weaker claim than proving the implementation.

## Get it running

```
pip install agent-coherence
```

The workspace demo runs offline with no keys, and `--baseline` shows the loss before anything prevents it. The manifest format, the tier definitions, and the S3 credential posture are in the guide at [github.com/Cohexa-ai/agent-coherence](https://github.com/Cohexa-ai/agent-coherence). Start on one host, on the workspace you already have.
