@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.
package/AGENTS.md CHANGED
@@ -11,7 +11,7 @@ Don't hand-write the integration. Run the CLI; it generates the current-API sche
11
11
  - **Read the docs for THIS version:** `npx ablo docs` lists every page, `npx ablo docs <page>` prints one. They ship inside the installed package, so they describe the code in `node_modules` and work with no network. Read them instead of a docs URL — a website describes the newest release, so against a pinned version it will hand you a call your package doesn't have (`retrieve`/`list` replaced `get`/`getAll`/`getCount` in 0.35.0).
12
12
  - **Scaffold:** `npx ablo init --yes` — flag-driven, never prompts. Override defaults with `--framework <nextjs|vite|remix|vanilla>`, `--auth <apikey|…>`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`. (Plain `ablo init` needs a TTY and will **HANG** in an agent/CI run — always pass `--yes`.)
13
13
  - **Auth:** set `ABLO_API_KEY` in the environment. Do **NOT** run `ablo login` — it opens a browser device flow and blocks an agent.
14
- - **Connect your database — logical replication (the primary path):** `npx ablo connect` prints the setup SQL (`wal_level=logical`, a publication, a `REPLICATION` role); `npx ablo connect register` registers the source with Ablo in one step. Ablo **consumes your Postgres' logical-replication stream** it never runs DDL on, writes to, owns, or migrates your database, and your application keeps the write path. Registration **is** the enable; there is no tier or flag to pick. (Ablo hosts only the transaction log + coordination, never your rows.)
14
+ - **Connect your database — logical replication (the primary path):** `npx ablo connect apply --url <postgres-url>` provisions scoped roles and replication using the admin credential supplied for that command. Model writes then go through Ablo and land in your Postgres; its change stream confirms them. Ablo does not own your rows or run application-schema migrations. Your ORM remains responsible for tables, columns, and constraints.
15
15
  - **Fallback — signed Data Source endpoint** (DB can't grant a `REPLICATION` role): the generated `ablo/data-source.ts` exposes one route; Ablo sends signed requests and your app touches its own DB. **Only in this mode** does `npx ablo migrate` provision the adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`) plus your Ablo models — it does **not** touch your other tables. Keep your own migrations (drizzle-kit / prisma migrate) for auth and anything outside the Ablo schema.
16
16
  - **No database yet?** Run `npx ablo dev --no-watch --branch <name>` to create an isolated non-root branch and obtain its expiring `sk_` credential. The branch uses a throwaway hosted data plane; Production remains the protected root. There is no shared Sandbox mode.
17
17
  - **Adopt an existing DB schema:** `npx ablo pull prisma [path]` / `pull drizzle <module>` (lossless) or `pull` (live DB, lossy). Writes `ablo/schema.ts`.
@@ -21,7 +21,7 @@ Don't hand-write the integration. Run the CLI; it generates the current-API sche
21
21
 
22
22
  When you use the signed-endpoint fallback, the generated `ablo/data-source.ts` is the whole endpoint and needs no hand-editing: `dataSourceNext({ schema, apiKey, adapter: prismaDataSource(prisma, schema) })` (or `drizzleDataSource(db, schema)`). The adapter owns commit / idempotency / outbox.
23
23
 
24
- **Working on a real database?** Ablo never runs DDL, migrates, or drops on your database, and every model write is claim-checked and reversible in the log so reads and `ablo.<model>.update(...)` are yours to run freely, while raw DDL (`ALTER TABLE …`) and a `--yes` cutover belong to a human. When you're unsure whether a write fits, `npx ablo check` reports the live column-by-column fit read-only, before anything runs. Full sorting rule: [Operating on Your Database](./docs/operating-on-your-database.md).
24
+ **Working on a real database?** Plain model writes are last-write-wins when no active claim applies. Use a functional update, a held claim, or `readAt` when a result depends on an earlier value. Reads are safe to inspect; raw application DDL (`ALTER TABLE …`) and a `--yes` connection cutover belong to a human. When you're unsure whether a write fits, `npx ablo check` reports the live column-by-column fit read-only, before anything runs. Full sorting rule: [Operating on Your Database](./docs/operating-on-your-database.md).
25
25
 
26
26
  ## Rule
27
27
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.0
4
+
5
+ ### An agent can tell Ablo what it read before it writes
6
+
7
+ An agent reads a row, spends a model call deciding what to do, and then writes.
8
+ Another agent can change that row while the model is still thinking, and the
9
+ write lands anyway, on top of a decision that is no longer true. Pass the rows
10
+ the decision was based on:
11
+
12
+ ```ts
13
+ const task = await ablo.tasks.get({ id: taskId });
14
+ await ablo.tasks.update({
15
+ id: task.id,
16
+ data: { status: 'done', result: `Completed: ${task.title}` },
17
+ reads: [task],
18
+ });
19
+ ```
20
+
21
+ If either row moved while the agent was thinking, the write is refused instead
22
+ of overwriting. The rows carry that evidence themselves, so there is nothing to
23
+ set up around your agent and no wrapper to run it inside. One row or several,
24
+ the same row you are writing or a different one, all use `reads`. Rows an agent
25
+ read without passing stay out of it, so `reads` says what the decision rested on
26
+ rather than everything the agent happened to look at.
27
+
28
+ `idempotencyKey` stays a separate option. It gives a write one stable identity if
29
+ the agent retries, which is a different question from what the write assumed.
30
+
31
+ ### A claim holds while an agent thinks
32
+
33
+ Agents that take minutes per turn can now hold work safely. A claim waits its
34
+ turn or skips, expires on its own, and keeps itself alive with a heartbeat while
35
+ the agent works.
36
+
37
+ If an agent loses its claim during a model call, its final write is refused. Two
38
+ agents cannot both believe they own the same task and both write, and a slow
39
+ agent cannot land its answer on top of whoever picked the work up after it. Ablo
40
+ decides who holds the claim, so an agent cannot assert one it does not have.
41
+
42
+ ### An agent can read back what it committed
43
+
44
+ Ask what happened to a write, using the same key the agent wrote with:
45
+
46
+ ```ts
47
+ const record = await ablo.commits.get({ id: commitId });
48
+ ```
49
+
50
+ The answer says who committed, what they intended, whether it is confirmed, and
51
+ which claim protected it. `commits.list` walks the history a page at a time, so
52
+ one agent can review what another already did before repeating it.
53
+
54
+ What an agent sent is not kept. Prompts, reasoning, and your customers' row
55
+ values are removed before the record is stored, so reading history back never
56
+ replays an agent's inputs. Records are kept for 90 days, and permanently for a
57
+ database you connected.
58
+
59
+ ### An agent can check what its key allows before it acts
60
+
61
+ An agent holding a key can now ask what that key permits and get the answer from
62
+ Ablo, whether it keeps a connection open or calls over HTTP for a single turn.
63
+ An agent that mints a narrower key for a sub-task can confirm what it handed
64
+ over.
65
+
66
+ ### A refused action says which permission was missing
67
+
68
+ When Ablo refuses, the error names the permission the agent needed. An agent can
69
+ report exactly what it lacked, or request it, instead of retrying a call that
70
+ will never succeed.
71
+
72
+ ### Models named in camelCase resolve when writing to your own database
73
+
74
+ A model whose key mixes capital letters did not match its declared name when the
75
+ write went to your database directly, so those writes could not find their
76
+ target. They resolve now.
77
+
78
+ ### `ablo connect apply` says when a database is already connected
79
+
80
+ Connecting a database that another project already owns reported a missing table
81
+ mapping, which described a symptom rather than the reason the command could not
82
+ continue. It now says the database is already connected. Nothing is written
83
+ while it checks, and a project with no models still gets its preflight.
84
+
85
+ **Action required.** Install this version rather than a tarball or a Git
86
+ dependency. This release pairs the SDK with the engine running behind
87
+ `api.abloatai.com`, which is already serving it, so there is nothing to
88
+ coordinate on your side.
89
+
3
90
  ## 0.48.0
4
91
 
5
92
  ### A branch is unbound until you connect a database to it
@@ -46,6 +133,26 @@ temporary alias can go:
46
133
  DROP PUBLICATION IF EXISTS "ablo_publication";
47
134
  ```
48
135
 
136
+ ### `ablo dev --local` connects a branch to the database on your machine
137
+
138
+ The rule above raises a fair question: if a branch is unbound until a database
139
+ is connected, what connects one during development? `--local` does.
140
+
141
+ ```bash
142
+ npx ablo dev --local
143
+ ```
144
+
145
+ It registers a connector-only endpoint for that exact branch and opens a
146
+ long-lived secure connector. Your database stays where it is: Ablo receives an
147
+ endpoint descriptor and a signing key, never a connection string, and reaches
148
+ your source back through the connector rather than dialling it. `DATABASE_URL`
149
+ is read from `.env.local` into the handler running on your machine and goes no
150
+ further.
151
+
152
+ The branch is then connected like any other, so schema pushes, reads and writes
153
+ behave the way they will in production. Because the connector is long-lived,
154
+ `--local` cannot be combined with `--no-watch`.
155
+
49
156
  ### Renamed
50
157
 
51
158
  `FootprintPlane` is now `DataSourceIdentity`, with the same three fields. The
@@ -433,7 +540,7 @@ through the same transaction API as every other caller.
433
540
 
434
541
  The integrations keep each product in its proper role: Temporal and Inngest
435
542
  own durable execution, scheduling, retries, and workflow history; Ablo owns
436
- shared-data authority, claims, conflicts, idempotency, settlement, and ordered
543
+ shared-data authority, claims, conflicts, idempotency, confirmation, and ordered
437
544
  observation. Workflow code does not open WebSockets or hold live client state.
438
545
 
439
546
  ### Database adapter foundation, starting with PostgreSQL
@@ -537,7 +644,7 @@ Install `@abloatai/ablo` as the single public SDK:
537
644
  - `@abloatai/ablo/react` provides the React bindings.
538
645
 
539
646
  Every entrypoint uses the same schema, capabilities, commits, claims,
540
- idempotency, settlement, and ordered changes. Authoritative reads use
647
+ idempotency, confirmation, and ordered changes. Authoritative reads use
541
648
  `model.get({ id })`; local reactive snapshots use `model.local.get(id)`.
542
649
 
543
650
  ### Coordination now matches the unit applications can safely write
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>The transaction layer for AI agents.</strong>
6
+ <strong>Collaboration infrastructure for AI agents.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -22,17 +22,12 @@
22
22
 
23
23
  ---
24
24
 
25
- > **Reading the implementation?** Start with the
26
- > **[source code map](./CODEMAP.md)**. It shows which files own `create`,
27
- > `update`, `delete`, `claim`, schemas, transports, and the reactive client.
25
+ Ablo is collaboration infrastructure for AI agents: one API for agents, apps,
26
+ and services to claim, change, and confirm the same rows.
28
27
 
29
- Safely coordinate AI agents, humans, workflows, and services writing to the
30
- same database.
31
-
32
- Ablo is an authoritative transaction layer for shared application state. Every
33
- write goes through one typed API where authority, idempotency, conflicts,
34
- ordering, and confirmation can be enforced. Your Postgres remains the source
35
- of truth.
28
+ Every write goes through it, so authority, idempotency, conflicts, ordering,
29
+ and confirmation are enforced in one place. Your Postgres remains the source of
30
+ truth.
36
31
 
37
32
  ## Why Ablo
38
33
 
@@ -58,7 +53,7 @@ npx ablo dev
58
53
  temporary credential to gitignored `.env.local`, pushes the schema, and watches
59
54
  for changes.
60
55
 
61
- Read and write through the transaction layer:
56
+ Read and write through one typed API:
62
57
 
63
58
  ```ts
64
59
  const order = await ablo.orders.get({ id: orderId });
@@ -12,7 +12,7 @@ HTTP, or be replayed from the sync cursor.
12
12
  | Need | Use | Why |
13
13
  | --- | --- | --- |
14
14
  | "I am holding this row because..." | `claim({ description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
15
- | "Remember this handoff/status/request" | A `messages` model | Durable row. Ordered in `sync_deltas`, replayed after reconnect, readable by HTTP agents. |
15
+ | "Remember this handoff/status/request" | A `messages` model | Durable row, replayed after reconnect and readable by HTTP agents. |
16
16
 
17
17
  Claim context is ephemeral. If a participant was offline, reconnected later, or
18
18
  only uses `transport: "http"`, it can miss claim/presence frames. Message rows
@@ -141,6 +141,6 @@ their cursor and see the rows they missed while offline.
141
141
  ## Retention
142
142
 
143
143
  Deleting or archiving old `messages` rows is your app's policy. The sync log is
144
- still durable audit/history: `sync_deltas` has no message-specific TTL. That is
144
+ still durable audit/history: messages have no message-specific TTL. That is
145
145
  useful for coordination and compliance, but a chat-scale product should plan
146
146
  retention before writing high-volume conversation traffic.
package/docs/api.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  > The per-method reference for every model call an agent or an interface can make.
4
4
 
5
- > **Upgrading?** Every breaking change and its migration is on the
6
- > [Version History & Migration Guide](./migration.md).
5
+ > **Upgrading?** Follow the version-matched workflow in the
6
+ > [Upgrade Guide](./migration.md), then read the intervening changelog entries.
7
7
 
8
8
  This is the per-method reference for reading and writing rows that stay in
9
9
  sync across sessions. You declare your models once, then call the same
@@ -1,305 +1,119 @@
1
1
  # Concurrency Convention
2
2
 
3
- > The governing rule for how Ablo resolves concurrent writes to shared state.
3
+ > What Ablo checks when a guarded write depends on earlier state.
4
4
 
5
- This page is the contract: the `onStale` dispositions, what a conflict is
6
- checked against, and where the convention stops. The three-layer mechanics of
7
- claiming live in [Coordination](./coordination.md).
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.
8
10
 
9
- ---
11
+ ## Unguarded writes
10
12
 
11
- ## 1. The principle: non-coercion
12
-
13
- **The engine surfaces the truthful current state and lets the intelligent actor —
14
- agent or human — decide what to do. It does not force a resolution.**
15
-
16
- That is the whole convention. Everything below is a consequence of it.
17
-
18
- Classical concurrency control is *coercive*: it imposes the remedy. Two-phase
19
- locking forces a block; optimistic concurrency forces an abort. Ablo's wager is
20
- that the actor in the loop (an agent reasoning over the change, or a human
21
- watching the row) is better placed to resolve a conflict than a fixed rule baked
22
- into the storage layer. So the engine's job narrows to one thing: **report what
23
- is true, on time, and get out of the way.**
24
-
25
- There are two forms of non-coercion, and they are the same principle at two
26
- moments in time:
27
-
28
- | form | when | mechanism |
29
- |---|---|---|
30
- | **Claim** | *prospective*: before you act | reserve the row; others queue. Coordinate so the conflict never forms. |
31
- | **Notification** | *in-flight*: after a concurrent change | surface the changed value; the actor resolves and re-issues. |
32
-
33
- Use a claim when you will hold the row across a slow read→reason→write gap. Use a
34
- notification when you didn't, and the premise moved under you.
35
-
36
- ---
37
-
38
- ## 2. The dispositions (`onStale`)
39
-
40
- Every guarded write (and every premise declared in §4) says what should happen
41
- when it goes stale. Three modes, split by whether they **force** an outcome:
42
-
43
- | mode | coercive? | what the engine does | who resolves | use when |
44
- |---|---|---|---|---|
45
- | `notify` | **No**: surface + delegate | Holds the write (does **not** apply it); returns a `StaleNotification` with the current value. | The actor (agent or human) reconciles and re-issues. | The aligned mode: tell the actor what changed, let it solve. |
46
- | `reject` | **Yes**: force-abort | Throws `AbloStaleContextError`; the batch is discarded. | The caller retries from scratch. | Hard invariants; legacy/strict callers. The current default. |
47
- | `overwrite` | **Yes**: force-clobber | Overwrites blindly last-writer-wins; **no** signal. | Nobody. | You genuinely own the field and concurrent values are noise. |
48
-
49
- > `notify` is the convention. `reject` and `overwrite` are escape hatches for the
50
- > two ends — "never let this be wrong" and "never bother me." They are not the
51
- > spirit; they are the boundary of it.
52
-
53
- ---
54
-
55
- ## 3. What is checked: two premises
56
-
57
- A conflict is a **premise intersection** — what your operation was based on
58
- overlaps a concurrent delta. Ablo checks two premises, and they are independent.
59
- They differ only in what declared them:
60
-
61
- | premise | declared by | question | scope |
62
- |---|---|---|---|
63
- | **Write-target** | per-op `readAt` | "did a row I'm **writing** change since I read it?" | the rows in `operations[]` |
64
- | **Batch** | batch-level `reads[]` | "did anything I **looked at** change since I read it?" | rows/groups in `reads[]`, even if not written |
65
-
66
- The write-target check alone is the narrow case the canary anomaly defeats: an
67
- agent reads `deal.stage`, writes `task.status`, and a peer moves `deal.stage` —
68
- `task` never changed, so a write-target-only check waves it through. The batch
69
- premise closes that gap.
70
-
71
- ---
72
-
73
- ## 4. The batch premise (`reads[]`)
74
-
75
- A commit may declare, at the batch level, what its writes were based on.
76
- Two granularities, developer's choice per entry:
13
+ A plain write has no stale premise:
77
14
 
78
15
  ```ts
79
- reads: [
80
- { model: 'Document', id: 's-1', readAt: N, fields?: ['title'] }, // ROW premise
81
- { group: 'workspace:abc', readAt: N, onStale: 'notify' }, // GROUP premise
82
- ]
16
+ await ablo.tasks.update({ id, data: { status: 'done' } });
83
17
  ```
84
18
 
85
- - **Row:** did this specific row (optionally these fields) change? The literal
86
- per-object premise.
87
- - **Group:** did *anything* in this sync group change? `group` is a sync-group
88
- key (`workspace:abc`, `document:s1`, `org:X`) the same unit a participant **watches
89
- and claims**. This is the more Ablo-native granularity.
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.
90
23
 
91
- **Boundary a stale premise fires over the whole batch.** Each entry covers
92
- *all* the writes in the commit, so its disposition governs the batch:
93
- `reject` aborts it, `notify` holds **every** write and notifies, `overwrite`
94
- lets them land. Per-entry `onStale` defaults to `reject`.
24
+ ## Guarded writes
95
25
 
96
- ---
26
+ Pass the exact returned rows when a write is based on values previously read:
97
27
 
98
- ## 5. The notification (`StaleNotification`)
99
-
100
- The non-coercive modes hand back data instead of throwing. The signal is
101
- delivered **twice**, by design once as a value, once as an event:
102
-
103
- - On the **commit receipt**: `receipt.notifications` (and `CommitResult.notifications`).
104
- - On the **event channel**: `conflict:notified` (mirrors `reconciliation:needed` /
105
- `sync:rollback`).
106
-
107
- Shape (canonical in `coordination/schema.ts`): one advisory in two scopes, told
108
- apart by `scope`, the same way `reads[]` entries come at two granularities.
28
+ ```ts
29
+ const task = await ablo.tasks.get({ id });
30
+ const policy = await ablo.policies.get({ id: policyId });
31
+ if (!task || !policy) throw new Error('required input is missing');
32
+
33
+ await ablo.tasks.update({
34
+ id: task.id,
35
+ data: { status: 'done' },
36
+ reads: [task, policy],
37
+ });
38
+ ```
109
39
 
110
- Both scopes carry:
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.
111
43
 
112
- | field | meaning |
44
+ | Disposition | If the premise is stale |
113
45
  |---|---|
114
- | `object` | stable type tag: `'stale_notification'` |
115
- | `scope` | `'row'` or `'group'` which premise granularity fired |
116
- | `target` | **the row that moved**, and which parts: `{ model, id, fields }`. The same locator a claim names its subject with |
117
- | `readAt` | the watermark the committer reasoned against |
118
- | `observedSyncId` | the newest delta on the premise: re-read at/after this |
119
- | `writtenBy` | `{ kind, id }` of the concurrent author, reported faithfully |
120
-
121
- `scope: 'row'` adds:
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. |
122
49
 
123
- | field | meaning |
124
- |---|---|
125
- | `currentValues` | the live values of `target.fields`: the premise to reconcile against |
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.
126
53
 
127
- `scope: 'group'` adds:
54
+ ## Functional updates
128
55
 
129
- | field | meaning |
130
- |---|---|
131
- | `group` | the group premise that fired (`report:abc`) |
132
- | `propagation?` | how `target` reached `group`: `{ via, through }` |
133
- | `changed?` | how much of the group moved: `{ count, sample, truncated }` |
56
+ For a pure read-modify-write calculation, use the functional update form:
134
57
 
135
- `target` names the row that changed in **both** scopes. A group notification
136
- reports the moved row there and the premise it broke in `group`, so the two are
137
- never the same field.
58
+ ```ts
59
+ await ablo.counters.update(counterId, (current) => ({
60
+ value: current.value + 1,
61
+ }));
62
+ ```
138
63
 
139
- Only `notify` produces a notification (the write was held). `reject` throws and
140
- `overwrite` is silent — neither notifies.
64
+ It performs the read, guarded write, and bounded reconciliation loop for you.
65
+ See [Coordination](./coordination.md#functional-updates).
141
66
 
142
- ### 5.1 The receive → reconcile loop
67
+ ## Claims
143
68
 
144
- You receive the signal two ways (same payload), then re-commit against the fresh
145
- watermark. The engine never re-issues for you the actor decides.
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.
146
73
 
147
- ```ts
148
- // Trigger: a guarded write under the non-coercive mode.
149
- const receipt = await ablo.task.update({
150
- id, data: { status: 'blocked' },
151
- readAt: myWatermark,
152
- onStale: 'notify',
153
- });
74
+ Claims and stale guards protect different things:
154
75
 
155
- // Receive pull: the held write surfaces on the receipt.
156
- for (const n of receipt.notifications ?? []) resolve(n);
157
-
158
- // Receive — push: the same StaleNotification[] fires ambiently on the socket.
159
- ws.subscribe('conflict:notified', ({ notifications }) => notifications.forEach(resolve));
160
-
161
- function resolve(n: StaleNotification) {
162
- // n.target — the row that moved, and which fields (both scopes)
163
- // n.writtenBy — who moved it (e.g. { kind: 'agent', id: 'agent-b' })
164
- if (n.scope === 'group') {
165
- // "something in report:abc moved" — but `target` says WHICH row, so this is
166
- // a one-row re-read, not a re-read of the group.
167
- // n.propagation?.via — 'self' | 'parent' | 'transitive'
168
- return refreshRow(n.target.model, n.target.id, n.observedSyncId);
169
- }
170
- // n.currentValues — what's actually there now (e.g. { status: 'done' })
171
- if (!stillValid(n.currentValues)) return; // premise gone → drop the write
172
-
173
- return ablo.task.update({
174
- id: n.target.id,
175
- data: { status: 'blocked' },
176
- readAt: n.observedSyncId, // adopt the new high-water mark — this is what terminates the loop
177
- onStale: 'notify',
178
- });
179
- }
180
- ```
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.
181
80
 
182
- The loop **terminates** because each retry advances `readAt` to `observedSyncId`;
183
- a peer that keeps writing only ever notifies you against a *newer* baseline, never
184
- the same one twice.
81
+ See [Coordination](./coordination.md#claims) for the API.
185
82
 
186
- ### 5.2 Reading a group notification
83
+ ## Cross-row and batch premises
187
84
 
188
- A group premise says "anything in this group", so the notification has to answer
189
- "what, exactly?" or the only correct response is to re-read the whole group —
190
- which on a busy group is how a reconcile loop turns into a large, repeated read.
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 task
87
+ only if the deal I inspected has not changed.” A stale batch premise applies to
88
+ the whole batch so atomicity is preserved.
191
89
 
192
- Four fields answer it:
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.
193
93
 
194
- - **`target`** — the row that moved and the fields that changed on it. Usually a
195
- one-row re-read.
196
- - **`group`** — the premise that fired.
197
- - **`propagation`** — how the row reached the group. `via: 'self'` means the row
198
- *is* the group's scope root; `'parent'` means one containment edge below it;
199
- `'transitive'` means further up, with `through` listing the intermediate
200
- models (`['slides', 'decks']`).
201
- - **`changed`** — how much moved, in **distinct rows** rather than deltas.
202
- `count` is the total, `sample` names the most recently changed (capped), and
203
- `truncated` says whether the sample tells the whole story.
94
+ ## Notifications
204
95
 
205
- `changed` is what lets an actor decline. One row moved, re-read one row; twelve
206
- thousand moved, stop reasoning and resync the group or abandon:
96
+ A `StaleNotification` identifies the stale premise and provides the current
97
+ state needed to reconcile. The original write has not been applied.
207
98
 
208
- ```ts
209
- if (n.changed && n.changed.count > 50) return null; // too much moved — abandon the write
210
- for (const row of n.changed?.sample ?? []) await refresh(row.model, row.id);
211
- ```
99
+ A typical loop is:
212
100
 
213
- `propagation` is absent when the commit path has no record-group spec for the
214
- model, which is the one case where the server knows a group moved without
215
- knowing the route. Treat its absence as "re-read the group", the old behavior.
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.
216
104
 
217
- ### 5.3 Gating on what you know
105
+ Give this loop a retry budget. Continuous contention should surface to the
106
+ caller rather than retry forever.
218
107
 
219
- The notification tells you a belief moved. Whether that should *stop you writing*
220
- is a separate decision, and it belongs on the belief, not on the write.
108
+ ## Boundaries
221
109
 
222
- A `track` is a standing premise — what you are watching, with a watermark for
223
- when you last looked. Its disposition says what a move does to your next commit:
110
+ Concurrency control does not replace:
224
111
 
225
- ```ts
226
- await ablo.documents.track({ id: 's-1' }); // notify report it
227
- await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // reject gate my next write
228
- ```
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.
229
116
 
230
- `reject` refuses your next commit while that belief is stale, **even if the
231
- commit writes an unrelated row**. The gate is on what you *know*, not on what you
232
- are touching — which is the case a read→reason→write agent actually has.
233
-
234
- It is enforced at the commit chokepoint, so it is a guarantee rather than a
235
- convention. And the gate takes a key: it does not reopen on its own, because an
236
- agent retrying blindly would land exactly the write `reject` was asked to
237
- prevent. Re-read, then re-register the track to say you have. See
238
- [Groups](./groups.md#reporting-or-gating-onstale).
239
-
240
- A row that goes stale twice is telling you something: the read→reason→write gap
241
- wants the *prospective* guard, not the in-flight one. Escalate to a claim
242
- (§1) rather than raising `retries`.
243
-
244
- ---
245
-
246
- ## 6. Boundaries & invariants
247
-
248
- What the convention **guarantees**, and where it **stops**:
249
-
250
- 1. **Engine surfaces, actor decides.** Under `notify` the engine never
251
- repairs, merges, or re-plans. It reports `currentValues` and the actor (agent
252
- or human) owns the resolution. The engine does not distinguish them — it is
253
- actor-neutral by design.
254
-
255
- 2. **Truthfulness:** `currentValues` / `observedSyncId` reflect committed state at
256
- detection time, inside the same transaction as the write. A notification is
257
- never speculative.
258
-
259
- 3. **No livelock, which is not the same as termination.** The monotonic
260
- `sync_id` landing order is the serialization order. The stale committer
261
- always yields/recomputes — an asymmetry that rules out the symmetric
262
- notify-rewrite livelock, because each round adopts a newer `observedSyncId`
263
- and no baseline is ever reasoned against twice.
264
-
265
- It does **not** rule out starvation. A peer writing faster than your
266
- read→decide→write gap keeps winning, and the engine never re-issues on your
267
- behalf, so the rounds are yours and they are unbounded. Progress in the
268
- watermark is not progress in the work — and for an agent, each round is a
269
- model call. `update(id, fn)` bounds it for you and hands the conflict to
270
- your updater (§5.3); a hand-rolled loop must bound itself.
271
-
272
- 4. **Scope: reversible DB state only.** The convention governs writes to the
273
- shared database, which are inherently reversible (prior value in
274
- `sync_deltas`). **Irreversible external side-effects** (emails, payments,
275
- third-party calls) are *out of scope* — the engine cannot hold or undo them,
276
- so they must not be gated by `notify`.
277
-
278
- 5. **Defaults.** A plain write (no `readAt`) is last-writer-wins with **no**
279
- check. A guarded write with `readAt` but no `onStale` defaults to `reject`.
280
-
281
- 6. **Policy seam.** Custom `ConflictPolicy` functions see **write-target**
282
- conflicts (`stale_context` / `claim_held`). **Batch-premise** conflicts are
283
- resolved directly via each entry's `onStale`, not through the policy seam.
284
-
285
- 7. **Claims win when held.** A non-holder writing to a claimed row is rejected
286
- (`AbloClaimedError`) regardless of `readAt` — the prospective form takes
287
- precedence over the in-flight form. Only `user`/`system` principals may
288
- `bypass` a foreign claim; agents may not.
289
-
290
- ---
291
-
292
- ## 7. What this convention does not cover
293
-
294
- Three limits worth knowing before you rely on it.
295
-
296
- - **Irreversible external side-effects.** Emails, payments, and third-party
297
- calls are not gated by this convention (§6.4). The engine cannot hold or undo
298
- them, so never place one behind `notify`.
299
- - **A caller that declares nothing gets no check.** The batch premise catches
300
- only what you declared. Write-target checking needs a `readAt` to compare
301
- against, so a plain write with neither is last-writer-wins (§6.5). What you
302
- declare is what is protected.
303
- - **`writtenBy.kind` reports what authenticated, not what you meant.** An `sk_`
304
- key resolves to `system`, not `agent`. How identities map to participant kinds
305
- is a separate concern from this convention.
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.