Product · Coordination
Claims and coordination for humans and AI agents
Two writers, one row, no silent clobber. Ablo coordinates concurrent writes with FIFO row-level claims, stale-write rejection, and live presence — so people and agents work on the same data without overwriting each other.
Key takeaways
- Ablo coordination has three layers: presence (advisory), claims (pessimistic row leases), and stale-context guards (optimistic lost-update detection).
- A claim reserves a whole row for one participant; foreign writes are rejected with AbloClaimedError while it is held — claimants join a fair FIFO queue.
- A quick unclaimed write is auto-guarded by the readAt stamp from its prior read: if the row moved, the write is rejected with AbloStaleContextError instead of overwriting newer data.
- Reads never block, and every successful write broadcasts a delta to all connected clients over WebSocket — so collision detection and live notification are separate concerns.
- Humans are the principal: when a person reaches for a row an agent holds, policy preempts the agent and the agent self-heals rather than the human being bounced.
The one decision a writer makes
The hard part of letting humans and AI agents touch the same data isn’t streaming changes — it’s deciding what happens when two writers reach for the same row at the same time. Ablo makes that an explicit, typed decision instead of a silent race.
A writer answers one question: will I hold this row across a slow gap — a read, an LLM call, then a write? If no (a single quick update), do nothing: the write is automatically guarded by the readAt stamp from its prior read and rejected only if the row actually moved. If yes (you’ll reason for seconds while holding it), claim the row first so no one else can commit underneath you.
Hold a row across the read → think → write gap
An agent that reads, calls a model, then writes can take seconds — long enough for someone else to commit underneath it. Claim the row first and that gap is safe: the claim is a pessimistic lease, late claimants join a fair FIFO queue, and the lease releases automatically at scope exit.
// Hold a row across a slow read → LLM → write gap.
// FIFO: if someone else holds it, you join a fair queue.
await using claim = await ablo.records.claim({ id, action: 'triaging' });
const fresh = claim.data; // re-read at the moment it's yours
const next = await llm.decide(fresh); // seconds may pass — the row is reserved
await ablo.records.update({ id, data: next });
// Claim auto-releases at scope exit (`await using`).// A quick write needs no claim — it's stale-guarded automatically.
const record = await ablo.records.get({ id }); // carries a readAt stamp
// ...someone else commits to this row in the meantime...
await ablo.records.update({
id,
data: { status: 'done' },
readAt: record.readAt, // the snapshot this write was built on
});
// → if the row moved since readAt, this throws AbloStaleContextError
// instead of silently overwriting the newer value.Quick writes are stale-guarded automatically
Most writes don’t need a claim. Every read returns a readAt stamp; echo it on the write and Ablo rejects the commit if the row advanced since you read it. That is optimistic lost-update detection — no lock held, no silent overwrite of newer data.
Reconcile instead of clobber
A rejected write doesn’t have to be lost. Under onStale: notify the engine hands the agent the conflicting value rather than throwing, so the agent re-reads and re-applies its contribution on top of the winner’s. Two authors can live on one row.
// Don't abort — reconcile. onStale: 'notify' hands you the new value.
const { notifications } = await ablo.records.update({
id,
data: { summary },
readAt,
onStale: 'notify',
});
if (notifications.length) {
// The human (or another agent) wrote first. Re-read, merge your
// contribution on top of theirs, and write again. Two authors, one row.
const current = await ablo.records.get({ id });
await ablo.records.update({ id, data: merge(current.data, summary), readAt: current.readAt });
}Three layers, one substrate
Presence observes, claims reserve, and stale-context guards catch lost updates. Together they let people and agents share state without a central scheduler deciding who writes when.
Row-level FIFO claims
A claim reserves one row for one participant. Late claimants join a fair first-in-first-out queue and acquire the lease in order — no thundering herd, no starvation.
Stale-write rejection
Every read returns a readAt stamp. Echo it on the write and Ablo rejects the commit if the row advanced since you read it — lost-update detection without holding a lock.
Live presence
claim.state and claim.queue broadcast who is working where, in real time, so people and agents see each other’s intent before they collide. Presence is advisory — it never blocks a write.
Reads never block
Reading a claimed row is always allowed. Observers and other agents always see fresh data — they never sit blind on a locked snapshot.
Humans win over agents
When a person reaches for a row an agent holds, policy preempts the agent. The human is never bounced; the agent receives a claim_lost signal and self-heals.
Notify, don’t silently drop
A preempted or stale write isn’t discarded. Under onStale: notify the agent gets the new value back, re-reads, and re-applies its contribution on top.
Two rejections, two guarantees — never interchangeable
Ablo rejects a write in exactly two ways, and they mean different things. AbloClaimedError means a foreign write hit a row someone actively holds — there is always a holder, and the loser re-claims through the FIFO queue. AbloStaleContextError means an unclaimed write was built on a snapshot older than the row’s latest delta — there is no holder, only a newer committer. The type system keeps them distinct: you cannot type a stale-read rejection on a claimed row.
Collision detection is separate from notification
Every successful write broadcasts a delta to every connected client on the row’s sync group over WebSocket — no polling. That fanout is automatic and independent of whether a write collided. A human editing a title notifies every agent watching the record and collides with none of them, because nobody held that row.
Claims are row-level, not field-level
A claim reserves the whole row. You may attach a field or path for presence — “editing the title” — but the lock itself is the row. Content can still carry multiple authors: under notify-and-reconcile, a yielded agent re-reads and merges its segment on top of the human’s, so two authors can coexist on one record without one erasing the other.
Frequently asked questions
When should an agent claim a row versus just writing?
Claim when you will hold the row across a slow gap — a read, an LLM call, then a write — so no one commits underneath you while you reason. For a single quick update, skip the claim: the readAt stamp guards it automatically and the write is rejected only if the row actually moved in the meantime.
What is the difference between AbloClaimedError and AbloStaleContextError?
AbloClaimedError means you tried to write a row another participant is actively holding (there is always a holder); you re-claim through the FIFO queue and retry. AbloStaleContextError means your unclaimed write was built on a snapshot older than the row’s latest delta (there is no holder, only a newer committer); you re-read and rebuild the write. They are never interchangeable.
Do claims block other people from reading the row?
No. Reads never block by default — reading a claimed row is always allowed, so observers and other agents always see fresh data. A reader can opt into ifClaimed: fail or wait if it specifically needs to avoid a held row, but that is the exception, not the default.
What happens when a human edits a row an agent is holding?
The human wins. Policy preempts the agent’s claim — the agent receives a claim_lost signal — and the human’s write commits without being bounced. The agent then self-heals: under onStale: notify it gets the human’s new value, re-reads, and can re-apply its own contribution on top. Humans are the principal; agents yield.
How is this different from a CRDT or last-write-wins?
A CRDT auto-merges concurrent edits with no explicit decision, and last-write-wins silently discards the loser. Ablo makes the conflict an explicit, typed outcome: a write either commits, is rejected because the row is claimed, or is rejected because the read was stale. That visibility is what lets an autonomous agent reason about a conflict instead of clobbering through it.
Put humans and agents on one shared state
Coordinate concurrent writes with claims and stale-write rejection — on top of your own Postgres.