@abloatai/ablo 0.39.0 → 0.40.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.0
4
+
5
+ ### The price is a contract, and the pricing page derives from it
6
+
7
+ Ablo's pricing now lives in one module: the tiers, the rate card, and the
8
+ arithmetic that turns a month of usage into a bill. Each tier has a monthly
9
+ floor, and metered usage is charged against that floor rather than added to
10
+ it, so an organization pays the greater of the two and never both. Commits,
11
+ reads, and claim creates roll into one metered axis. Concurrent connections
12
+ are a capacity reservation, so they are capped rather than billed.
13
+
14
+ The published pricing page is generated from the same contract the meter
15
+ charges against, and a CI gate fails any change that would let the page and
16
+ the invoice disagree. The dashboard gains a billing page that shows the
17
+ period's usage and what it costs.
18
+
19
+ ### Tracks that refuse to write on a stale belief
20
+
21
+ A durable track can now carry `onStale: 'reject'`. The tracker's next commit
22
+ is refused until it has observed the change it was tracking, so an agent
23
+ cannot write on the basis of a row it has been shown to be stale. The default
24
+ stays `notify`, which reports the movement on the receipt and lets the commit
25
+ through. Group premises now name the concrete row that moved rather than the
26
+ group alone, so reconciliation starts from the exact conflict instead of a
27
+ re-read of everything the group covers.
28
+
29
+ ### A CLI that fails in plain language
30
+
31
+ Every command that talks to Ablo's control plane goes through one typed HTTP
32
+ client. Failures arrive as named `cli_` error codes with plain-language
33
+ messages: a missing API key says how to log in, a missing connection string
34
+ says where the CLI looked, an unreachable database says what refused the
35
+ dial. Setting `ABLO_JSON=1` switches command output to a machine-readable
36
+ form for agents and scripts.
37
+
3
38
  ## 0.39.0
4
39
 
5
40
  ### Schema-relative session grants
@@ -104,19 +104,38 @@ delivered **twice**, by design — once as a value, once as an event:
104
104
  - On the **event channel**: `conflict:notified` (mirrors `reconciliation:needed` /
105
105
  `sync:rollback`).
106
106
 
107
- Shape (canonical in `coordination/schema.ts`):
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.
109
+
110
+ Both scopes carry:
108
111
 
109
112
  | field | meaning |
110
113
  |---|---|
111
114
  | `object` | Stripe-style type tag: `'stale_notification'` |
112
- | `model`, `id` | the conflicting row (for a group dep, both are the group key) |
113
- | `group?` | set when this is a group-scoped 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 |
114
117
  | `readAt` | the watermark the committer reasoned against |
115
118
  | `observedSyncId` | the newest delta on the premise: re-read at/after this |
116
- | `conflictingFields` | fields that moved (empty for group / whole-entity) |
117
- | `currentValues` | the live values of those fields: the premise to reconcile against (empty for group) |
118
119
  | `writtenBy` | `{ kind, id }` of the concurrent author, reported faithfully |
119
120
 
121
+ `scope: 'row'` adds:
122
+
123
+ | field | meaning |
124
+ |---|---|
125
+ | `currentValues` | the live values of `target.fields`: the premise to reconcile against |
126
+
127
+ `scope: 'group'` adds:
128
+
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 }` |
134
+
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.
138
+
120
139
  Only `notify` produces a notification (the write was held). `reject` throws and
121
140
  `overwrite` is silent — neither notifies.
122
141
 
@@ -134,18 +153,25 @@ const receipt = await ablo.task.update({
134
153
  });
135
154
 
136
155
  // Receive — pull: the held write surfaces on the receipt.
137
- for (const n of receipt.notifications ?? []) reconcile(n);
156
+ for (const n of receipt.notifications ?? []) resolve(n);
138
157
 
139
158
  // Receive — push: the same StaleNotification[] fires ambiently on the socket.
140
- ws.subscribe('conflict:notified', ({ notifications }) => notifications.forEach(reconcile));
141
-
142
- function reconcile(n: StaleNotification) {
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
+ }
143
170
  // n.currentValues — what's actually there now (e.g. { status: 'done' })
144
- // n.writtenBy — who moved it (e.g. { kind: 'agent', id: 'agent-b' })
145
171
  if (!stillValid(n.currentValues)) return; // premise gone → drop the write
146
172
 
147
173
  return ablo.task.update({
148
- id: n.id,
174
+ id: n.target.id,
149
175
  data: { status: 'blocked' },
150
176
  readAt: n.observedSyncId, // adopt the new high-water mark — this is what terminates the loop
151
177
  onStale: 'notify',
@@ -155,8 +181,65 @@ function reconcile(n: StaleNotification) {
155
181
 
156
182
  The loop **terminates** because each retry advances `readAt` to `observedSyncId`;
157
183
  a peer that keeps writing only ever notifies you against a *newer* baseline, never
158
- the same one twice. A group premise reconciles identically, except `group` is set
159
- and `currentValues` is empty (re-read the group).
184
+ the same one twice.
185
+
186
+ ### 5.2 Reading a group notification
187
+
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.
191
+
192
+ Four fields answer it:
193
+
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.
204
+
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:
207
+
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
+ ```
212
+
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.
216
+
217
+ ### 5.3 Gating on what you know
218
+
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.
221
+
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:
224
+
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
+ ```
229
+
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`.
160
243
 
161
244
  ---
162
245
 
@@ -173,10 +256,18 @@ What the convention **guarantees**, and where it **stops**:
173
256
  detection time, inside the same transaction as the write. A notification is
174
257
  never speculative.
175
258
 
176
- 3. **Termination (no livelock).** The monotonic `sync_id` landing order is the
177
- serialization order. The stale committer always yields/recomputes — an
178
- asymmetry that rules out the symmetric notify-rewrite livelock. Unbounded
179
- retry is bounded by the client's reconciliation retry cap.
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.
180
271
 
181
272
  4. **Scope: reversible DB state only.** The convention governs writes to the
182
273
  shared database, which are inherently reversible (prior value in
package/docs/groups.md CHANGED
@@ -64,6 +64,7 @@ await ablo.documents.track({ id: 's-1' });
64
64
  | Who else is working here? | `join`, then `room.peers` and `room.claims` | As participants come and go, on the socket |
65
65
  | Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies |
66
66
  | Has anything I read moved since? | `track` | On your next commit's receipt |
67
+ | Stop me writing if it has | `track` with `onStale: 'reject'` | Refuses that commit, at the chokepoint |
67
68
 
68
69
  Two distinctions do most of the work here.
69
70
 
@@ -231,10 +232,51 @@ await ablo.documents.update({
231
232
  ```
232
233
 
233
234
  So `reads` is the premise for the commit in hand; `track` is a standing
234
- subscription that outlives it. Both speak the same notification vocabulary, and
235
- both leave the resolution to you the engine reports that the target moved and
236
- lets the actor decide what that means. Delivery is on your next commit's receipt;
237
- a track does not yet push out of band between commits.
235
+ subscription that outlives it. Both speak the same notification vocabulary.
236
+ Delivery is on your next commit's receipt; a track does not yet push out of band
237
+ between commits.
238
+
239
+ ### Reporting, or gating: `onStale`
240
+
241
+ A track says what a moved belief should do to your **next write**. Same enum the
242
+ `reads` premise carries, minus the one mode that cannot apply:
243
+
244
+ ```ts
245
+ await ablo.documents.track({ id: 's-1' }); // notify (default)
246
+ await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // gate
247
+ ```
248
+
249
+ - **`notify`** — the change rides your next receipt and the commit proceeds. You
250
+ decide what it means.
251
+ - **`reject`** — your next commit is **refused** while that belief is stale, even
252
+ if it writes an unrelated row. The gate is on what you *know*, not on what you
253
+ are touching.
254
+
255
+ `reject` is for an actor that must not act on a stale picture — an agent that
256
+ read a row, reasoned for minutes, and is about to write based on what it
257
+ believed. Ablo enforces it at the commit chokepoint, so it is a guarantee rather
258
+ than a convention: the write cannot land.
259
+
260
+ The gate takes a key. It does **not** reopen on its own, because an agent that
261
+ retried blindly would land exactly the write `reject` was asked to prevent. You
262
+ re-read, then re-register the track to say so:
263
+
264
+ ```ts
265
+ try {
266
+ await ablo.tasks.update({ id, data });
267
+ } catch (err) {
268
+ if (err.code === 'stale_context') {
269
+ const fresh = await ablo.documents.retrieve({ id: 's-1' }); // read
270
+ await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // acknowledge
271
+ await ablo.tasks.update({ id, data: reconsider(fresh) }); // now it lands
272
+ }
273
+ }
274
+ ```
275
+
276
+ A track-only call is never gated, so acknowledging is always reachable.
277
+
278
+ `overwrite` is not offered: it means "apply my write anyway", and a track guards
279
+ no write of its own to apply.
238
280
 
239
281
  ---
240
282
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -104,6 +104,8 @@
104
104
  "test": "vitest run",
105
105
  "generate:errors": "tsx scripts/generate-error-docs.mts",
106
106
  "lint:errors": "tsx scripts/check-error-docs.mts",
107
+ "generate:pricing": "tsx scripts/generate-pricing-docs.mts",
108
+ "lint:pricing": "tsx scripts/check-pricing-docs.mts",
107
109
  "generate:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts",
108
110
  "lint:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts --check",
109
111
  "validate:openapi": "redocly lint ../../docs/ablo/public/openapi.json --extends=recommended --skip-rule=no-server-example.com",
@@ -122,8 +124,8 @@
122
124
  "directory": "packages/ablo"
123
125
  },
124
126
  "dependencies": {
125
- "@abloatai/humans": "^0.39.0",
126
- "@abloatai/transaction": "^0.39.0"
127
+ "@abloatai/humans": "^0.40.0",
128
+ "@abloatai/transaction": "^0.40.0"
127
129
  },
128
130
  "peerDependencies": {
129
131
  "ai": "^6.0.0 || ^7.0.0",