# Lock, Lease, or Fence: Which Stops a Zombie Write?

> A lock, a lease, and a fencing token answer three different questions. The write your version check passes is the one the fence refuses.

Published: 2026-08-12 · Canonical: https://agent-coherence.dev/blog/lock-lease-fence-shared-write/

---
A fleet of agents working from a single common queue is how you scale the work. An agent finishing a write it no longer owns is how you corrupt it.

The pattern is never different. Every worker in the fleet updates the order records the same way: take over a task, get a write grant for one record, make the call to the third-party enrichment API, write the result back, then release the grant. The lease period for the grant is thirty seconds, because the enrichment call takes four. On Monday the process works smoothly all day long. On Thursday the enrichment provider is slow, and one call takes ninety seconds. Your supervisor does the right thing: at thirty seconds it reclaims the write grant that had become stalled on `orders/4412` and assigns the task to a new worker, which finishes and moves on. Later, the slow call finally completes, and the first worker, not being aware of the delay, carries out its write.

Nothing errors. Depending on the timing, the record now shows the enrichment twice, or the more recent result from the new worker is lost. The run is displayed as green. The cause was a slow day at a vendor you do not control, and it appears in no changelog of yours.

You have three options to reach for: a lock, a lease, or a fence. It is generally the belief of teams that these are just different names for the same thing, or merely different ways of expressing the same kind of paranoia. They are neither. Here is the key idea: each of these mechanisms addresses a different question about a write, those questions typically arise in the order specified as the fleet grows, and the particular mechanism you are missing can be determined by the type of failure you have just encountered.

## Three questions, not three flavors

**A lock replies "is someone else writing right now."** This is the first point raised, and in the context in which it is made, that is accurate. It is impossible for two workers to hold `orders/4412` at the same time, which means overlapping writes to the same record are avoided. The failure it leaves behind is a dead holder. If a worker acquires the lock but then crashes, or continues to wait indefinitely because of the slow API call, nobody will be able to write to the record from that point on, and the whole system ends up safe but frozen. For that reason, nobody leaves a bare lock in place for long.

**A lease is the means of answering "is my window still open."** It is basically a lock combined with a clock. When the grant expires, the supervisor takes it back, and the fleet carries on, passing by a worker who is no longer active. This is the mechanism explained in the Thursday story, and it worked exactly as intended. The reclaim was carried out properly. The failure resulting from a lease has two parts. The first is that teams set the timeout to match the happy path. Thirty seconds seems a reasonable amount against a four-second call, until a retry policy causes that call to be made three times, or an autoscaler increases the queue depth, or the provider slows down without notifying you. In these circumstances your code is not affected. The time period you measured simply ceases to be the actual time you have. The second and more important point is that even though expiry enables the system to move on, it does not stop the lease holder from acting later. The lease caused the grant to be reclaimed, but it did not cancel the stalled worker's future write. That write will still happen. Remember to whom the lease is accountable: it tells the supervisor "you may reclaim." The holder's own answer to "is my window still open" must not be trusted, which is why the question has to be repeated at commit time.

**A version check asks whether the value is still the one it read.** Most agent stacks regard this as their fourth mechanism and halt there, and in fact this does offer a reliable way to stop. The writer transmits the version number it read, and the store will only go ahead with the commit if the record is still at that version. If it is not, a conflict is produced instead of permitting a silent overwrite. This avoids the problem of concurrent lost updates, in which two workers compete for the same key. As illustrated in the Thursday story, if the new worker had committed version 8 while the zombie was still holding version 7, the zombie's late commit would have failed the check, and therefore would have been detected.

Yet the alternative ending does occur. The new worker was dispatched to carry out the task, looked at the record, and then either finished without making any entry or else prepared a different record. The version of `orders/4412` stays at 7. The zombie comes to life and performs the action by using version 7 against version 7. The check is approved. The write is then entered into the state the system had decided upon long before, under a grant which lapsed at second thirty. The version check is not wrong. It is correctly answering its own question, since the question of ownership had never come up. A version check which acts only in response to data writes cannot pick up on this ending, and this is the type of version check used by almost every stack.

**A fence answers whether the grant you read this under is still yours.** It assigns each record an ownership generation and increases that generation each time there is a transfer of ownership, on a reclamation or when a new grant is issued, and never in response to a data write. It records the generation number given to the writer when the record was acquired, and at the time of commit it checks these two numbers in a single atomic operation. This is the function carried out by fencing tokens in Martin Kleppmann's ["How to do distributed locking"](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html): they function as a counter which the storage layer examines in order to reject a late write from a holder whose grant has expired. In Kleppmann's approach, the token is incremented every time it is acquired, and the store throws away anything that is older. Here, the generation number achieves the same effect with the grant authority and the store in a single place. Now the two endings come together:

```
t0   worker A claims orders/4412. version 7, generation 3.
t1   A calls the enrichment API. The call hangs.
t2   A's lease expires. The supervisor reclaims the grant
     and hands it to worker B: one transfer, generation 3 -> 4.
t3   B holds version 7 under generation 4. Two endings:

Ending one: B commits. version 7 -> 8.
     A wakes, commits (version 7, generation 3).
     The version check refuses it: 8 != 7.

Ending two: B never writes this record. version stays 7.
     A wakes, commits (version 7, generation 3).
     The version check PASSES: 7 == 7.
     The fence refuses it: generation 4 != 3.
     This ending is the reason the fence exists.
```

A bump is also given when a clean release is followed by a new claim. It is precisely this point which prevents a delayed copy of an old commit, a queued retry, or a message that had taken the long route, from presenting the same numbers and therefore getting through.

The second ending is a quiet one, so it is necessary to establish exactly what has gone wrong, since no one else has entered the record. When A makes the late entry, worker B has already looked at `orders/4412`, concluded that enrichment did not apply, and has already recorded that decision in the task ledger. A's entry now contradicts a decision which the system has already put into effect, and a downstream consumer that had taken version 7 as the final version has already moved on. Being late is not the same as being harmless.

The refusal is typed, so the zombie does not suddenly fail. Instead, it is informed that the grant it had been operating under is now dead, and it can therefore examine the situation once again and make a new decision rather than sticking to its old plan.

Before introducing a second counter, consider a simpler alternative: each time a reclamation occurs, have the supervisor carry out an empty touch write on the version. With this approach, the zombie will fail the standard version check, and there will be no need for a generation. This method is practical, and it makes sense when you are writing your own code. The drawback is that one counter has to handle two different questions. Because of the write that achieved nothing, each reader's cache becomes out of date, and the writer who is refused receives a general version conflict even though the correct answer should be that your grant has died. This point is important, since the recovery actions are different. A version conflict means you have to re-read and re-derive. A dead grant means your entire run may already have been superseded. By keeping the generation separate, the two questions can be dealt with independently. That is the trade-off, and it is a choice, not a law.

## What kind of failure does each one stop?

| Mechanism | The question it answers | Stops | Cannot see |
|---|---|---|---|
| Lock | Is someone else writing right now? | overlapping writers on one record | a dead holder (the fleet waits forever) |
| Lease | Is my window still open? | deadlock on a dead holder | its own expired holder acting late |
| Version check | Is the value still the one I read? | the concurrent lost update | a late write over a value nothing else touched |
| Fence | Is the grant I read this under still mine? | the zombie write | (adds the ownership axis the other three lack) |

They accumulate. The fence does not replace the version check. Rather, it deals with the ending that the version check cannot detect structurally, and the version check continues to have a responsibility which the fence cannot take on, namely that of the writer who reads without ever accepting a grant and who follows the optimistic path. In the case of grant holders, it is the fence that becomes dominant, and in the first ending both checks refuse, the version simply getting there first. This establishes a dependency order, a build order rather than the order in which the failures appear to you. The versioned state comes first, since nothing can be rejected as stale until something has first defined what stale means. Then comes the conditional write, which acts on those versions. Then the fence, which operates on reclamation. Beyond all three lies the boundary which none of them crosses, namely what a worker has already done in the outside world, and that part belongs to your outbox and your idempotency keys, not to any coordination primitive.

## What lets a fleet skip the fence and still feel fine?

The trigger has still not been pulled, and this fact is easily taken as an indication that the fix is holding.

A fleet with a lease period generous relative to the work it is required to perform almost never needs to reclaim when operating under ordinary conditions. In the absence of reclamation, there will be no zombie and no obvious need for a fence. There will therefore be a number of months when the system functions without any problems. However, eventually one of the Thursday triggers will be activated: a retry storm, a change in load, or a dependency that is itself running more slowly than its scheduled rate. Reclamation then starts to overlap with live writes, and the zombie write takes place without any modification to the code on your end. The failure was not missing. It had in fact been there all the time, and it depended on a timing factor which is outside your control.

The high cost arises since both elements of the failure, the write and the cause of it, go undetected. A zombie write is one that succeeds, which is the reason why it does not produce any exception to page you. The trigger lies outside of your system, meaning there is no deployment, no difference in configuration, and no entry in the changelog that could be used to identify it. From an external point of view, "we have not seen it break" and "it broke and we never noticed" mean exactly the same thing. This is the reason why the suggestion "we will put in the fence when we start seeing the problem" cannot work as a solution, since it is precisely the problem that you never get to see.

## Generalize it

The mechanisms in question are not particular to any given agent, and in all cases they depend on the nature of the exposure. Each time a system reads some data, holds on to it, and then writes it out later, it will have these kinds of windows. Agents retain the data which is being read for the entire duration of one reasoning pass, a period that is many orders of magnitude longer than the time that a service keeps the data, and all the artifacts that are generated during this time period escape before a commit check is performed. Memory layers are now progressing in the direction of offering shared, concurrent access to this kind of state, and it is in this context that the four questions stop being purely theoretical. If your architecture actually ensures that writers are serialized on a per-key basis, or if your store is append-only, then you have removed these failure modes, and it is only the other features of [the taxonomy](/blog/silent-data-loss-taxonomy/), namely the durability receipt and the retention policy, that remain applicable in your case. The mechanisms described here apply to shared mutable state that is edited in place by multiple actors, which is exactly the type of state a shared workspace ends up with [as it grows](/blog/what-breaks-first-agents-share-workspace/).

## You are here

The methods currently employed by a single host along one coordinator's commit path are a fail-closed denial whenever a stale reader tries to write, the use of `write_cas` to check versions, and a read-generation fence that rejects a superseded grant with a typed `stale_read_generation`. These constitute [the write-side receipt](/blog/shipped-write-side/), and with each push the TLA+ model of the protocol is verified, the model containing the invariants in question (single writer, no lost update, no stale apply) as well as a deliberately included mutant which must cause the checker to issue an error. As a comment on terminology for readers who are familiar with distributed systems: since the grant authority and the commit path are co-located, the equality check corresponds to the degenerate case of Kleppmann's token. The cross-host case, where the check involves the highest token seen at a store that is not the grant authority, is precisely the part that has not yet been shipped.

The following features are not included: production-level cross-host fencing. When two workers are using two machines against a single store, it is necessary for the generation to be correct across all hosts, and this is a difficult problem for which this library has only provided a demo-quality mechanism for transport. As for reconciling effects beyond the commit boundary, the outbox, the idempotency key at the connector, and the receipt confirming what actually happened downstream, that remains your responsibility. And in this situation nothing will undo what a worker has already done in the real world. The guarantees apply only to the artifacts that a fleet shares, not to the fleet's actions. If your fleet has already spread across multiple machines, then you are ahead of the tooling, and I would genuinely like to hear how you are fencing it.

## Why it matters

The failure that occurred on Thursday can never be correctly diagnosed as being what it really is. A duplicate enrichment seems to be the result of a defective worker, and a missing result appears to mean that the model has forgotten. The lease seems harmless since it carried out its function, and the version check seems harmless since it answered its query. No one suspects a problem with the write path, because all the mechanisms involved reported success. It is only by posing the four questions that this cycle can be broken: if a record receives a step twice after a slow day at a vendor, you do not need a better prompt. What you need is the question that none of the first three mechanisms asked.

A specific next step is to check the lease timeout of your fleet, and then to check your slowest task this month, taking account of any retries. The difference between those two figures is the amount of leeway your trigger has. The fence mechanics, which include deterministic reproductions of both endings in the examples and the test suite, can be found at [github.com/Cohexa-ai/agent-coherence](https://github.com/Cohexa-ai/agent-coherence).
