ADR-0069: A claim fences the writes that consume¶
Status: Accepted Date: 2026-10-16 Deciders: Alex Nodeland
Amends ADR-0055.
Context¶
ADR-0055 holds a thread with a claim, a lease in storage, and says a message is consumed once (I1) and one run is active in a thread (I3). Nothing in the write that consumes a message checked the claim, so both rested on timing: on the claim's holder finding it lost, through its renewal's timer, before another process took it.
TLC, checking the thread claim's spec (docs/artifactr/spec/) with claims that lapse while their holders live, found that timing is not enough (lattice #249):
- Process A claims a thread and plans a run for its oldest message.
- A's claim lapses in storage before A's run records
run_started. - Process B claims the thread. No run is
running, since A's has not started, so B abandons none, readstakenunchanged, and plans a run for the same message. - Both
run_startedwrites commit. The message is a run's prompt twice, and two runs are active.
A claim lapses under a live holder two ways. A write in flight past the TTL is run to its end, since storage finishes each call. And SQL storage stamps a lease's expiry with the application's own clock, so a process whose clock runs ahead of A's sees A's claim expired while A's timer has not fired: A is not stalled at all, and is never cancelled. Runner's claim_ttl already asks to exceed the clock skew between replicas; nothing held a deployment to it.
Decision¶
- The writes that consume a message under a claim are fenced by it:
run_started, which consumes a run's prompt, and each answer a reply commits. Each is made only while the claim's holder has the thread's lease, checked in the write's own transaction. - The fence is the holder, not the time. A lease that has expired, and that no one else took, still fences its holder's write: whoever takes it next abandons a run that started under it, as ADR-0055 does for a run its process left. So the fence needs no clock, and skew between processes cannot break it.
- Storage's
Transactiongainsholds_lease(key, holder). It answers whether the holder has the lease, and keeps it so until the transaction ends: SQL storage locks the lease's row (SELECT ... FOR UPDATE), soacquire_leasefor another holder waits for the transaction; in-memory storage'sacquire_leasetakes the workspace's lock, as its transactions do. Workspace.commitandWorkspace.recordtakeclaim=(thread_id, holder), besidecursor=. A write whose holder no longer has the thread is refused withThreadBusy, and nothing is written.- The Runner names its claim to the run through the session's
Delivered, as it names thetakencursor. A run whose start is refused fails before it starts, consumes nothing, and hands over as ADR-0055's I4 says. A reply refused in_planends the claimant's round, asThreadBusyat the claim does: the claimant that took the thread plans its turn. - The run's end is not fenced. A run's end is refused by core once the run is abandoned, which the next claimant does before it plans, so an end recorded under a lapsed claim consumes nothing another turn took. Fencing it too would refuse a completed run's end, with its history, for no gain.
Options considered¶
Option A: The claim's holder, checked in the consuming write's transaction (chosen)¶
| Dimension | Assessment |
|---|---|
| Complexity | Low: one storage method, one parameter, a field on Delivered |
| Clocks | None: the holder is compared, not the expiry |
| Storage protocol | Transaction.holds_lease: a storage written outside artifactr implements it |
Pros: the invariant is one sentence and holds whatever the timing; the same shape as reflexr's epoch fence (lattice #247), a fence checked in the transaction that writes. Cons: a consuming write locks the lease's row, and a claim renewal in flight waits for it.
Option B: Compare taken with what the plan read¶
run_started would save its cursor only if taken still stood where the plan read it.
Pros: no change to storage's protocol.
Cons: taken is read through the upgrade point and what the agent was last told, and a reply moves it within a plan, so the value the plan read is not the stored one; the compare would need to know which. And it fences only what moves taken: an answer is refused by core per request, not per message, so two replies could still take one message to two requests.
Option C: Lease time from the database's clock¶
acquire_lease would stamp expiries with the database's now(), so processes agree on when a claim lapses.
Pros: removes the skew. Cons: leaves the write in flight past the TTL, the other way a claim lapses under a live holder; the in-memory storage has no database clock; and it is still timing.
Option D: Core refuses run_started while another run is active¶
Cons: fences I3 and not I1. Once the first run ends, a second, planned under a lapsed claim before it started, starts with the same prompt.
Option E: Name a second exception in ADR-0055¶
Cons: a prompt recorded twice is two replies to one message, which a person sees; ADR-0055's one exception, steering given again to the model, is not visible.
Trade-off analysis¶
Options C and D narrow the window; only a fence closes it. Option B fences the cursor, and has to reconstruct what the plan read; option A fences the claim itself, whose holder is a value the Runner already makes for each claim. The cost of A is a method in storage's Transaction, which ADR-0055 already extended for save_cursor, and a row lock held for one write.
Consequences¶
- Easier: I1 and I3 hold under a stalled write and under clock skew, and
claim_ttlexceeding the skew between replicas is no longer what holds them; it still sets how soon a dead process's run is abandoned. - Easier: the spec checks it.
ThreadClaimLapse.cfgruns in CI withI1_ConsumedAtMostOnceandI3_OneActiveRun;RunStartandReplyrequire the claim in storage to be their own, andStartFencedandReplyFencedare the refusals. TLC finds a run's start without the fence violatesI3_OneActiveRunin 16 states. - Harder: storage's
Transactionprotocol growsholds_lease. Only artifactr's two storages implement it in the packages. - A run whose start is refused by the fence is a failed turn, in the turn's span and metrics, and its
RunHandle.waitraisesThreadBusy. It consumed nothing. - ADR-0055's one exception stands: steering a stalled run was given, and could not record, may be given again by the thread's next holder.
Action items¶
-
Transaction.holds_leasein both storages;claim=onWorkspace.commitandWorkspace.record; the Runner fencesrun_startedand the reply's answers (lattice #249). - The spec's
RunStartandReplyare fenced, andThreadClaimLapse.cfgruns in CI.