@abloatai/ablo 0.57.0 → 0.59.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 (90) hide show
  1. package/AGENTS.md +12 -5
  2. package/CHANGELOG.md +269 -13
  3. package/README.md +6 -5
  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/agents.md +38 -15
  35. package/docs/api-keys.md +6 -6
  36. package/docs/api.md +107 -34
  37. package/docs/basic-usage.md +84 -0
  38. package/docs/branch-development.md +23 -4
  39. package/docs/cli.md +16 -9
  40. package/docs/client-behavior.md +27 -29
  41. package/docs/comparison.md +63 -0
  42. package/docs/concurrency-convention.md +92 -75
  43. package/docs/context.md +76 -31
  44. package/docs/coordinate-existing-work.md +104 -0
  45. package/docs/coordination.md +93 -38
  46. package/docs/data-sources.md +12 -6
  47. package/docs/debugging.md +1 -1
  48. package/docs/deployment.md +19 -1
  49. package/docs/examples/agent-human.md +6 -18
  50. package/docs/examples/coordination-conformance.md +69 -0
  51. package/docs/examples/evidence-backed-document-pipeline.md +488 -0
  52. package/docs/examples/existing-python-backend.md +10 -13
  53. package/docs/examples/nextjs.md +2 -2
  54. package/docs/examples/scoped-agent.md +18 -1
  55. package/docs/examples/server-agent.md +2 -2
  56. package/docs/faq.md +75 -0
  57. package/docs/groups.md +19 -139
  58. package/docs/guarantees.md +8 -8
  59. package/docs/idempotency.md +3 -0
  60. package/docs/identity.md +2 -1
  61. package/docs/implement.md +61 -0
  62. package/docs/implementation-index.md +20 -0
  63. package/docs/index.md +59 -173
  64. package/docs/installation.md +77 -0
  65. package/docs/instrumentation.md +52 -0
  66. package/docs/integration-guide.md +20 -19
  67. package/docs/integrations/sandbox-runtime.md +157 -0
  68. package/docs/integrations.md +9 -0
  69. package/docs/migration.md +12 -7
  70. package/docs/operating-on-your-database.md +7 -0
  71. package/docs/options.md +172 -0
  72. package/docs/quickstart.md +25 -14
  73. package/docs/react.md +9 -9
  74. package/docs/schema-contract.md +14 -13
  75. package/docs/security.md +64 -0
  76. package/docs/sessions.md +1 -1
  77. package/examples/README.md +8 -2
  78. package/examples/agent-turn.ts +1 -1
  79. package/examples/expensive-agent-turn.ts +1 -1
  80. package/examples/stale-context-agent-turn.ts +106 -0
  81. package/llms.txt +23 -12
  82. package/package.json +7 -6
  83. package/dist/context/sources.d.ts +0 -21
  84. package/dist/context/sources.d.ts.map +0 -1
  85. package/dist/context/sources.js +0 -36
  86. package/dist/context/sources.js.map +0 -1
  87. package/dist/context.d.ts +0 -22
  88. package/dist/context.d.ts.map +0 -1
  89. package/dist/context.js +0 -33
  90. package/dist/context.js.map +0 -1
@@ -1,8 +1,11 @@
1
1
  # Client Behavior
2
2
 
3
- > Per-write options, claim behavior, and which errors are safe to retry.
3
+ > Guarded writes, claim behavior, and which errors are safe to retry.
4
4
 
5
- When several writers touch the same data at once — an agent worker, a Server Action, a person in the browser — the SDK decides whose write lands and how the others find out. This page is the reference for that: per-write options like `wait` and `onStale`, claiming a record so your slow work runs uninterrupted, and which errors are safe to retry.
5
+ When several writers touch the same data at once — an agent worker, a Server
6
+ Action, a person in the browser — the SDK protects explicit read dependencies
7
+ and claims records across slow work. This page describes those guarantees and
8
+ which errors are safe to retry.
6
9
 
7
10
  Claims don't lock. If another writer holds the row, `claim` waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
8
11
 
@@ -25,20 +28,10 @@ const ablo = Ablo({
25
28
  });
26
29
  ```
27
30
 
28
- Common options:
29
-
30
- | Option | Purpose |
31
- |---|---|
32
- | `schema` | Required for typed model clients. |
33
- | `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
34
- | `baseURL` | Override the hosted sync endpoint for staging or private deployments. An HTTPS origin, optionally with a path prefix; plain HTTP is accepted for localhost. Your key travels here, so a URL carrying its own credentials, a query, or a fragment is refused at construction. |
35
- | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
36
- | `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
37
- | `transport` | `'websocket'` (default) is the live, stateful client: a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (the `local` reads, `onChange`, `join`) are compile errors rather than runtime gaps. |
38
- | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
39
- | `defaultHeaders` | Extra headers attached to every HTTP request. |
40
- | `defaultQuery` | Extra query parameters attached to every HTTP request. |
41
- | `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. |
31
+ The package-root export is the stateless HTTP client for agents, workers, route
32
+ handlers, and other server operations. See [Options](./options.md) for its exact
33
+ constructor reference. Live state and local reads are added through the
34
+ [React client](./react.md).
42
35
 
43
36
  Your database connects out of band — through logical replication (`npx ablo
44
37
  connect`), or the signed [Data Source](./data-sources.md) endpoint as the
@@ -53,7 +46,7 @@ Each schema model becomes a typed model:
53
46
  ```ts
54
47
  await ablo.ready();
55
48
 
56
- const report = await ablo.weatherReports.get({ id: 'report_stockholm' });
49
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
57
50
  const local = ablo.weatherReports.local.get('report_stockholm');
58
51
 
59
52
  await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
@@ -65,7 +58,7 @@ On the reactive client, each model write changes local state optimistically
65
58
  before the call returns. Its promise always waits for authoritative
66
59
  confirmation, so `await update(...)` is the confirmation barrier.
67
60
 
68
- Call `get`/`list` first they fetch from the server and you `await` them.
61
+ Call `get`/`list` to observe, or `read` when a later mutation depends on the row.
69
62
  After that, `local.get`/`local.list`/`local.count` read the already-synced data instantly with
70
63
  no `await`, and stay reactive in render. Use the async pair to load, the sync trio
71
64
  to read.
@@ -83,14 +76,13 @@ through the same model client path. A human Server Action, a browser view, and a
83
76
  agent worker can all use `ablo.weatherReports`:
84
77
 
85
78
  ```ts
86
- const report = await ablo.weatherReports.get({ id });
87
- const snap = ablo.snapshot({ weatherReports: id });
79
+ const report = await ablo.weatherReports.read({ id });
80
+ if (!report) throw new Error('Row not found');
88
81
 
89
82
  await ablo.weatherReports.update({
90
83
  id,
91
84
  data: patch,
92
- readAt: snap.stamp,
93
- onStale: 'reject',
85
+ reads: [report],
94
86
  });
95
87
  ```
96
88
 
@@ -106,24 +98,28 @@ actor routing through Ablo is coordinated. The one write it can't coordinate is
106
98
  one made directly against your database, around Ablo — the WAL echo still catches
107
99
  it for reads, but it bypasses claims and ordering.
108
100
 
109
- ## Per-Write Options
101
+ ## Guarded Writes
110
102
 
111
103
  ```ts
104
+ const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
105
+ if (!report) throw new Error('report not found');
106
+
112
107
  await ablo.weatherReports.update({
113
- id: 'report_stockholm',
108
+ id: report.id,
114
109
  data: { status: 'ready' },
115
- readAt: snap.stamp,
116
- onStale: 'reject',
110
+ reads: [report],
117
111
  idempotencyKey: 'report_stockholm:mark-ready:v1',
118
112
  });
119
113
  ```
120
114
 
121
115
  | Option | Purpose |
122
116
  |---|---|
123
- | `readAt` | State cursor the write was based on. |
124
- | `onStale` | Policy when the target changed after `readAt`. Prefer `reject`. |
117
+ | `reads` | Exact rows returned by `read` that this mutation depends on. |
125
118
  | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
126
119
 
120
+ A stale premise always rejects with `AbloStaleContextError`. Omit `reads` only
121
+ when the assignment is intentionally unconditional.
122
+
127
123
  ## Claimed Behavior
128
124
 
129
125
  If your update involves a slow step — an API call, an LLM round-trip — and someone
@@ -163,7 +159,9 @@ stream, so they never poll.
163
159
 
164
160
  ## Errors
165
161
 
166
- All SDK errors extend `AbloError` and carry a stable `type`.
162
+ All SDK errors extend `AbloError`. `type` is the class-name discriminator, such
163
+ as `AbloStaleContextError`; `code` is the wire condition, such as
164
+ `stale_context`. Use `instanceof` in-process and `type` after serialization.
167
165
 
168
166
  | Error | Typical cause |
169
167
  |---|---|
@@ -0,0 +1,63 @@
1
+ # Comparison
2
+
3
+ > How Ablo relates to the coordination tools an experienced team may already use.
4
+
5
+ Teams do not need Ablo to build a lock. Redis reservations and PostgreSQL locks
6
+ are proven, inexpensive primitives. Ablo is useful when the same team would
7
+ otherwise have to define ownership, expiry, recovery, waiting, participant
8
+ identity, stale-result handling, and visibility for each new workflow.
9
+
10
+ ## Versus PostgreSQL locks
11
+
12
+ - **Keep the lock.** PostgreSQL should continue to protect the short,
13
+ authoritative database transaction.
14
+ - **Coordinate before the transaction.** An Ablo claim can cover the model call,
15
+ document search, browser session, or tool run that happens before commit.
16
+ - **Keep the connection short-lived.** The application does not have to hold one
17
+ database session while an agent waits on external work.
18
+ - **Revalidate at commit.** The existing service still applies authorization,
19
+ constraints, version checks, and business rules.
20
+
21
+ ## Versus Redis reservations
22
+
23
+ - **The primitive is familiar.** Ablo uses expiring leases for live ownership
24
+ and waiting; it does not claim that temporary reservations are novel.
25
+ - **The lifecycle is defined.** Acquisition, skip, wait, heartbeat, release,
26
+ expiry, cancellation, and recovery share one client contract.
27
+ - **Ownership has identity.** A claim belongs to a scoped participant rather
28
+ than only an arbitrary worker string.
29
+ - **Correctness stays durable.** PostgreSQL and commit-time version checks remain
30
+ the backstop when an expired worker resumes late.
31
+ - **Contention is visible.** Owners, waiters, duration, and rejection reasons use
32
+ the same operational model across workflows.
33
+
34
+ ## Versus queues and workflow engines
35
+
36
+ - Queues decide who receives a job; claims decide who may act on a contested
37
+ business resource.
38
+ - Redelivery still needs idempotency, and delivery does not prove the rows behind
39
+ a decision are unchanged.
40
+ - Workflow engines remain the right owner for durable steps, timers, and retry
41
+ history. Ablo coordinates those workflows with other agents, services, and
42
+ people touching the same state.
43
+
44
+ ## Versus rolling your own
45
+
46
+ - Start without designing Redis key conventions, ownership tokens, renewal,
47
+ safe release, wait queues, and crash recovery for every call site.
48
+ - Reuse one participant and authorization model across workers and human
49
+ interfaces.
50
+ - Test against one documented failure contract instead of rebuilding delayed
51
+ worker, expiry, retry, and partial-failure tests per workflow.
52
+ - Add captured reads, guarded writes, and atomic Ablo commits only when the
53
+ operation needs them.
54
+
55
+ ## When Ablo is not necessary
56
+
57
+ PostgreSQL or a small internal reservation can be enough when one team controls
58
+ every writer, work is short, contention is rare, and stale or duplicate work is
59
+ cheap. Ablo becomes more valuable as slow agent work, independent participants,
60
+ shared resources, recovery, authority, and operational explanation matter.
61
+
62
+ The adoption boundary is intentionally small: keep the architecture that
63
+ already works and [coordinate one existing operation](./coordinate-existing-work.md).
@@ -1,59 +1,81 @@
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
+ Use a captured read when a write must be rejected because an earlier premise
6
+ changed.
10
7
 
11
- ## Unguarded writes
12
-
13
- A plain write has no stale premise:
8
+ Read the authoritative premise, carry
9
+ that exact evidence into the write, and handle the documented failure code.
14
10
 
15
- ```ts
16
- await ablo.records.update({ id, data: { status: 'done' } });
17
- ```
11
+ Ablo does not put a configurable stale mode between your code and a
12
+ commit. The public choice is visible at the call site:
18
13
 
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.
14
+ - `get` and `list` observe state. They do not create a write premise.
15
+ - `read` returns a row that can be passed to a mutation in `reads`.
16
+ - a mutation with `reads` rejects with `AbloStaleContextError` if any declared
17
+ premise changed;
18
+ - a mutation without `reads` is an unconditional, last-write-wins assignment
19
+ when no active claim applies.
23
20
 
24
21
  ## Guarded writes
25
22
 
26
- Pass the exact returned rows when a write is based on values previously read:
23
+ Use `read` for every row that materially influenced a decision, then pass the
24
+ exact returned objects to the mutation:
27
25
 
28
26
  ```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');
27
+ const record = await ablo.records.read({ id });
28
+ const rules = await ablo.rules.read({ id: rulesId });
29
+ if (!record || !rules) throw new Error('required input is missing');
32
30
 
33
31
  await ablo.records.update({
34
32
  id: record.id,
35
- data: { status: 'done' },
36
- reads: [record, policy],
33
+ data: decide(record, rules),
34
+ reads: [record, rules],
37
35
  });
38
36
  ```
39
37
 
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.
38
+ Ablo records only the evidence needed for the check: model, id, and the
39
+ watermark at which the row was read. It does not retain the row's contents.
40
+ The exact object identity matters, so clones, fabricated rows, and rows from a
41
+ different client are rejected locally.
42
+
43
+ The server validates every declared premise inside the write transaction. If
44
+ one is stale, the entire mutation rejects before any write applies. Re-read,
45
+ recompute, and submit a new mutation when that is the behavior you want.
46
+
47
+ ```ts
48
+ import { AbloStaleContextError } from '@abloatai/ablo';
49
+
50
+ try {
51
+ await submitGuardedWrite();
52
+ } catch (error) {
53
+ if (error instanceof AbloStaleContextError && error.code === 'stale_context') {
54
+ return rebuildFromFreshReads();
55
+ }
56
+ throw error;
57
+ }
58
+ ```
59
+
60
+ `error.type` is the class-name discriminator (`AbloStaleContextError`);
61
+ `error.code` is the wire condition (`stale_context`).
62
+
63
+ ## Unguarded writes
64
+
65
+ Use `get` or `list` when you only need to observe, and omit `reads` when the
66
+ new value should win regardless of what was previously observed:
43
67
 
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. |
68
+ ```ts
69
+ const visible = await ablo.records.get({ id });
70
+ await ablo.records.update({ id, data: { status: 'done' } });
71
+ ```
49
72
 
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.
73
+ This is deliberately unconditional, not an implicit fallback. It is suitable
74
+ for independent assignments and inappropriate for read-modify-write decisions.
53
75
 
54
76
  ## Functional updates
55
77
 
56
- For a pure read-modify-write calculation, use the functional update form:
78
+ For a pure calculation based on one current row, use the functional form:
57
79
 
58
80
  ```ts
59
81
  await ablo.counters.update(counterId, (current) => ({
@@ -61,59 +83,54 @@ await ablo.counters.update(counterId, (current) => ({
61
83
  }));
62
84
  ```
63
85
 
64
- It performs the read, guarded write, and bounded reconciliation loop for you.
65
- See [Coordination](./coordination.md#functional-updates).
86
+ The SDK reads, attempts a guarded write, and retries from fresh state within a
87
+ bounded budget. Because the updater may run more than once, do not perform
88
+ side effects inside it.
66
89
 
67
90
  ## Claims
68
91
 
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.
92
+ A claim protects a target across a slower read decide → write interval.
93
+ Foreign writers are rejected while the claim is active; contenders that ask
94
+ to queue wait in order. Ordinary reads stay open.
73
95
 
74
- Claims and stale guards protect different things:
96
+ When the final effect remains in an existing application path—such as its API,
97
+ database transaction, filesystem, or Git merge—start with
98
+ [Coordinate existing work](./coordinate-existing-work.md). Use the
99
+ row-backed claim below when the target and final write belong to an Ablo model.
75
100
 
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.
101
+ Claims and stale reads answer different questions:
80
102
 
81
- See [Coordination](./coordination.md#claims) for the API.
103
+ | Mechanism | Lifetime | Question |
104
+ |---|---|---|
105
+ | `reads` | One mutation | Is every input to this decision still current? |
106
+ | claim | Slow work interval | Who may write this target while work is underway? |
107
+ | database transaction | One apply | Can this physical change commit atomically? |
108
+ | idempotency key | Retries | Has this same mutation already been applied? |
82
109
 
83
- ## Cross-row and batch premises
110
+ Claims do not hold a Postgres transaction open while an agent thinks. The
111
+ database transaction remains short and owns only validation plus apply.
84
112
 
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.
93
-
94
- ## Notifications
113
+ ## Cross-row and batch premises
95
114
 
96
- A `StaleNotification` identifies the stale premise and provides the current
97
- state needed to reconcile. The original write has not been applied.
115
+ A write may depend on rows other than its target. Put every influential row in
116
+ `reads`; if any one changed, Ablo rejects the whole mutation so atomicity is
117
+ preserved. Declare only material dependencies, because broader premises create
118
+ more contention.
98
119
 
99
- A typical loop is:
120
+ Low-level runtimes can also declare row or group watermarks directly. They have
121
+ the same fixed result: stale rejects, fresh applies.
100
122
 
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.
123
+ ## Live change delivery is separate
104
124
 
105
- Give this loop a retry budget. Continuous contention should surface to the
106
- caller rather than retry forever.
125
+ Model `onChange` is not a stale-write disposition. It streams committed changes
126
+ to a stateful WebSocket client. `context().onChange` has the narrower job of
127
+ calling once when one of that context's exact reads changes; HTTP delivers it
128
+ through a response held open for that listener. Neither replaces passing those
129
+ same `reads` to create, update, or delete.
107
130
 
108
131
  ## Boundaries
109
132
 
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.
133
+ Concurrency control does not replace authorization, database constraints,
134
+ transactions, or idempotency. The rule at the SDK boundary is intentionally
135
+ small: `read` declares a dependency, `reads` enforces it, and omission means an
136
+ 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,69 @@ 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.
129
+
130
+ ## Retry stale agent work
131
+
132
+ Use this policy for a long-running turn:
133
+
134
+ 1. Create one operation key before the retry loop.
135
+ 2. Build a new context on every attempt.
136
+ 3. Use `onChange` to abort the model and cancellable tools.
137
+ 4. Still pass `reads: ctx.reads` to the final write.
138
+ 5. Retry a stale attempt at most a small fixed number of times.
139
+
140
+ Automatic retry is safe only before the first external action that cannot be
141
+ canceled. After sending an email, charging a card, or receiving an uncertain
142
+ tool response, look up that action by the same operation key. Do not run it
143
+ again unless that tool explicitly guarantees the retry is safe.
144
+
145
+ [`examples/stale-context-agent-turn.ts`](../examples/stale-context-agent-turn.ts)
146
+ is the complete copyable loop. Put that function in the application operation
147
+ that owns the write; GraphQL resolvers and route handlers should call it once,
148
+ not add another retry loop.
100
149
 
101
150
  ## External context
102
151
 
@@ -108,7 +157,7 @@ Reducto, or another system behind their own interfaces.
108
157
  const ctx = await context({
109
158
  ablo,
110
159
  data: {
111
- record: ablo.records.get({ id: recordId }),
160
+ record: ablo.records.read({ id: recordId }),
112
161
  memory: loadMemories({ query, userId }),
113
162
  related: findRelatedChunks({ projectId, query }),
114
163
  evidence: extractEvidence({ recordId }),
@@ -157,14 +206,10 @@ The first version deliberately has no:
157
206
 
158
207
  - search or memory API;
159
208
  - provider registry or provider-specific adapter;
160
- - `since` cursor or incremental `changes` result;
161
209
  - context session, persistence, or sharing lifecycle;
162
210
  - token counting, trimming, summarisation, or model call;
163
211
  - guarantee that a person or model understood the included information.
164
212
 
165
- Store `ctx.cursor` in application-owned state if it is useful. Incremental
166
- context is not yet derived from it.
167
-
168
213
  `context` remains available as a schema model name. The helper lives at
169
214
  `@abloatai/ablo/context`; it does not add `ablo.context()` or reserve a member
170
215
  of the schema-backed client.