@abloatai/ablo 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,998 +1,267 @@
1
- # Coordination Reference
2
-
3
- > Claim mechanics and the API behind them: who holds a row, who is waiting, and how the line moves.
4
-
5
- > **Governing convention:** [`concurrency-convention.md`](./concurrency-convention.md)
6
- > — the non-coercion principle (surface state, let the actor decide), the full
7
- > `onStale` taxonomy, the batch premise (`reads[]`), and the boundaries. Read
8
- > that for the *why* and the contract; this reference is the *how* (claim
9
- > mechanics + API).
10
-
11
- Coordinate long-running work on a row so agents — and the people watching them —
12
- don't clobber each other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
13
- is **last-write-wins** by default.
14
-
15
- > **Read-modify-write under contention? Use the functional update — it owns all
16
- > of this for you.** When the new value is computed from the current one (the
17
- > shape that races), pass a function instead of data:
18
- >
19
- > ```ts
20
- > await ablo.documents.update(id, (current) => ({ content: revise(current.content) }));
21
- > ```
22
- >
23
- > The SDK reads the freshest row, runs your updater, writes it as a
24
- > compare-and-swap, and re-reads + re-runs on any concurrent write — no claim, no
25
- > per-agent identity, no `stale_context` / `claim_*` codes, no retry loop, and no
26
- > way to silently clobber. See [Functional update](#functional-update).
27
- > The rest of this page is the **low-level** machinery the functional form is
28
- > built on — reach for it directly only when you need to **hold a row across a
29
- > slow gap with side effects** (e.g. presence badges, multi-row handles), or want
30
- > explicit FIFO ordering. For lost-update detection on a single read-modify-write,
31
- > prefer the functional form over hand-rolling `claim` / `readAt` / `onStale`.
32
-
33
- Claims don't lock. If another writer holds the row, `claim` waits for them,
34
- re-reads the fresh row, then hands it to you — so two writers serialize instead
35
- of clobbering. The wait is a **server-side FIFO queue**: a second claimer blocks
36
- until promoted to the head of the line — it does not fail and does not poll.
37
- Reads stay open: reading a claimed row is allowed unless the caller explicitly
38
- asks for claimed gating. A claim carries a TTL so a crashed holder is
39
- auto-released and the queue advances.
40
-
41
- A claim is also as narrow as you make it. Select one or more `fields` and
42
- exclusion follows the target: **two claims on different fields of the same row
43
- are both granted**, and only claims that share a field queue behind each other.
44
- See [claiming part of a row](#claiming-part-of-a-row).
45
-
46
- > **Transport: both wait — only the mechanism differs.** `claim({ id })` means
47
- > "serialize me behind whoever holds this row" on every transport. The
48
- > realtime client parks the promise on its socket and resolves it on the grant
49
- > frame. The **stateless HTTP client** (`Ablo({ transport: 'http' })` — the
50
- > transport server-side agents use) holds the same place in the same
51
- > server-side FIFO line; under the hood it heartbeats its queued ticket until
52
- > the line moves, then re-reads the row and resolves to the same held claim.
53
- > The same snippet works on both.
54
- >
55
- > Shape the wait the same way on either transport: cap it with
56
- > `waitTimeoutMs` (rejects `grant_timeout` and leaves the line), cancel it
57
- > from outside with `signal` (an `AbortSignal`; rejects
58
- > `claim_wait_aborted`), bound the line you'll join with `maxQueueDepth`
59
- > (`queue_too_deep`), or skip waiting entirely with `queue: false` — the
60
- > try-claim, which resolves `null` when the target is held (skipped work
61
- > is not an error) and takes no place in line. For
62
- > callers that manage the wait themselves, the ticket surface remains:
63
- > `ablo.claims.get({ claimId })` polls a ticket to its grant,
64
- > `ablo.claims.heartbeat({ claimId })` keeps the slot, and
65
- > `ablo.claims.release({ claimId })` leaves the line. And contention can be
66
- > treated as a signal rather than a wait at all — catch the error, re-read
67
- > fresh, regenerate, retry; see [Errors](#errors) for the loop sketch.
68
-
69
- This reference opens with [the model](#the-model-three-layers-one-decision) — the
70
- one answer to "how do two agents not clobber each other" — then covers the
71
- [claim state object](#the-claim-state-object), the SDK [methods](#methods)
72
- (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
73
- claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
74
-
75
- > **Before anything else: one identity per agent (for the low-level claim
76
- > path).** The [functional update](#functional-update) does
77
- > **not** need this — its safety comes from the row watermark (compare-and-swap),
78
- > not from participant identity, so it's correct even when many workers share one
79
- > `sk_`. The rule below applies when you take **explicit `claim`s** for FIFO
80
- > exclusion or presence.
81
- >
82
- > Coordination excludes
83
- > *participants*, and a participant **is the key**, not the client object. The
84
- > server derives identity from the credential's scope — so **N clients sharing
85
- > one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
86
- > queue behind each other, and a "second" claimer silently re-takes the lease it
87
- > already holds (no mutual exclusion). To get real per-agent exclusion, mint a
88
- > **distinct scoped `rk_` per agent** and bind a client to it:
89
- >
90
- > ```ts
91
- > const { token } = await ablo.sessions.create({ agent: { id: `agent-${i}` } }); // rk_
92
- > const agent = Ablo({ schema, apiKey: token }); // this agent's own participant
93
- > ```
94
- >
95
- > Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
96
- > turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
97
- >
98
- > **Testing exclusion:** mint two sessions with different `agent.id` values,
99
- > assert those values differ, let A acquire the row, and inspect
100
- > `claim.state({ id })` before B calls
101
- > `claim({ id, contention: { mode: 'skip' } })`. B must receive `null`.
102
- > Creating two clients from the same key tests re-entrancy, not contention.
103
-
104
- ---
105
-
106
- ## The model: three layers, one decision
107
-
108
- Ablo has exactly **three** coordination layers. They are **not** three competing
109
- answers to the same question — they stack, and only one of them is a decision you
110
- make:
111
-
112
- | layer | kind | what it does | enforces? |
113
- |---|---|---|---|
114
- | **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." Reading or claiming a row auto-enrolls you in its sync group, so `claim.state({ id })` observes co-participants from any client (browser or Node agent) with no manual subscribe step. | **No.** Advisory only: it never blocks or rejects a write. |
115
- | **Claim** (`claim`/`claim.queue`/`claim.release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants: mutual exclusion. |
116
- | **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time: lost-update detection. |
117
-
118
- **The one decision: do you hold the row across a slow gap (read → LLM call →
119
- write)?**
120
-
121
- - **No** (the common case — a single quick `update`): a plain `ablo.<model>.update`
122
- is **last-write-wins** — it carries no `readAt`, so the server skips the stale
123
- check and the write simply lands. That's fine for most fields. If you need
124
- lost-update detection on a no-claim write, pass `readAt` + `onStale: 'reject'`
125
- yourself and it rejects with `AbloStaleContextError` when the row moved under
126
- you.
127
- - **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
128
- excludes other participants for the duration, queues contenders fairly, and —
129
- see below — your own writes under it stay stale-guarded too.
130
-
131
- **How they compose (what wins):** If you don't hold the row, claims win — a
132
- non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
133
- `readAt`. If you do hold it, your own writes are still stale-checked — a row that
134
- moved between your snapshot and your write still rejects with
135
- `AbloStaleContextError`. With no claim held and no `readAt`, there is **no**
136
- stale protection — the plain write is last-write-wins; opt into lost-update
137
- detection by passing `readAt` + `onStale` yourself. Presence (`claim.state`)
138
- never decides anything — read it to render, act on the errors. The two checks are
139
- independent: one rejects writes from people who don't hold the claim, the other
140
- rejects writes based on a stale snapshot, and the SDK adds the stale-check for you
141
- when you write under a claim **you took on this client**, so there you don't pass
142
- anything extra.
143
-
144
- ---
145
-
146
- ## Declaring conflict behaviour in the schema (Axis 3)
147
-
148
- The two enforcement layers above are decided **per write** (claim a row, or pass
149
- `readAt` + `onStale`). You can also declare a model's **default** conflict
150
- disposition once, in the schema, so every commit to that model is governed
151
- without per-call wiring. This is the third coordination axis — orthogonal to
152
- `policy` (who may read a row) and `groups` (which delta channels it fans into).
153
-
154
- Set a model's `conflict` stance with `coordination`, naming one rule per kind of
155
- committer:
1
+ # Coordination
156
2
 
157
- ```ts
158
- import { coordination, model, z } from '@abloatai/ablo/schema';
159
-
160
- export const cards = model(
161
- {
162
- title: z.string(),
163
- },
164
- {
165
- // "a human's edit always wins (never blocked); an agent yields"
166
- conflict: coordination.humansOverwrite().agentsReject(),
167
- }
168
- );
169
- ```
170
-
171
- Each rule pairs a committer with a disposition, drawn from the same `onStale`
172
- vocabulary the write guards use:
173
-
174
- | disposition | meaning |
175
- |---|---|
176
- | `overwrite` | the write wins; that committer is never blocked. |
177
- | `reject` | the write is refused; that committer yields to a held claim / stale snapshot. |
178
- | `notify` | hold the write and hand back the current value so the committer re-reads and re-applies (stale writes only). |
179
-
180
- That gives nine rules — `humansOverwrite` / `humansReject` / `humansNotify`,
181
- `agentsOverwrite` / `agentsReject` / `agentsNotify`, `systemOverwrite` /
182
- `systemReject` / `systemNotify` — and a chain may name as many as it needs. A
183
- kind left unnamed falls through to the engine default, and a kind named twice
184
- takes the later rule.
185
-
186
- When the rules are assembled at runtime rather than written out, each one is
187
- also a standalone function, and `coordination()` merges them:
188
-
189
- ```ts
190
- import { coordination, humansOverwrite, agentsReject } from '@abloatai/ablo/schema';
191
-
192
- const stance = coordination(humansOverwrite(), agentsReject());
193
- ```
194
-
195
- Both forms produce the same thing: a map keyed by the committer's participant
196
- kind, which is what travels to the server.
197
-
198
- ```ts
199
- { user: 'overwrite', agent: 'reject' }
200
- ```
201
-
202
- ### How it relates to per-write coordination
203
-
204
- - The `conflict` map is **pure, serializable data**: it ships in your pushed
205
- schema (`npx ablo push`) and the engine interprets it at the commit
206
- chokepoint — there is no per-model code on the server.
207
- - An **omitted committer kind falls through to the engine default**: reject, and
208
- honor a per-write `onStale: 'notify'`. Declaring `conflict` is purely
209
- additive — existing schemas behave exactly as before.
210
- - It sets the **default** disposition; a per-write `onStale` and a held `claim`
211
- still apply on top as described above. Think of `conflict` as "the house rule
212
- for this model," and `claim` / `onStale` as what an individual write does
213
- within it.
3
+ > Choose plain writes, functional updates, stale guards, or claims without losing concurrent work.
214
4
 
215
- The `ConflictAxis` type (also available as `Ablo.Conflict.Axis`) and the
216
- `interpretConflictAxis` interpreter are exported for composing custom policies.
5
+ Ablo gives you several concurrency tools because not every write has the same
6
+ meaning. Choose the narrowest one that matches the operation.
217
7
 
218
- ---
219
-
220
- ## The claim state object
221
-
222
- The claim state object is the live record that a participant is coordinating work on
223
- a model row. It's what `claim.state()` returns and what observers render.
224
-
225
- | field | type | description |
8
+ | Situation | Use | Result |
226
9
  |---|---|---|
227
- | `id` | `string` | The claim id (distinct from the target row id). |
228
- | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished: `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
229
- | `target` | `EntityRef` | What is being coordinated: the row (`{ model, id }`) plus any field narrowing the holder claimed: `field?`, `fields?`, and opaque `meta?`. A target with no narrowing covers the whole row. |
230
- | `description` | `string` | Peer-visible description of the work: the sentence another participant reads to decide whether to wait or move on (`'rewriting the risk section'`). Defaults to `'editing'`. |
231
- | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
232
- | `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it: a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
233
- | `position` | `number?` | 0-based place in the FIFO line: present only when `status: 'queued'` (`0` = next behind the holder). |
234
- | `createdAt` | `number?` | Ms-epoch the holder opened it. Optional: derived shapes may omit it. |
235
- | `expiresAt` | `number` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive: a crash-cleanup floor, not a duration you size. |
236
- | `meta` | `Record<string, unknown>?` | The claim's open metadata bag, as it stands on the wire. A [heartbeat](#heartbeat-holding-a-claim-for-long-running-work) writes its `details` here under `progress`: last beat wins, so an observer can read what a long hold is doing without the holder releasing it. Distinct from `target.meta`, which is the shape your program declared: a declared shape has no member for a key the coordinator wrote. |
237
-
238
- ```jsonc
239
- {
240
- "id": "claim_8fJ2",
241
- "status": "active",
242
- "target": { "model": "weatherReports", "id": "report_stockholm" },
243
- "description": "editing",
244
- "heldBy": "agent:forecaster",
245
- "participantKind": "agent",
246
- "createdAt": 1748160000000,
247
- "expiresAt": 1748160030000,
248
- "meta": { "progress": { "phase": "writing", "done": 2, "of": 5 } }
249
- }
250
- ```
10
+ | Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. |
11
+ | Compute a value from the current row | `update(id, current => next)` | Re-reads and retries if the row changes concurrently. |
12
+ | Write only if earlier rows are still current | `reads: [task, policy]` | Rejects when an explicitly named dependency changed. |
13
+ | Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. |
251
14
 
252
- ### Lifecycle
15
+ **If a model call sits between the read and the write, take a claim.** A stale
16
+ guard tells you the row moved after you have already paid for the turn. A claim
17
+ makes the contender wait before it spends anything, and it reads the winner's
18
+ result rather than reasoning against state that has since moved.
253
19
 
254
- ```
255
- claim({ id }) update({ id }) lands
256
- (free) ───────────▶ active ───────────────────────▶ committed
257
-
258
- ┌───────────┴───────────┐
259
- ▼ ▼
260
- canceled expired
261
- (release w/o write) (TTL; holder died)
262
- ```
20
+ The important boundary is explicit: a plain update does not claim a row and
21
+ does not carry a stale premise. It is intentionally last-write-wins.
263
22
 
264
- A target is free when `ablo.<model>.claim.state({ id })` returns `null`. Terminal
265
- states drop out of the live stream, so a claim you can see is either `active`
266
- (the holder) or `queued` (waiting in the FIFO line behind it; see
267
- [`claim.queue`](#claimqueue)).
23
+ ## Explicit read dependencies
268
24
 
269
- Reading a holder's progress is the same synchronous read as everything else
270
- here — no second subscription, and nothing to poll:
25
+ Pass the exact rows that produced a decision on the write:
271
26
 
272
27
  ```ts
273
- const held = ablo.documents.claim.state({ id: docId });
274
- const phase = held?.meta?.progress?.phase ?? 'reading';
275
- ```
276
-
277
- ---
278
-
279
- ## Methods
280
-
281
- One word — "claim" — names four distinct things; keep them separate as you read:
282
-
283
- - **the lease (claim handle):** the *object* returned by `ablo.<model>.claim({ id })`
284
- (`ClaimHandle`, an `AsyncDisposable` with `.data` and `.release()`).
285
- - **acquiring a claim/lease:** the *verb* `ablo.<model>.claim({ id })`, the call
286
- that takes the lease.
287
- - **`claim.state` / `claim.queue`:** the *inspection namespace* hanging off the
288
- model, for reading who holds the row and who's lined up.
289
- - **the write's `claim` param:** `update({ id, data, claim })`, where you pass a
290
- lease the proxy didn't take itself.
291
-
292
- Each method below follows one fixed shape: **signature · what it does ·
293
- parameters · returns · example**.
294
-
295
- ### `claim`
296
-
297
- ```ts
298
- ablo.<model>.claim({ id, ...options }): Promise<ClaimHandle<T>> // handle; AsyncDisposable, auto-releases with `await using`
299
- ```
300
-
301
- Claim a row so other writers serialize behind you until you're done; reads stay
302
- open by default. The claim acquires through the server's fair FIFO queue: if the
303
- target is free the lease is yours immediately, and if another participant holds
304
- it your claim **waits in line** and resolves only once it reaches the head —
305
- then re-reads so the claimed snapshot reflects what the previous holder
306
- committed. There's no polling and no race window — the server decides the order,
307
- so two claimers can't both think they won.
308
-
309
- **Parameters** — every option is flat on the call, and each sits on one of
310
- four axes. `claim({ id })` alone is a complete call; each axis is opt-in.
311
-
312
- *What you claim* — the target, narrowed below the row:
313
-
314
- | name | type | required | description |
315
- |---|---|---|---|
316
- | `id` | `string` | yes | The row id: same id as `get` / `update`. |
317
- | `options.fields` | field selector | no | Claim fields declared by the model's Zod schema instead of the whole row: `fields: (task) => task.status`, or `fields: (task) => [task.status, task.title]` for several. The model supplies its own fields, so autocomplete is exact, a typo does not compile, and a schema rename is a compile error at every use. Two sets conflict where they intersect, so holders of disjoint fields do not wait for each other; see [claiming part of a row](#claiming-part-of-a-row). |
318
-
319
- *What others see* — the presence half:
320
-
321
- | name | type | required | description |
322
- |---|---|---|---|
323
- | `options.description` | `string` | no | Peer-visible description of the work, shown to observers (default `'editing'`). |
324
- | `options.meta` | `object` | no | App-defined structured metadata, carried verbatim to every participant observing the claim. Declare its shape once on `Register`'s `ClaimMeta` slot. |
28
+ const task = await ablo.tasks.get({ id: taskId });
29
+ const policy = await ablo.policies.get({ id: policyId });
30
+ if (!task || !policy) throw new Error('required input is missing');
325
31
 
326
- *How you handle contention*:
32
+ const result = await model({ task, policy });
327
33
 
328
- | name | type | required | description |
329
- |---|---|---|---|
330
- | `options.contention` | `Claim.ContentionOptions` | no | Keeps the response to a busy target together: `{ mode: 'wait', maxDepth?, timeoutMs?, signal?, onStatus? }`. Use `mode: 'skip'` for claim-or-skip dedup; a foreign holder resolves the claim as `null`. |
331
- | `options.queue` | `boolean` | no | Compatibility shorthand: `true` waits and `false` skips. Prefer `contention` when configuring more than the mode. |
332
- | `options.maxQueueDepth` | `number` | no | Legacy flat spelling of `contention.maxDepth`. Prefer `contention` for new code. |
333
- | `options.waitTimeoutMs` | `number` | no | Legacy flat spelling of `contention.timeoutMs`. Prefer `contention` for new code. |
334
- | `options.signal` | `AbortSignal` | no | Legacy flat spelling of `contention.signal`. Prefer `contention` for new code. |
335
-
336
- *How long you hold* — the lease:
337
-
338
- | name | type | required | description |
339
- |---|---|---|---|
340
- | `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set: the lease renews while your connection is alive, so it only matters once you go silent. |
341
- | `options.heartbeat` | `true \| Duration \| { every?, onBeat?, onLost? }` | no | Keep the lease alive for work that outlives the TTL: `true` beats every third of the TTL, a duration sets the cadence, and the structured form carries the cadence and both callbacks in one place: `onBeat` fires after every successful beat (chiefly `queueDepth`, the pressure signal), `onLost` once if a beat learns the lease is gone. The loop stops on release. |
342
-
343
- The request-scoped `onStatus` callback receives one discriminated event. It is
344
- observational: an exception in UI or telemetry code never changes the claim
345
- attempt.
346
-
347
- | event | meaning | claim promise |
348
- |---|---|---|
349
- | `queued` | this request joined the wait line | remains pending |
350
- | `granted` | this request owns the lease | resolves with the claim |
351
- | `skipped` | `mode: 'skip'` found another participant holding the target | resolves `null` |
352
- | `failed` | the attempt could not complete, for example timeout, cancellation, authorization, or connectivity | rejects with `event.error` |
353
-
354
- ```ts
355
- const claim = await ablo.tasks.claim({
356
- id,
357
- contention: {
358
- mode: 'wait',
359
- maxDepth: 3,
360
- timeoutMs: 30_000,
361
- onStatus(event) {
362
- if (event.type === 'queued') {
363
- console.log(`${event.ahead} participant(s) ahead`);
364
- } else if (event.type === 'granted') {
365
- console.log(event.waited ? 'your turn' : 'granted immediately');
366
- } else {
367
- console.warn(event.error.code, event.error.message);
368
- }
369
- },
370
- },
34
+ await ablo.tasks.update({
35
+ id: task.id,
36
+ data: result,
37
+ reads: [task, policy],
371
38
  });
372
39
  ```
373
40
 
374
- For claim-or-skip work, make the caller's intent explicit and keep its status
375
- observer beside the decision:
41
+ This means “apply this update only if the rows used to produce it have not
42
+ changed.” The exact returned objects carry opaque evidence; no watermark is
43
+ exposed. Same-row and cross-row dependencies use one shape. Incidental reads do
44
+ nothing, and cloned, fabricated, or cross-client rows fail locally.
376
45
 
377
- ```ts
378
- const claim = await ablo.tasks.claim({
379
- id,
380
- contention: {
381
- mode: 'skip',
382
- onStatus: (event) => {
383
- if (event.type === 'skipped') metrics.increment('claim.skipped');
384
- },
385
- },
386
- });
387
- if (!claim) return;
388
- ```
389
-
390
- The high-level `claim` queues by default, so on contention you either get the row
391
- when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
392
- `grant_timeout`).
46
+ An `undefined` result cannot carry evidence. Guarded absence therefore remains
47
+ a separate low-level design; do not treat a missing read as an automatic
48
+ create-if-absent condition.
393
49
 
394
- **Returns** a `ClaimHandle<T>` (an `AsyncDisposable`): `handle.data` is the
395
- fresh row snapshot taken once the lease is yours, and `handle.release()` gives
396
- the claim back. Bind it with `await using` so the claim auto-releases when the
397
- scope exits.
50
+ ## Functional updates
398
51
 
399
- **Example**
52
+ When the next value is a function of the current one, pass an updater rather
53
+ than fixed data:
400
54
 
401
55
  ```ts
402
- await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
403
- const report = claim.data;
404
- const weather = await weatherAgent.getWeather(report.location);
405
- await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
56
+ const document = await ablo.documents.update(documentId, (current) => ({
57
+ revision: current.revision + 1,
58
+ content: revise(current.content),
59
+ }));
406
60
  ```
407
61
 
408
- The claim releases when the `await using` scope exits **on return and on
409
- throw.** The "on throw" is the whole reason to bind it with `await using`: if the
410
- work between the claim and the write fails the agent call errors, validation
411
- rejects, you decide not to write — the scope unwinds, the lease is released, and
412
- the next waiter is promoted, with no `finally` to remember. And nothing reaches
413
- the server until `update`, so a failure *before* the write leaves the row exactly
414
- as it was: claiming and committing are separate steps, so a failure between them
415
- has nothing to roll back. (A failure *after* a successful write leaves that write
416
- committed — pass an idempotency key on the write if you replay the block.) The
417
- lower-level [`claim.release`](#claimrelease) shows the manual `try/finally`
418
- equivalent for when you hold a claim without `await using`.
62
+ The SDK reads the current row, runs the updater, and writes only if that row is
63
+ still current. If another write wins first, it re-reads and runs the updater
64
+ again. This prevents the usual lost-update race without holding a claim across
65
+ your application code.
419
66
 
420
- ### Claiming part of a row
67
+ Use this form only for a pure calculation. Because the updater may run more than
68
+ once, do not send email, charge a card, call a model, or perform another side
69
+ effect inside it.
421
70
 
422
- Name a field and a typo cannot survive — the model is already bound by the call,
423
- so it hands you its own fields:
71
+ You can bound or cancel reconciliation:
424
72
 
425
73
  ```ts
426
- await using mine = await ablo.tasks.claim({ id, fields: (task) => task.status });
74
+ await ablo.documents.update(
75
+ documentId,
76
+ (current) => ({ revision: current.revision + 1 }),
77
+ { retries: 8, signal: request.signal },
78
+ );
427
79
  ```
428
80
 
429
- `task.status` is checked against the model: a field it does not have stops
430
- compiling, and renaming one is a compile error at every use. Nothing to import,
431
- nothing to add to your schema file.
81
+ If contention continues beyond the retry budget, the call rejects with
82
+ `AbloContentionError` and does not apply a stale calculation.
432
83
 
433
- The selector is the public model API. Quoted field names exist only in the wire
434
- contract and low-level coordination protocol.
84
+ ## Stale guards
435
85
 
436
- A claim covers the whole row only when you name nothing narrower. Select
437
- `fields` and exclusion follows them: **two claims on different
438
- fields of the same row are both granted**, and only claims that share a field
439
- queue behind each other.
86
+ Use explicit returned rows when application code reads first and writes later,
87
+ but does not need to reserve the row:
440
88
 
441
89
  ```ts
442
- // Two agents on the SAME order at the same time. The pricing agent holds
443
- // `total` and `discount`; the fulfillment agent holds `status`. Disjoint
444
- // fields, so both are granted at once and neither waits on the other.
445
- await using priced = await ablo.orders.claim({
446
- id: orderId,
447
- fields: (o) => [o.total, o.discount],
448
- description: 'repricing',
449
- });
90
+ const report = await ablo.reports.get({ id: reportId });
91
+ if (!report) throw new Error('report missing');
450
92
 
451
- await using shipping = await ablo.orders.claim({
452
- id: orderId,
453
- fields: (order) => order.status,
454
- description: 'marking shipped',
93
+ await ablo.reports.update({
94
+ id: report.id,
95
+ data: { status: 'ready' },
96
+ reads: [report],
455
97
  });
456
98
  ```
457
99
 
458
- Overlap is set intersection. Holders on `total` and on `status` proceed
459
- concurrently; two claims that both name `total` queue; naming no field covers
460
- every field, so it conflicts with any narrower claim on the row.
100
+ The dispositions are:
461
101
 
462
- **Field is the floor.** A claim cannot be finer than a whole field, because
463
- nothing writes part of a value: whichever holder commits first takes the entire
464
- field. Sub-field targets, a range of text or a path into a document, are not
465
- offered. Two writers holding different parts of one field is only safe once
466
- concurrent edits to that field can be reconciled (operational transformation),
467
- and that is not solved yet. When it is, sub-field targets return, and they
468
- return working.
102
+ | `onStale` | Behavior |
103
+ |---|---|
104
+ | `reject` | Reject the write if its premise is stale. |
105
+ | `notify` | Leave the row unchanged and return the current value for reconciliation. |
106
+ | `overwrite` | Apply the write without a stale check. |
469
107
 
470
- A claim with no target is the widest parent: it covers every field of the row
471
- and conflicts with any narrower claim on it.
108
+ See [Concurrency Convention](./concurrency-convention.md) for guarded batches
109
+ and notifications.
472
110
 
473
- ### Claim-gated reads
111
+ ### Decide the model's conflict policy
474
112
 
475
- `claim.state({ id })` always returns immediately. Model reads such as
476
- `ablo.<model>.local.get(id)` are local reads and stay available while a claim is
477
- held. Server/model reads can choose a claimed policy:
113
+ Who yields is a design decision about the model, not something to restate on
114
+ every write. Declare it once, in the schema, and it travels to the server with
115
+ the rest of the model:
478
116
 
479
117
  ```ts
480
- await ablo.weatherReports.get({
481
- id: 'report_stockholm',
482
- ifClaimed: 'fail',
483
- });
484
- ```
485
-
486
- - `ifClaimed: 'return'` (the default) reads now and includes active work metadata.
487
- - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
488
-
489
- Reads never block on a claim — there is no `ifClaimed: 'wait'`. Waiting for a row
490
- to free up is a **claim-side** concern: take `ablo.<model>.claim({ id })` (it
491
- queues fairly behind the current holder and re-reads the fresh row once it's
492
- yours). Use `ifClaimed: 'fail'` when a read should simply refuse to proceed
493
- against a row someone else is mid-editing.
494
-
495
- ### `claim.state`
118
+ import { coordination, model, z } from '@abloatai/ablo/schema';
496
119
 
497
- ```ts
498
- ablo.<model>.claim.state({ id })
120
+ const cards = model(
121
+ { title: z.string() },
122
+ {
123
+ conflict: coordination.humansOverwrite().agentsReject(),
124
+ },
125
+ );
499
126
  ```
500
127
 
501
- Read who's currently working on a row, for observers and UI. Synchronous and
502
- reactive (it reads the local coordination snapshot). Never blocks.
503
-
504
- The first call also starts row-scoped observation. Because that subscription is
505
- asynchronous, an imperative script may briefly read `null` before the reactive
506
- snapshot arrives; subscribe to changes or wait for the expected holder when the
507
- observation itself is the test. Late subscriptions are backfilled across server
508
- instances, and a skipped conflict attempt seeds its authoritative holder
509
- summary into the same snapshot immediately.
510
-
511
- **You don't subscribe to anything first.** Reading or claiming a row
512
- automatically enrolls you in that row's sync group: reading it (including
513
- `get`, or `claim.state` itself) gives you **read-interest**, and
514
- `claim`-ing it gives you a **pinned write-intent**. So `claim.state({ id })`
515
- observes co-participants on that row from **any** client — a browser, a Server
516
- Action, or a Node agent — and a holder sees its own claim, with no manual
517
- subscribe step. There is no `participants.join` to call: the typed
518
- `ablo.<model>` surface (read / `claim` / `claim.state` / `claim.queue`) is the
519
- whole coordination API.
520
-
521
- **Parameters**
522
-
523
- | name | type | required | description |
524
- |---|---|---|---|
525
- | `id` | `string` | yes | The row id. |
526
-
527
- **Returns** — an active [claim state object](#the-claim-state-object) on the row, or
528
- `null` when the row is free.
128
+ An omitted participant kind uses the engine default, `reject`. A per-write
129
+ `onStale` states the disposition for that one write. Keep the policy simple, and
130
+ document any rule that lets a participant overwrite a held claim.
529
131
 
530
- **One holder, and a row can have several.** This reads a row, not a target, and
531
- answers with a single claim. That is the whole story for a whole-row claim, and
532
- only part of it once you [claim parts of a row](#claiming-part-of-a-row): three
533
- agents holding `total`, `discount`, and `status` are all active at once, and
534
- this read surfaces one of them. To render every holder, a badge per claimed
535
- field or a chip per participant, use [`claim.list`](#claimlist).
132
+ ## Claims
536
133
 
537
- **Example**
134
+ Use a claim when work must remain exclusive across a slow gap such as an LLM
135
+ call or another external service:
538
136
 
539
137
  ```ts
540
- const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
541
- if (who) console.log(`${who.heldBy} is ${who.description}`);
542
- ```
543
-
544
- Returns the active claim state when the row is held, or `null` when it's free:
545
-
546
- ```jsonc
547
- {
548
- "id": "claim_8fJ2",
549
- "status": "active",
550
- "target": { "model": "weatherReports", "id": "report_stockholm" },
551
- "description": "editing",
552
- "heldBy": "agent:forecaster",
553
- "participantKind": "agent",
554
- "expiresAt": 1748160030000
555
- }
556
- ```
557
-
558
- ### `claim.list`
559
-
560
- ```ts
561
- ablo.<model>.claim.list({ id })
562
- ```
563
-
564
- Every holder of a row. Same synchronous, reactive read as `claim.state` — off
565
- the same local snapshot, safe to call inline in a render — and the same list
566
- envelope as [`claim.queue`](#claimqueue).
567
-
568
- Reach for it whenever a row can be claimed by field. One agent repricing
569
- `total` while another marks `status` are two active claims on one row, and only
570
- this read returns both.
571
-
572
- **Parameters**
573
-
574
- | name | type | required | description |
575
- |---|---|---|---|
576
- | `id` | `string` | yes | The row id. |
577
-
578
- **Returns** — `{ object: 'list', data: Claim[] }`. Your own claim comes first
579
- when this client holds one, then the other participants'. Empty `data` when the
580
- row is free.
581
-
582
- **Example** — a badge on every claimed field:
583
-
584
- ```tsx
585
- const { data: holders } = ablo.orders.claim.list({ id: orderId });
586
-
587
- return FIELDS.map((field) => {
588
- const held = holders.find((c) => c.target.field === field);
589
- return <FieldBadge key={field} field={field} by={held?.heldBy} note={held?.description} />;
138
+ await using claim = await ablo.reports.claim({
139
+ id: reportId,
140
+ description: 'generating forecast',
590
141
  });
591
- ```
592
-
593
- `claim.state({ id })` remains the right read when a row is claimed whole, or
594
- when all you need is "is anyone working here" — it answers with one claim and
595
- `null` when the row is free.
596
-
597
- ### `claim.queue`
598
-
599
- ```ts
600
- ablo.<model>.claim.queue({ id })
601
- ```
602
-
603
- Read the **wait line** behind a row — the FIFO of claims queued behind the
604
- current holder, in promotion order. Like `claim.state`, it's synchronous and
605
- reactive (it reads the local coordination snapshot, kept current by the server's
606
- queue-mutation frames), and reading never blocks. Where `claim.state` answers "who
607
- holds it," `claim.queue` answers "who's lined up next" — render "3rd in line", or
608
- decide the wait isn't worth it.
609
-
610
- **Parameters**
611
-
612
- | name | type | required | description |
613
- |---|---|---|---|
614
- | `id` | `string` | yes | The row id. |
615
-
616
- **Returns** — a structured queue snapshot:
617
-
618
- - `waiting` — queued [claim state objects](#the-claim-state-object) in
619
- promotion order, excluding the active holder;
620
- - `next` — the first waiter, or `null`;
621
- - `size` — how many participants are waiting;
622
- - `data` — the same array as `waiting`, retained as the standard list-envelope
623
- member for compatibility.
624
142
 
625
- **Example**
143
+ const forecast = await generateForecast(claim.data.location);
626
144
 
627
- ```ts
628
- const line = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
629
- console.log(`${line.size} waiting`);
630
- console.log(`next: ${line.next?.heldBy ?? 'nobody'}`);
631
- console.log(line.waiting.map((claim) => claim.heldBy));
632
- ```
633
-
634
- ### `claim.release`
635
-
636
- ```ts
637
- ablo.<model>.claim.release({ id }): Promise<void>
638
- ```
639
-
640
- Release a claim you hold. Usually **implicit** — the `await using` scope exiting
641
- releases for you, and TTL cleans up a crashed holder.
642
- Call this only to give a manually held claim back early (claimed, then decided
643
- not to write).
644
- Releasing **promotes the head of the queue**: the next waiter receives the claim.
645
-
646
- **Parameters**
647
-
648
- | name | type | required | description |
649
- |---|---|---|---|
650
- | `id` | `string` | yes | The row id you hold a claim on. No-op if you don't hold it. |
651
-
652
- **Returns** — resolves once the claim is released.
653
-
654
- **Example**
655
-
656
- ```ts
657
- const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', description: 'reviewing' });
658
- const report = claim.data;
659
- try {
660
- const ok = await reviewExternally(report);
661
- if (!ok) return; // abandon, no write
662
- await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' } });
663
- } finally {
664
- await ablo.weatherReports.claim.release({ id: report.id });
665
- }
145
+ await ablo.reports.update({
146
+ id: claim.data.id,
147
+ data: { forecast, status: 'ready' },
148
+ });
666
149
  ```
667
150
 
668
- ### `heartbeat`: holding a claim for long-running work
151
+ If another participant already holds the target, `claim` waits its turn and
152
+ then resolves with a fresh row in `claim.data`. Ordinary reads remain open. By
153
+ default, a write from a participant that does not hold the active claim is
154
+ rejected; an explicit model conflict policy can choose otherwise.
669
155
 
670
- ```ts
671
- held.heartbeat(ttl?: Duration): Promise<{ expiresAt: number }>
672
- ```
156
+ Bind claims with `await using` whenever possible. The claim then releases when
157
+ the scope exits, including when the external call or write throws. For runtimes
158
+ without explicit resource management, use `try/finally` and
159
+ `await claim.release()`.
673
160
 
674
- A claim's TTL is crash cleanup, not a work-duration estimate — so a task that
675
- outlives it (an agent run, a background worker's job) keeps its lease by
676
- **beating**, the same pattern as an SQS visibility heartbeat or a Temporal
677
- activity heartbeat. Each beat extends the lease from now (never shortens it,
678
- and each extension is clamped server-side); a crashed worker stops beating and
679
- its lease lapses within one beat window, promoting the next waiter.
161
+ ### One identity per participant
680
162
 
681
- Usually **implicit** pass `heartbeat` when claiming and the SDK beats every
682
- third of the TTL until release:
163
+ Explicit claims coordinate authenticated participants. Two clients using the
164
+ same credential represent the same participant and do not exclude one another.
165
+ Mint a distinct scoped session for each independently coordinated agent:
683
166
 
684
167
  ```ts
685
- await using claim = await ablo.reports.claim({
686
- id: 'report_q3',
687
- description: 'generating',
688
- ttl: '5m',
689
- heartbeat: { onLost: () => abortWork() }, // `true` and '2m' are the shorthands
168
+ const { token } = await server.sessions.create({
169
+ agent: { id: `forecast-agent-${workerId}` },
690
170
  });
691
- await runLongGeneration(claim.data); // lease held for the duration
692
- // scope exit releases; the loop stops with it
693
- ```
694
171
 
695
- A beat that comes back with a definitive loss — the lease expired and the
696
- queue moved on — rejects with `AbloClaimedError` (`claim_lost`) and stops the
697
- auto-loop. For a worker with no socket, **the failed beat is the loss
698
- notification**; abandon or re-claim, and remember any write attempted under
699
- the old lease is independently rejected by its `readAt` guard. Transient
700
- failures (a connection blip) don't stop the loop — the next tick retries.
701
-
702
- Each beat's answer carries two more things:
703
-
704
- - **`queueDepth`:** how many participants wait in line behind the lease.
705
- This is the cooperative-yield pressure signal: a worker that can checkpoint
706
- may release early when others wait. Read it from the resolved beat, or pass
707
- `heartbeat: { onBeat }` when claiming to observe every auto-beat.
708
- - **progress `details`:** `held.heartbeat({ details: { pages: 42, of: 100 } })`
709
- stores the payload as the claim's peer-visible `meta.progress` (last beat
710
- wins, via `claim.state`). This is presence, not a checkpoint: it dies with
711
- the lease. Durable progress belongs in the data itself — write a row, and
712
- every subscriber already sees it.
713
-
714
- Cooperative yield has a server-side backstop your deployment can turn on: a
715
- **cumulative-hold ceiling**. Left unset — the default — a holder that keeps
716
- beating holds the row as long as it likes, and the line behind it depends on
717
- that holder reading `queueDepth` and releasing of its own accord. With a ceiling
718
- configured for a model, a holder that runs past its fair share *while contenders
719
- are queued* is preempted at the server: its next beat comes back `claim_lost`
720
- (reason `preempted`) — the same loss you already handle, so abandon or re-claim,
721
- and any write attempted under the old lease is fenced regardless. A holder with
722
- no one waiting is never preempted, however long it runs. It is the same idea as
723
- an SQS message that cannot stay invisible past a hard cap however often its lock
724
- is refreshed, narrowed here to bite only under real contention.
725
-
726
- Works identically on both transports: the realtime client sends a
727
- `claim_heartbeat` frame; the HTTP client posts
728
- `POST /api/v1/models/{model}/{id}/claim/heartbeat` (`{ ttl?, claimId?, details? }`).
729
- Over HTTP, a **queued** claim can heartbeat too — it refreshes the waiter's
730
- slot in the line (a queued slot is TTL'd like a lease) and reports
731
- `{ status: 'queued', position }`.
732
-
733
- A stateless worker holding **many** rows beats them all in one round trip:
734
- `ablo.claims.heartbeatAll({ ttl: '5m' })` → `POST /api/v1/claims/heartbeat`, one
735
- entry per extended lease. This is the socketless twin of the realtime
736
- keepalive, which already renews every held lease on each ping.
737
-
738
- ### durability: what a claim survives
739
-
740
- A lease belongs to your **identity** — the participant behind the credential —
741
- not to the socket it was claimed on; the server keys each lease by participant
742
- and `claimId`. That one fact decides what a claim lives through.
743
-
744
- **A brief blip is transparent.** The realtime client reconnects on its own
745
- (exponential backoff), and on each reconnect it re-announces every claim it
746
- still holds, so the server renews those leases and peers never see them flicker.
747
- A heartbeat that would land while the socket is momentarily down is skipped
748
- rather than failed — the next tick retries once the connection is back. Nothing
749
- to write: hold the claim and keep working.
750
-
751
- **A crashed holder frees the claim quickly — and it is the keepalive, not the
752
- TTL, that does it.** A dead holder is caught whichever way fires first: a clean
753
- socket close releases immediately, and a silent socket that never sent a close
754
- frame (a crashed tab, a dropped NAT) is reaped on the keepalive cycle (a ~30s
755
- ping / 10s pong window) and released then. Either way the next waiter is
756
- promoted within tens of seconds. This reclaim is **per-connection**, and it runs
757
- whether or not the TTL is anywhere near lapsing. Release fires only when your
758
- **last** connection goes, so a second connection under the same identity keeps
759
- the claim held.
760
-
761
- **The TTL is the deeper floor — for when the server itself restarts.** The live
762
- claim roster is held in memory, so a server restart would lose it; the durable
763
- lease in the coordination store carries the TTL, and a reconnecting client
764
- re-announces its claims before that TTL lapses. So size `ttl` to cover a
765
- deploy or restart window, not your work duration — beating covers the work
766
- duration.
767
-
768
- **To hold a claim across a holder crash, give it a durable identity.** A claim
769
- that must outlive a single failure belongs to a process that stays up — a
770
- backend worker or agent with its own credential — rather than one ephemeral
771
- browser tab. On reconnect the SDK re-announces it; if the row was granted onward
772
- while you were gone, that re-announce comes back as `AbloClaimedError`
773
- (`claim_lost`) — re-claim (you rejoin the line fairly) and retry from the fresh
774
- snapshot.
775
-
776
- | the holder… | what happens to the claim |
777
- | --- | --- |
778
- | blips, then reconnects within the window | renewed automatically on reconnect: no interruption |
779
- | crashes or drops for good | released within one keepalive cycle; the queue advances |
780
- | still has a second live connection | survives: release fires only on the last connection |
781
- | loses the server to a restart | rides the TTL in the coordination store; re-announced on reconnect |
782
-
783
- ### `join`: presence for a set of rows
784
-
785
- Reading or claiming a row auto-enrolls you in its sync group, which is enough for
786
- `claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
787
- presence on a known set of rows — a workspace's documents, a board's cards — and react to
788
- who joins or leaves, use `join`:
789
-
790
- ```ts
791
- await using room = await ablo.documents.join(slideIds, { ttl: '5m' });
792
- room.peers; // who else is here, live
172
+ const agent = Ablo({ schema, apiKey: token });
793
173
  ```
794
174
 
795
- `join(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
796
- a participant handle (`.peers`, the scoped claim stream, `.leave()` / `await using`
797
- disposal). It is the model-scoped successor to the old top-level
798
- `ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
799
- socket, so `join` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
800
- throws on any non-ws construction.
175
+ Functional updates do not require distinct participant identities because they
176
+ protect the row version rather than a participant-held claim.
801
177
 
802
- ### Writing under a claim
178
+ ### Skip instead of wait
803
179
 
804
- There is no separate "write" method on a claim use the normal
805
- `ablo.<model>.update({ id, data })`. The auto-guarding holds **only when this same
806
- client took the claim** via `ablo.<model>.claim({ id })` (the proxy remembers the
807
- lease in-process): that `update` is then stale-guarded against the snapshot the
808
- claim took (`readAt` = snapshot watermark, `onStale: 'reject'`) and attributed to
809
- the claim's lease, so it rejects with [`AbloStaleContextError`](#errors) if the
810
- row changed under you.
180
+ For deduplicated jobs, skip work when another participant already owns it:
811
181
 
812
182
  ```ts
813
- await using claim = await ablo.weatherReports.claim({ id });
814
- await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
815
- ```
816
-
817
- A claim handle minted by **another client** (or returned over HTTP) is not known
818
- to this proxy, so a plain `update` won't pick it up. Pass it explicitly:
819
-
820
- ```ts
821
- await ablo.weatherReports.update({ id, data: { status: 'ready' }, claim: handle });
822
- ```
823
-
824
- **Self-stale on a second write.** The claim's watermark is fixed at claim time
825
- and is **not** re-baselined as you write. So a *second* `update` under one held
826
- claim is stale-checked against the snapshot the claim took — which your *first*
827
- write already moved past — and rejects with `AbloStaleContextError` against your
828
- own earlier write. Re-read (and re-claim) between writes if you need to write the
829
- same row more than once under one claim.
183
+ const claim = await ablo.tasks.claim({
184
+ id: taskId,
185
+ contention: { mode: 'skip' },
186
+ });
830
187
 
831
- Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
832
- participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
833
- 'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
834
- behind the current holder and re-reads once it's yours, so your `update` lands
835
- on fresh data. You never conflict with your own claim, and reads are never gated.
188
+ if (!claim) return;
836
189
 
837
- ```ts
838
190
  try {
839
- await ablo.weatherReports.update({ id, data: { status: 'ready' } });
840
- } catch (err) {
841
- if (err instanceof AbloClaimedError) {
842
- // someone else holds it — claim the row and retry from fresh state
843
- }
191
+ await processTask(claim.data);
192
+ } finally {
193
+ await claim.release();
844
194
  }
845
195
  ```
846
196
 
847
- ---
848
-
849
- ## Errors
850
-
851
- All extend `AbloError` (`packages/transaction/src/errors.ts`). Catch by `type` or
852
- inspect the `code`.
853
-
854
- | error | `code` | thrown when | carries |
855
- |---|---|---|---|
856
- | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away: the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited), while you were holding or waiting. | `claims?` |
857
- | `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable: re-attempt the claim. | `claims?` |
858
- | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
859
- | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join: fail-fast instead of waiting. | `claims?` |
860
- | `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds: the server's pre-commit check rejected it. |: |
861
- | `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. |: |
862
- | `AbloStaleContextError` |: | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot: your reasoning is stale. | `readAt`, `conflicts[]` |
863
- | `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model proxy built without the collaboration runtime: an internal/advanced construction path. The standard `Ablo({ schema, apiKey })` client enables claiming for **every** model; there is no per-model claim config to add. |: |
864
- | `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. |: |
865
-
866
- `AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
867
- that moved during your generation window — use it for selective regeneration
868
- (re-think only the documents that changed, not the whole workspace) and for metrics.
197
+ To wait with limits, keep the policy together:
869
198
 
870
199
  ```ts
871
- try {
872
- await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
873
- const report = claim.data;
874
- const weather = await weatherAgent.getWeather(report.location); // slow gap
875
- await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
876
- } catch (err) {
877
- if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
878
- // Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
879
- } else if (err instanceof AbloStaleContextError) {
880
- // The row moved under us — re-read and regenerate from the fresh snapshot.
881
- } else throw err;
882
- }
200
+ const claim = await ablo.tasks.claim({
201
+ id: taskId,
202
+ contention: {
203
+ mode: 'wait',
204
+ maxDepth: 3,
205
+ timeoutMs: 30_000,
206
+ signal: request.signal,
207
+ },
208
+ });
883
209
  ```
884
210
 
885
- ### The reconcile loop, by hand (rarely needed)
211
+ ### Claim part of a row
886
212
 
887
- This is the loop the [functional update](#functional-update) runs for you. Write
888
- it yourself only when one attempt isn't a pure re-read-and-recompute — e.g. you
889
- must hold an explicit `claim` for a presence badge across the gap, or coordinate
890
- several rows in one handle. For a single read-modify-write, use the functional
891
- form instead of this.
213
+ Narrow a claim when independent fields may be edited concurrently:
892
214
 
893
215
  ```ts
894
- const RETRYABLE = (e: unknown) =>
895
- e instanceof AbloStaleContextError || // row moved under our write
896
- (e instanceof AbloClaimedError &&
897
- (e.code === 'claim_queued' || // someone holds it right now
898
- e.code === 'claim_lost')); // a human preempted us
899
-
900
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
901
- try {
902
- await using claim = await ablo.reports.claim({ id }); // acquire or claim_queued
903
- const fresh = claim.data; // read at the lease moment
904
- const next = await generate(fresh); // slow gap
905
- await ablo.reports.update({ id, data: next, claim }); // first writer wins
906
- break; // landed
907
- } catch (err) {
908
- if (!RETRYABLE(err) || attempt === MAX_ATTEMPTS) throw err;
909
- await sleep(80 + attempt * 40 + Math.random() * 60); // jitter: don't lock-step
910
- }
911
- }
216
+ await using claim = await ablo.tasks.claim({
217
+ id: taskId,
218
+ fields: (task) => task.status,
219
+ });
912
220
  ```
913
221
 
914
- The loop, not a queue, is the coordination mechanism over HTTP. On the WebSocket
915
- client the same code works but rarely loops, because `claim` blocks in the FIFO
916
- line instead of throwing `claim_queued`.
222
+ Claims on disjoint fields can coexist. A whole-row claim conflicts with every
223
+ field claim on that row.
917
224
 
918
- ---
225
+ The target options are:
919
226
 
920
- ## Functional update
921
-
922
- The read-modify-write surface that owns the loop above so you never write it.
923
- Pass a **function of the current state** instead of fixed `data` the
924
- `setState(prev => next)` of the data layer:
925
-
926
- ```ts
927
- const row = await ablo.documents.update(documentId, (current) => ({
928
- content: revise(current.content), // "given the latest, here is the next"
929
- }));
930
- ```
227
+ | Option | Purpose |
228
+ |---|---|
229
+ | `options.field` | Claim one field by its wire-level name. Prefer the typed selector in application code. |
230
+ | `options.fields` | Claim one or more schema fields with a typed selector. |
231
+ | `options.meta` | Attach application-defined metadata observers may display. |
931
232
 
932
- What the SDK does on every call, on **both transports** (one shared loop, so the
933
- guarantee can't drift): read the freshest row + its watermark → run your updater
934
- → write it as a **compare-and-swap** against that watermark (`readAt` +
935
- `onStale: 'reject'`) → on any concurrent write, re-read and re-run. Correctness
936
- comes from the watermark, **not** from participant identity — so it's immune to
937
- the shared-credential clobber footgun and needs no `claim` and no per-agent `rk_`.
233
+ ## Observe coordination
938
234
 
939
- Nothing about claims, identity, or conflict codes surfaces. On both transports,
940
- the call returns the reconciled row or, at the extreme, throws **one** error:
235
+ Read current claim state without blocking:
941
236
 
942
237
  ```ts
943
- import { AbloContentionError } from '@abloatai/ablo';
944
-
945
- try {
946
- await ablo.documents.update(id, (cur) => ({ content: revise(cur.content) }), {
947
- retries: 16, // reconcile rounds before giving up (default 16)
948
- signal: req.signal, // optional: abort the loop if the request is cancelled
949
- });
950
- } catch (err) {
951
- if (err instanceof AbloContentionError) {
952
- // The row stayed continuously contended past the budget — nothing was
953
- // written. err.attempts, err.cause (the last conflict). Back off, raise
954
- // `retries`, or move the row to the WebSocket transport (fair FIFO queue).
955
- }
956
- }
238
+ const holder = ablo.tasks.claim.state({ id: taskId });
239
+ const queue = ablo.tasks.claim.queue({ id: taskId });
957
240
  ```
958
241
 
959
- Return `null` / `undefined` from the updater to **skip the write** after seeing
960
- fresh state (the call resolves to `undefined`). A missing row throws
961
- `AbloNotFoundError`; a genuine failure (validation, constraint, permission)
962
- propagates immediately without retrying.
242
+ Use this state for presence and progress UI. Do not use an observed `null` as a
243
+ substitute for claiming: another participant can acquire the row immediately
244
+ after your read.
963
245
 
964
- ### How `create` and `delete` relate
246
+ The main methods are:
965
247
 
966
- They don't get a functional form — and shouldn't. The functional form exists
967
- because `update` is the only verb whose **next state is a function of the current
968
- state**, which is the shape that races. The other two aren't read-modify-write:
969
-
970
- | Verb | Functional form? | Why | Its "just works" property |
971
- | --- | --- | --- | --- |
972
- | `update` | **yes**: `update(id, current => next)` | next value depends on the current one (lost-update hazard) | compare-and-swap + reconcile |
973
- | `create` | no: `create({ data, id? })` | no prior state to read; the hazard is *id collision*, a terminal `unique_violation`, not a lost update | **idempotency**: stable id / `idempotencyKey` makes a retried create safe |
974
- | `delete` | no: `delete({ id })` | no resulting state to compute; "make it not exist" is unchanged by concurrent edits, and delete is idempotent | naturally idempotent |
975
-
976
- The same reason React has `setState(prev => next)` but no functional mount /
977
- unmount. A *conditional* delete ("only if unchanged since I read it") is the one
978
- niche case express it with an explicit `claim` / `readAt` on `delete({ id })`,
979
- not a function.
980
-
981
- ---
982
-
983
- ## Observability
984
-
985
- Coordination you can't see is coordination you can't debug. Pass an
986
- `observability` provider to `Ablo({ ... })` and the client reports every claim
987
- lifecycle event and stale-write collision it sees. The batteries-included
988
- provider is `ClaimLog`, and `collisions()` is the eval primitive:
989
-
990
- ```ts
991
- const log = new ClaimLog();
992
- const ablo = Ablo({ schema, apiKey, observability: log });
993
-
994
- expect(log.collisions()).toHaveLength(0); // no one stepped on anyone
995
- ```
996
-
997
- See [Debugging & Logs](./debugging.md) for the setup, the event shapes, a
998
- reactive activity feed, and routing events to your own backend.
248
+ | Method | Purpose |
249
+ |---|---|
250
+ | `claim({ id })` | Acquire the target, waiting by default. |
251
+ | `claim.state({ id })` | Read the current holder without blocking. |
252
+ | `claim.queue({ id })` | Read the current wait order. |
253
+ | `claim.release({ id })` | Release early when you do not hold a handle. |
254
+ | `join({ scope })` | Observe presence for a broader scope. |
255
+
256
+ ## Choosing correctly
257
+
258
+ - Prefer a plain update for values that do not depend on an earlier read.
259
+ - Prefer a functional update for a quick, pure read-modify-write calculation.
260
+ - Prefer a stale guard when your caller should decide how to reconcile.
261
+ - Prefer a claim when you must hold exclusivity across slow or side-effecting
262
+ work.
263
+ - Prefer idempotency for safe retries; it solves a different problem from
264
+ concurrency.
265
+
266
+ For exact error codes and recovery guidance, see [Errors](./errors.md). For what
267
+ a confirmed write promises, see [Guarantees](./guarantees.md).