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.

claim.ts
// 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`).
stale-guard.ts
// 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.

reconcile.ts
// 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.