@abloatai/ablo 0.57.0 → 0.58.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.
Files changed (77) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +199 -13
  3. package/README.md +2 -1
  4. package/dist/ai-sdk.d.ts +1 -1
  5. package/dist/ai-sdk.d.ts.map +1 -1
  6. package/dist/context/evidence.d.ts +6 -8
  7. package/dist/context/evidence.d.ts.map +1 -1
  8. package/dist/context/evidence.js +6 -20
  9. package/dist/context/evidence.js.map +1 -1
  10. package/dist/context/index.d.ts +23 -0
  11. package/dist/context/index.d.ts.map +1 -0
  12. package/dist/context/index.js +26 -0
  13. package/dist/context/index.js.map +1 -0
  14. package/dist/context/onChange.d.ts +9 -0
  15. package/dist/context/onChange.d.ts.map +1 -0
  16. package/dist/context/onChange.js +37 -0
  17. package/dist/context/onChange.js.map +1 -0
  18. package/dist/source-conformance.d.ts +1 -1
  19. package/dist/source-conformance.d.ts.map +1 -1
  20. package/dist/source-conformance.js +1 -1
  21. package/dist/source-conformance.js.map +1 -1
  22. package/dist/source-drizzle.d.ts +1 -1
  23. package/dist/source-drizzle.d.ts.map +1 -1
  24. package/dist/source-drizzle.js +1 -1
  25. package/dist/source-drizzle.js.map +1 -1
  26. package/dist/source-kysely.d.ts +1 -1
  27. package/dist/source-kysely.d.ts.map +1 -1
  28. package/dist/source-kysely.js +1 -1
  29. package/dist/source-kysely.js.map +1 -1
  30. package/dist/source-next.d.ts +1 -1
  31. package/dist/source-next.d.ts.map +1 -1
  32. package/dist/source-next.js +1 -1
  33. package/dist/source-next.js.map +1 -1
  34. package/docs/agent-integration-decision-guide.md +123 -0
  35. package/docs/agents.md +18 -14
  36. package/docs/api-keys.md +6 -6
  37. package/docs/api.md +52 -29
  38. package/docs/branch-development.md +23 -4
  39. package/docs/cli.md +16 -9
  40. package/docs/client-behavior.md +21 -15
  41. package/docs/concurrency-convention.md +67 -77
  42. package/docs/context.md +56 -31
  43. package/docs/coordination.md +115 -36
  44. package/docs/data-sources.md +12 -6
  45. package/docs/debugging.md +1 -1
  46. package/docs/examples/agent-human.md +6 -18
  47. package/docs/examples/coordination-conformance.md +69 -0
  48. package/docs/examples/existing-document-pipeline.md +488 -0
  49. package/docs/examples/existing-python-backend.md +10 -13
  50. package/docs/examples/nextjs.md +2 -2
  51. package/docs/examples/scoped-agent.md +18 -1
  52. package/docs/examples/server-agent.md +2 -2
  53. package/docs/groups.md +19 -139
  54. package/docs/guarantees.md +5 -6
  55. package/docs/identity.md +2 -1
  56. package/docs/index.md +5 -0
  57. package/docs/integration-guide.md +20 -19
  58. package/docs/integrations/sandbox-runtime.md +148 -0
  59. package/docs/integrations.md +9 -0
  60. package/docs/operating-on-your-database.md +7 -0
  61. package/docs/quickstart.md +19 -13
  62. package/docs/react.md +9 -9
  63. package/docs/schema-contract.md +14 -13
  64. package/docs/sessions.md +1 -1
  65. package/examples/README.md +2 -2
  66. package/examples/agent-turn.ts +1 -1
  67. package/examples/expensive-agent-turn.ts +1 -1
  68. package/llms.txt +22 -11
  69. package/package.json +6 -6
  70. package/dist/context/sources.d.ts +0 -21
  71. package/dist/context/sources.d.ts.map +0 -1
  72. package/dist/context/sources.js +0 -36
  73. package/dist/context/sources.js.map +0 -1
  74. package/dist/context.d.ts +0 -22
  75. package/dist/context.d.ts.map +0 -1
  76. package/dist/context.js +0 -33
  77. package/dist/context.js.map +0 -1
@@ -1,59 +1,59 @@
1
1
  # Concurrency Convention
2
2
 
3
- > What Ablo checks when a guarded write depends on earlier state.
3
+ > A write either declares what it read or deliberately does not.
4
4
 
5
- Ablo never infers whether a write depends on earlier state. You decide, in two
6
- places. The model's `conflict` setting in the schema says what each kind of
7
- participant does when it hits a conflict, and it is the policy for that model.
8
- A per-write `onStale` states the disposition for one write. Ablo enforces what
9
- you declared and nothing else.
5
+ Ablo does not put a configurable stale mode between your code and a
6
+ commit. The public choice is visible at the call site:
10
7
 
11
- ## Unguarded writes
12
-
13
- A plain write has no stale premise:
14
-
15
- ```ts
16
- await ablo.records.update({ id, data: { status: 'done' } });
17
- ```
18
-
19
- If no active claim conflicts with it, the write is last-write-wins. That is a
20
- choice rather than a fallback: use it for independent assignments where the
21
- latest value should win. When a model's writes are never independent, say so
22
- once in its `conflict` setting instead of at every call site.
8
+ - `get` and `list` observe state. They do not create a write premise.
9
+ - `read` returns a row that can be passed to a mutation in `reads`.
10
+ - a mutation with `reads` rejects with `AbloStaleContextError` if any declared
11
+ premise changed;
12
+ - a mutation without `reads` is an unconditional, last-write-wins assignment
13
+ when no active claim applies.
23
14
 
24
15
  ## Guarded writes
25
16
 
26
- Pass the exact returned rows when a write is based on values previously read:
17
+ Use `read` for every row that materially influenced a decision, then pass the
18
+ exact returned objects to the mutation:
27
19
 
28
20
  ```ts
29
- const record = await ablo.records.get({ id });
30
- const policy = await ablo.policies.get({ id: policyId });
31
- if (!record || !policy) throw new Error('required input is missing');
21
+ const record = await ablo.records.read({ id });
22
+ const rules = await ablo.rules.read({ id: rulesId });
23
+ if (!record || !rules) throw new Error('required input is missing');
32
24
 
33
25
  await ablo.records.update({
34
26
  id: record.id,
35
- data: { status: 'done' },
36
- reads: [record, policy],
27
+ data: decide(record, rules),
28
+ reads: [record, rules],
37
29
  });
38
30
  ```
39
31
 
40
- Ablo privately resolves each exact object to its model, id, and read watermark,
41
- then compares those premises with current state when the write is accepted.
42
- Clones, fabrications, and rows returned by another client are rejected locally.
32
+ Ablo records only the evidence needed for the check: model, id, and the
33
+ watermark at which the row was read. It does not retain the row's contents.
34
+ The exact object identity matters, so clones, fabricated rows, and rows from a
35
+ different client are rejected locally.
43
36
 
44
- | Disposition | If the premise is stale |
45
- |---|---|
46
- | `reject` | Reject the write with `AbloStaleContextError`. |
47
- | `notify` | Keep the current row, return a `StaleNotification`, and let the caller reconcile. |
48
- | `overwrite` | Apply the new value without enforcing the stale premise. |
37
+ The server validates every declared premise inside the write transaction. If
38
+ one is stale, the entire mutation rejects before any write applies. Re-read,
39
+ recompute, and submit a new mutation when that is the behavior you want.
49
40
 
50
- `notify` is useful when an agent or human can merge the new information.
51
- `reject` is useful when the caller should restart from fresh state. Use
52
- `overwrite` only when the newer assignment should unconditionally win.
41
+ ## Unguarded writes
42
+
43
+ Use `get` or `list` when you only need to observe, and omit `reads` when the
44
+ new value should win regardless of what was previously observed:
45
+
46
+ ```ts
47
+ const visible = await ablo.records.get({ id });
48
+ await ablo.records.update({ id, data: { status: 'done' } });
49
+ ```
50
+
51
+ This is deliberately unconditional, not an implicit fallback. It is suitable
52
+ for independent assignments and inappropriate for read-modify-write decisions.
53
53
 
54
54
  ## Functional updates
55
55
 
56
- For a pure read-modify-write calculation, use the functional update form:
56
+ For a pure calculation based on one current row, use the functional form:
57
57
 
58
58
  ```ts
59
59
  await ablo.counters.update(counterId, (current) => ({
@@ -61,59 +61,49 @@ await ablo.counters.update(counterId, (current) => ({
61
61
  }));
62
62
  ```
63
63
 
64
- It performs the read, guarded write, and bounded reconciliation loop for you.
65
- See [Coordination](./coordination.md#functional-updates).
64
+ The SDK reads, attempts a guarded write, and retries from fresh state within a
65
+ bounded budget. Because the updater may run more than once, do not perform
66
+ side effects inside it.
66
67
 
67
68
  ## Claims
68
69
 
69
- A claim protects a target across a longer interval. By default, other
70
- participants cannot write the claimed target, while contenders that claim it
71
- wait their turn. Reads remain open. A model's explicit conflict policy can
72
- choose a different disposition for a participant kind.
70
+ A claim protects a target across a slower read decide → write interval.
71
+ Foreign writers are rejected while the claim is active; contenders that ask
72
+ to queue wait in order. Ordinary reads stay open.
73
73
 
74
- Claims and stale guards protect different things:
74
+ Claims and stale reads answer different questions:
75
75
 
76
- - A claim excludes other participants while it is held.
77
- - A stale guard proves that the state a write depended on has not changed.
78
- - A write made under a claim is still rejected if its own claimed snapshot has
79
- become stale.
76
+ | Mechanism | Lifetime | Question |
77
+ |---|---|---|
78
+ | `reads` | One mutation | Is every input to this decision still current? |
79
+ | claim | Slow work interval | Who may write this target while work is underway? |
80
+ | database transaction | One apply | Can this physical change commit atomically? |
81
+ | idempotency key | Retries | Has this same mutation already been applied? |
80
82
 
81
- See [Coordination](./coordination.md#claims) for the API.
83
+ Claims do not hold a Postgres transaction open while an agent thinks. The
84
+ database transaction remains short and owns only validation plus apply.
82
85
 
83
86
  ## Cross-row and batch premises
84
87
 
85
- Model writes and lower-level commits can declare rows they read even when the
86
- write targets somewhere else. This protects decisions such as “update the record
87
- only if the deal I inspected has not changed.” A stale batch premise applies to
88
- the whole batch so atomicity is preserved.
89
-
90
- Use the high-level model methods unless you are building a custom runtime. When
91
- you do use batch premises, declare only the rows or groups that materially
92
- influenced the decision; overly broad premises create unnecessary contention.
88
+ A write may depend on rows other than its target. Put every influential row in
89
+ `reads`; if any one changed, Ablo rejects the whole mutation so atomicity is
90
+ preserved. Declare only material dependencies, because broader premises create
91
+ more contention.
93
92
 
94
- ## Notifications
93
+ Low-level runtimes can also declare row or group watermarks directly. They have
94
+ the same fixed result: stale rejects, fresh applies.
95
95
 
96
- A `StaleNotification` identifies the stale premise and provides the current
97
- state needed to reconcile. The original write has not been applied.
96
+ ## Live change delivery is separate
98
97
 
99
- A typical loop is:
100
-
101
- 1. Inspect the current value in the notification.
102
- 2. Recompute the intended change.
103
- 3. Submit a new guarded write with a fresh premise.
104
-
105
- Give this loop a retry budget. Continuous contention should surface to the
106
- caller rather than retry forever.
98
+ Model `onChange` is not a stale-write disposition. It streams committed changes
99
+ to a stateful WebSocket client. `context().onChange` has the narrower job of
100
+ calling once when one of that context's exact reads changes; HTTP delivers it
101
+ through a response held open for that listener. Neither replaces passing those
102
+ same `reads` to create, update, or delete.
107
103
 
108
104
  ## Boundaries
109
105
 
110
- Concurrency control does not replace:
111
-
112
- - database constraints and transactions for application invariants;
113
- - authorization for deciding who may read or write;
114
- - idempotency for safely replaying the same request;
115
- - claims for exclusivity across slow, side-effecting work.
116
-
117
- The rule is simple: the model's `conflict` setting is the policy, and each write
118
- declares what it read. Plain writes are last-write-wins because declaring
119
- nothing is itself a decision, so make it deliberately.
106
+ Concurrency control does not replace authorization, database constraints,
107
+ transactions, or idempotency. The rule at the SDK boundary is intentionally
108
+ small: `read` declares a dependency, `reads` enforces it, and omission means an
109
+ unconditional write.
package/docs/context.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Context
2
2
 
3
3
  > Assemble the current information for an action and carry its authoritative
4
- > Ablo reads into the write that follows.
4
+ > Ablo reads into the model write or atomic commit that follows.
5
5
 
6
6
  `context()` is a standalone SDK function. It does not run a model, keep a
7
7
  conversation, search documents, or create memory. The application chooses the
8
8
  values; Ablo awaits them and identifies the exact returned rows that can guard
9
- a later write.
9
+ a later model write or atomic commit.
10
10
 
11
11
  ## Context, model, write
12
12
 
@@ -21,7 +21,7 @@ import { generateText } from 'ai';
21
21
  const ctx = await context({
22
22
  ablo,
23
23
  data: {
24
- record: ablo.records.get({ id: recordId }),
24
+ record: ablo.records.read({ id: recordId }),
25
25
  records: ablo.records.list({ where: { recordId } }),
26
26
  memory: loadMemories(recordId),
27
27
  },
@@ -42,6 +42,25 @@ await ablo.records.update({
42
42
  });
43
43
  ```
44
44
 
45
+ The same captured rows guard an atomic batch. There is no second read format
46
+ and no manual conversion step:
47
+
48
+ ```ts
49
+ await ablo.commits.create({
50
+ operations: [
51
+ { action: 'update', model: 'records', id: recordId, data: update },
52
+ { action: 'create', model: 'auditEvents', id: eventId, data: event },
53
+ ],
54
+ reads: ctx.reads,
55
+ idempotencyKey: runId,
56
+ });
57
+ ```
58
+
59
+ Both the stateless HTTP client and the reactive WebSocket client resolve these
60
+ captured rows into canonical `{ model, id, readAt }` dependencies before the
61
+ commit reaches the transport. A claim returned by a typed model resource can
62
+ also be passed directly as the batch `claim`.
63
+
45
64
  If an authoritative row moves during the model call, the update rejects with
46
65
  `AbloStaleContextError`. Rebuild the context before trying again. The model is
47
66
  not called or retried by `context()`.
@@ -54,7 +73,7 @@ protection according to the work:
54
73
  | Situation | Use | Why |
55
74
  |---|---|---|
56
75
  | Bring several current values into one model call | `context()` | Awaits the selected values and collects their evidence. |
57
- | Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when the write reaches the server. |
76
+ | Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when a model write or atomic commit reaches the server. |
58
77
  | Avoid paying for a model call while another participant owns the row | `claim()` | Waits first, then supplies fresh state. |
59
78
  | Compute a patch from one current row without external work | Functional `update()` | Re-reads and retries the pure calculation. |
60
79
 
@@ -64,39 +83,49 @@ See [Coordination](./coordination.md) for the full choice.
64
83
 
65
84
  ## Result
66
85
 
67
- The result has four members:
86
+ The result has three members:
68
87
 
69
88
  | Member | Meaning |
70
89
  |---|---|
71
90
  | `data` | The selected values, with nested promises resolved. |
72
- | `reads` | Exact Ablo rows accepted by a write's `reads` option. |
73
- | `cursor` | The greatest watermark among those authoritative reads, or `null`. |
74
- | `sources` | One provenance summary for each top-level value. |
91
+ | `reads` | Exact Ablo rows accepted by a model write or atomic commit's `reads` option. |
92
+ | `onChange` | Calls a listener once if any exact row in `reads` changes. Returns a function that stops listening. |
75
93
 
76
- If a row in `ctx.reads` moves before the write, the server rejects the write as
77
- stale. A plain value can inform the action, but it does not gain that guarantee.
78
- This distinction is visible in `sources`:
94
+ If a row in `ctx.reads` moves before a model write or atomic commit, the server
95
+ rejects the operation as stale. Plain values remain in `ctx.data`, but only
96
+ exact Ablo reads appear in `ctx.reads` and gain that guarantee.
79
97
 
80
- ```ts
81
- ctx.sources;
82
- // [
83
- // { key: 'record', kind: 'ablo', guarantee: 'guardable', cursor: 42 },
84
- // { key: 'memory', kind: 'value', guarantee: 'informational', cursor: null },
85
- // ]
86
- ```
98
+ ## Stop work when context changes
87
99
 
88
- A top-level value may contain both kinds. It is then marked `mixed` and only
89
- its exact Ablo rows appear in `ctx.reads`:
100
+ `onChange` lets long-running work stop early without changing the write rule:
90
101
 
91
102
  ```ts
92
- // data: { briefing: { record, memory } }
93
- // sources: [
94
- // { key: 'briefing', kind: 'mixed', guarantee: 'partial', cursor: 42 },
95
- // ]
103
+ const controller = new AbortController();
104
+ const stop = ctx.onChange((error) => controller.abort(error));
105
+
106
+ try {
107
+ const result = await generateText({
108
+ model,
109
+ abortSignal: controller.signal,
110
+ messages: [contextMessage(ctx)],
111
+ });
112
+
113
+ await ablo.records.update({
114
+ id: ctx.data.record.id,
115
+ data: parseTaskUpdate(result.text),
116
+ reads: ctx.reads,
117
+ });
118
+ } finally {
119
+ stop();
120
+ }
96
121
  ```
97
122
 
98
- `partial` does not weaken the included Ablo rows. It says the surrounding value
99
- also contains information Ablo cannot guard.
123
+ The first listener starts delivery and all listeners on that context share it.
124
+ The last returned `stop` closes it. A context with no reads opens nothing. The
125
+ first matching change calls every listener with `AbloStaleContextError`, then
126
+ delivery closes. The final create, update, or delete must still receive
127
+ `reads: ctx.reads`; that check remains authoritative if delivery races the
128
+ write or is disconnected.
100
129
 
101
130
  ## External context
102
131
 
@@ -108,7 +137,7 @@ Reducto, or another system behind their own interfaces.
108
137
  const ctx = await context({
109
138
  ablo,
110
139
  data: {
111
- record: ablo.records.get({ id: recordId }),
140
+ record: ablo.records.read({ id: recordId }),
112
141
  memory: loadMemories({ query, userId }),
113
142
  related: findRelatedChunks({ projectId, query }),
114
143
  evidence: extractEvidence({ recordId }),
@@ -157,14 +186,10 @@ The first version deliberately has no:
157
186
 
158
187
  - search or memory API;
159
188
  - provider registry or provider-specific adapter;
160
- - `since` cursor or incremental `changes` result;
161
189
  - context session, persistence, or sharing lifecycle;
162
190
  - token counting, trimming, summarisation, or model call;
163
191
  - guarantee that a person or model understood the included information.
164
192
 
165
- Store `ctx.cursor` in application-owned state if it is useful. Incremental
166
- context is not yet derived from it.
167
-
168
193
  `context` remains available as a schema model name. The helper lives at
169
194
  `@abloatai/ablo/context`; it does not add `ablo.context()` or reserve a member
170
195
  of the schema-backed client.
@@ -9,7 +9,7 @@ meaning. Choose the narrowest one that matches the operation.
9
9
  |---|---|---|
10
10
  | Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. |
11
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: [record, policy]` | Rejects when an explicitly named dependency changed. |
12
+ | Write only if earlier rows are still current | `reads: [record, rules]` | Rejects when an explicitly named dependency changed. |
13
13
  | Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. |
14
14
 
15
15
  **If a model call sits between the read and the write, take a claim.** A stale
@@ -25,8 +25,8 @@ does not carry a stale premise. It is intentionally last-write-wins.
25
25
  Pass the exact rows that produced a decision on the write:
26
26
 
27
27
  ```ts
28
- const record = await ablo.records.get({ id: recordId });
29
- const policy = await ablo.policies.get({ id: policyId });
28
+ const record = await ablo.records.read({ id: recordId });
29
+ const policy = await ablo.policies.read({ id: policyId });
30
30
  if (!record || !policy) throw new Error('required input is missing');
31
31
 
32
32
  const result = await model({ record, policy });
@@ -91,7 +91,7 @@ Use explicit returned rows when application code reads first and writes later,
91
91
  but does not need to reserve the row:
92
92
 
93
93
  ```ts
94
- const report = await ablo.reports.get({ id: reportId });
94
+ const report = await ablo.reports.read({ id: reportId });
95
95
  if (!report) throw new Error('report missing');
96
96
 
97
97
  await ablo.reports.update({
@@ -101,37 +101,13 @@ await ablo.reports.update({
101
101
  });
102
102
  ```
103
103
 
104
- The dispositions are:
105
-
106
- | `onStale` | Behavior |
107
- |---|---|
108
- | `reject` | Reject the write if its premise is stale. |
109
- | `notify` | Leave the row unchanged and return the current value for reconciliation. |
110
- | `overwrite` | Apply the write without a stale check. |
104
+ There is no stale-mode option on the write. If a declared row changed, Ablo
105
+ rejects the whole mutation with `AbloStaleContextError`. Re-read and recompute,
106
+ or use the functional update form when the computation is pure and retryable.
107
+ To make an unconditional assignment, omit `reads` deliberately.
111
108
 
112
109
  See [Concurrency Convention](./concurrency-convention.md) for guarded batches
113
- and notifications.
114
-
115
- ### Decide the model's conflict policy
116
-
117
- Who yields is a design decision about the model, not something to restate on
118
- every write. Declare it once, in the schema, and it travels to the server with
119
- the rest of the model:
120
-
121
- ```ts
122
- import { coordination, model, z } from '@abloatai/ablo/schema';
123
-
124
- const cards = model(
125
- { title: z.string() },
126
- {
127
- conflict: coordination.humansOverwrite().agentsReject(),
128
- },
129
- );
130
- ```
131
-
132
- An omitted participant kind uses the engine default, `reject`. A per-write
133
- `onStale` states the disposition for that one write. Keep the policy simple, and
134
- document any rule that lets a participant overwrite a held claim.
110
+ and the `get` / `read` boundary.
135
111
 
136
112
  ## Claims
137
113
 
@@ -155,7 +131,7 @@ await ablo.reports.update({
155
131
  If another participant already holds the target, `claim` waits its turn and
156
132
  then resolves with a fresh row in `claim.data`. Ordinary reads remain open. By
157
133
  default, a write from a participant that does not hold the active claim is
158
- rejected; an explicit model conflict policy can choose otherwise.
134
+ rejected.
159
135
 
160
136
  Bind claims with `await using` whenever possible. The claim then releases when
161
137
  the scope exits, including when the external call or write throws. For runtimes
@@ -198,7 +174,7 @@ try {
198
174
  }
199
175
  ```
200
176
 
201
- To wait with limits, keep the policy together:
177
+ To wait with limits, keep the contention settings together:
202
178
 
203
179
  ```ts
204
180
  const claim = await ablo.records.claim({
@@ -226,6 +202,19 @@ await using claim = await ablo.records.claim({
226
202
  Claims on disjoint fields can coexist. A whole-row claim conflicts with every
227
203
  field claim on that row.
228
204
 
205
+ ### Relations do not create hierarchical claims
206
+
207
+ A `parent: true` relation controls ownership, access inheritance, and sync
208
+ routing. It does not make claims conflict across related rows. For example, a
209
+ claim on one document row and a claim on one of its page rows have different
210
+ model-and-ID targets and can coexist.
211
+
212
+ Choose the row that represents the actual unit of exclusive work. Page rows
213
+ allow different pages to process concurrently. If a whole-document operation
214
+ must exclude every page operation, enumerate the authoritative page manifest,
215
+ acquire page claims in one stable order, and guard the manifest against change.
216
+ Do not infer that exclusion from the schema relation alone.
217
+
229
218
  The target options are:
230
219
 
231
220
  | Option | Purpose |
@@ -251,12 +240,102 @@ The main methods are:
251
240
 
252
241
  | Method | Purpose |
253
242
  |---|---|
254
- | `claim({ id })` | Acquire the target, waiting by default. |
243
+ | `claim({ id, ...options })` | Read and claim an existing model row; the handle includes fresh row data. |
244
+ | `claim(id, options)` | Claim an identifier in a registered model namespace without reading a row. |
255
245
  | `claim.state({ id })` | Read the current holder without blocking. |
256
246
  | `claim.queue({ id })` | Read the current wait order. |
257
247
  | `claim.release({ id })` | Release early when you do not hold a handle. |
258
248
  | `join({ scope })` | Observe presence for a broader scope. |
259
249
 
250
+ Choose the overload deliberately:
251
+
252
+ | Form | Evidence requirement | Typical use |
253
+ |---|---|---|
254
+ | `model.claim({ id })` | The row exists and the caller may read it. | Coordinate work on a synchronized row while using `handle.data`. |
255
+ | `model.claim(id, options)` | The model namespace is registered; no row is read. | Select one participant before calling an existing authoritative service. |
256
+
257
+ The identifier-only form is row-free, not schema-free. It does not authorize a
258
+ worker or test fixture to push an unrelated model into an inherited production
259
+ schema. Register the namespace through the application's normal schema process,
260
+ or select an already registered namespace whose ownership matches the operation.
261
+
262
+ ## Coordinate an existing database operation
263
+
264
+ Use this pattern when an application already has a service that protects a
265
+ transition with a Postgres row lock or advisory lock, but slow preparation such
266
+ as OCR, a model call, or another tool currently happens while that database
267
+ lock is held.
268
+
269
+ Keep the ownership boundary explicit:
270
+
271
+ | Owner | Responsibility |
272
+ |---|---|
273
+ | Ablo claim | Select one participating worker before expensive work begins. |
274
+ | Application service | Authoritative re-read, transition policy, database lock, idempotency, and commit. |
275
+ | Postgres | Canonical row, constraints, and final integrity boundary. |
276
+
277
+ The operation runs in this order:
278
+
279
+ ```text
280
+ claim identifier
281
+ -> prepare expensive result once
282
+ -> application service re-reads and commits under its database lock
283
+ -> release claim in finally
284
+ ```
285
+
286
+ Model the service seam as two operations rather than moving database policy
287
+ into a resolver, worker, or agent tool:
288
+
289
+ ```ts
290
+ interface ExistingOperationService<Input, Result, Row> {
291
+ run(
292
+ input: Input,
293
+ prepare: () => Promise<Result>,
294
+ ): Promise<Row>;
295
+
296
+ commitPrepared(
297
+ input: Input,
298
+ prepared: Result,
299
+ ): Promise<Row>;
300
+ }
301
+ ```
302
+
303
+ The existing rollout path calls `run` and preserves current behavior. The
304
+ coordinated path wins the claim, prepares once, then calls `commitPrepared`.
305
+ Both methods stay under the same application-service owner and enforce the same
306
+ authorization and transition rules.
307
+
308
+ When the transition permits it, implement `commitPrepared` as one SQL statement
309
+ that acquires a transaction-level advisory lock, re-reads the row, validates
310
+ its current state, and updates it. The statement's implicit transaction
311
+ releases the advisory lock automatically. This can remove several sequential
312
+ client/database round trips without replacing the existing database lock.
313
+
314
+ Do not make any of these substitutions:
315
+
316
+ - Do not assume a remote Ablo request joins a local Postgres transaction.
317
+ - Do not remove database constraints or locks during the coordination rollout.
318
+ - Do not prepare expensive work speculatively before the claim resolves.
319
+ - Do not assume direct SQL writers obey an Ablo claim. A claim coordinates only
320
+ callers routed through the participating operation.
321
+ - Do not use a claim as durable workflow state. A lease expires; workflow
322
+ progress must survive independently.
323
+
324
+ Measure the old and coordinated paths with the same inputs. Record cold and
325
+ warm claim acquire/release latency, database round-trip latency, database-lock
326
+ duration, end-to-end latency, duplicate work under contention, and recovery
327
+ after worker exit. Keep a per-operation switch to the old path until the new
328
+ path preserves behavior and improves the selected race at production
329
+ percentiles.
330
+
331
+ For a runnable GraphQL.js implementation and PostgreSQL race/crash proof, see
332
+ [GraphQL.js over an existing backend](./approaches/graphql/graphql-js.md).
333
+ For a domain-neutral hosted lease proof, see
334
+ [Verify hosted coordination separately](./examples/coordination-conformance.md).
335
+ For the same operation boundary applied to source-versioned document
336
+ processing, see
337
+ [Process an existing document once](./examples/existing-document-pipeline.md).
338
+
260
339
  ## Choosing correctly
261
340
 
262
341
  - Prefer a plain update for values that do not depend on an earlier read.
@@ -110,12 +110,12 @@ migrations — your migration tool stays in charge of the shape of your database
110
110
  Ablo only writes rows into tables you already have, through a role scoped to
111
111
  exactly that.
112
112
 
113
- > **Just trying Ablo?** You don't need a database to start. Pass an `apiKey` only,
114
- > and Ablo keeps your rows in its own log so you can build the whole app today.
115
- > `ablo dev` gives each Git branch its own isolated plane.
116
- > Keep it hosted with no database, or point that branch at a separate/local
117
- > Postgres. Connect your production root (below) when you're ready for its
118
- > database to be the system of record.
113
+ > **Just trying Ablo?** Start on a throwaway Postgres rather than your production
114
+ > one. `ablo dev` gives each Git branch its own isolated plane, so point that
115
+ > branch at a separate or local database, build against it, and connect your
116
+ > production root (below) when you're ready for its database to be the system of
117
+ > record. A branch with nothing connected refuses a schema push, which is the
118
+ > first thing you'll hit if you skip this.
119
119
 
120
120
  Connecting sets up two capabilities on your Postgres: **logical replication**, so
121
121
  Ablo can read and confirm, and a **scoped DML role**, so Ablo can write. `ablo
@@ -475,6 +475,12 @@ directly by other application code is visible only if that code writes the same
475
475
  outbox record in its transaction. Native WAL observation sees both Ablo and
476
476
  external writes.
477
477
 
478
+ Endpoint events use a versioned envelope. Version 2 freezes `syncGroups` in the
479
+ writing transaction; version 1 is retained only to decode events written by an
480
+ older adapter during a rolling upgrade. Poll requests keep `cursor` (where to
481
+ read) separate from `acknowledgedThrough` (what Ablo has durably accepted), and
482
+ the built-in adapters prune acknowledged rows in bounded batches.
483
+
478
484
  ## Next steps
479
485
 
480
486
  - [Quickstart](./quickstart.md) — connect and write through `ablo.<model>`.
package/docs/debugging.md CHANGED
@@ -86,7 +86,7 @@ Read it as the lifecycle of one claim:
86
86
  - **`queued … position N of M`:** the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance.
87
87
  - **`granted … your turn`:** you reached the head of the line; the lease is now yours and the row may have changed while you waited.
88
88
  - **`rejected … held by <who>`:** your claim was refused because someone else holds it (and the model's policy didn't let you in).
89
- - **`lost`:** you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
89
+ - **`lost`:** you held the lease and it ended (the queue fairness ceiling advanced, or it expired).
90
90
  - **`released`:** you (or `await using`'s scope exit) gave the lease back.
91
91
 
92
92
  ## Where the logs run
@@ -25,7 +25,7 @@ a typed error if the row moved underneath you while the agent was busy.
25
25
  ## Schema-Backed Worker
26
26
 
27
27
  The worker uses the same schema client the app uses. It reads the record from the
28
- server with `get({ id })`, claims the row, and writes through
28
+ server with `read({ id })`, claims the row, and writes through
29
29
  `ablo.records.update(...)` with a stale-check so a concurrent edit can't be
30
30
  overwritten.
31
31
 
@@ -49,8 +49,8 @@ const ablo = Ablo({
49
49
  export async function markDone(recordId: string) {
50
50
  await ablo.ready();
51
51
 
52
- // get({ id }) is an async server read — await it.
53
- const record = await ablo.records.get({ id: recordId });
52
+ // read({ id }) is an async server read — await it.
53
+ const record = await ablo.records.read({ id: recordId });
54
54
  if (!record) return { status: 'not_found' };
55
55
 
56
56
  try {
@@ -67,21 +67,9 @@ export async function markDone(recordId: string) {
67
67
  await using claim = acquired;
68
68
  if (claim.data.status === 'done') return { status: 'noop' };
69
69
 
70
- // Inside an active claim, `update` is stale-checked automatically: the SDK
71
- // attaches the claim's snapshot version as `readAt` and sets
72
- // `onStale: 'reject'`. The write below is therefore equivalent to passing
73
- // those options yourself:
74
- //
75
- // ablo.records.update({
76
- // id: claim.data.id,
77
- // data: { status: 'done' },
78
- // readAt: <claim snapshot version>,
79
- // onStale: 'reject',
80
- // });
81
- //
82
- // If a newer version landed mid-run, the row no longer matches `readAt`, so
83
- // the server rejects this commit with AbloStaleContextError (caught below)
84
- // instead of clobbering that edit.
70
+ // The claim handle carries its acquisition snapshot. If a newer version
71
+ // somehow lands mid-run, the server rejects this commit with
72
+ // AbloStaleContextError instead of clobbering that edit.
85
73
  const updated = await ablo.records.update({
86
74
  id: claim.data.id,
87
75
  data: { status: 'done' },