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 decision read becomes a guard when its exact row is passed in reads: if that row moved, the write is rejected with AbloStaleContextError.
- 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.
- The rule is the same for every participant: a foreign active claim rejects the write, and a contender can queue for its turn.
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 two visible questions. Did earlier state influence this write? Use read and pass its row in reads. Will work span a slow gap such as an LLM call? 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`).// Use read when this row will influence a later write.
const record = await ablo.records.read({ id });
if (!record) throw new Error('record not found');
// ...someone else commits to this row in the meantime...
await ablo.records.update({
id,
data: { status: 'done' },
reads: [record],
});
// → if the row moved since read, this throws AbloStaleContextError
// instead of silently overwriting the newer value.Quick writes are stale-guarded automatically
Most writes don’t need a claim. Use read for a decision input and pass that exact row in reads; Ablo rejects the commit if it advanced. That is optimistic lost-update detection — no lock held, no silent overwrite.
Reconcile instead of clobber
A rejected write does not have to end the task. Catch AbloStaleContextError, re-read, and deliberately re-apply the contribution on top of current state with a fresh guard.
// A stale rejection is explicit. Re-read and reconcile if the work is still useful.
try {
await ablo.records.update({ id, data: { summary }, reads: [record] });
} catch (error) {
if (!(error instanceof AbloStaleContextError)) throw error;
const current = await ablo.records.read({ id });
if (!current) throw new Error('record not found');
await ablo.records.update({ id, data: merge(current, summary), reads: [current] });
}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
Pass rows returned by read in the mutation’s reads array and Ablo rejects the commit if any premise advanced — 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.
One claim rule
A foreign active claim rejects every ordinary writer, regardless of whether it is a person or agent. Contenders can queue for their turn.
Reject, then reconcile
A stale decision never lands silently. The typed rejection identifies the broken premise so the caller can re-read and recompute.
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 write exclusion applies to the row. After a stale rejection, a caller can re-read and deliberately merge its contribution.
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 quick state-dependent update, use read and pass that row in reads.
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 someone writes a row another participant is holding?
The write rejects with AbloClaimedError. A contender that calls claim can queue until the holder releases, then continue with fresh state.
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.