@abloatai/ablo 0.56.0 → 0.57.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,19 +1,251 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.57.0
4
+
5
+ ### Before you upgrade: drain the endpoint outbox
6
+
7
+ This release adds a `sync_groups` column to the source outbox, and the migration
8
+ refuses to run while legacy rows are still sitting in it. That refusal is
9
+ deliberate. A legacy row has no routes recorded, and assigning it one would
10
+ either invent an audience or give it none, so the migration stops and tells you
11
+ rather than guessing.
12
+
13
+ If you run a `dataSource()` endpoint, do this on the previous release, in order:
14
+
15
+ 1. Let Ablo poll until every event already in `ablo_outbox` has been consumed.
16
+ 2. Confirm the polling cursor has advanced past the last of them.
17
+ 3. Delete those consumed rows.
18
+
19
+ Then upgrade and run the migration. If you connect your database over
20
+ replication rather than an endpoint, there is no outbox and nothing to do.
21
+
22
+ ### A row is authorized by the subject its schema declares
23
+
24
+ A model may now declare which field decides who a row belongs to, and that rule
25
+ is enforced on every path: reads, writes, claims, presence, and every storage
26
+ adapter, including the endpoint ones. A row is authorized exactly when the
27
+ request carries the sync group `${group}:${row[field]}`.
28
+
29
+ This is what 0.56.0's boundary change was heading towards. Sync groups routed
30
+ delivery and did not decide authorization, so a model that used them as though
31
+ they did was relying on something the guide told you not to rely on. A declared
32
+ subject is that rule made real, checked in one place and failing closed.
33
+
34
+ Two consequences worth knowing. A subject-scoped model stamps exactly one group
35
+ on a change, because delivery matching is OR-based and a second group would
36
+ widen the audience rather than narrow it. And a tombstone now reaches only the
37
+ row's authorized subject group, where before a delete could be announced more
38
+ widely than the row ever was.
39
+
40
+ A schema that routes by sync group without a matching row-access policy is
41
+ flagged. If the routing really is only routing, acknowledge it explicitly and
42
+ the flag goes quiet.
43
+
44
+ ### Creating many rows is one commit
45
+
46
+ `create` takes a list as well as a single row, under the same verb:
47
+
48
+ ```ts
49
+ const rows = await ablo.weatherReports.create({
50
+ data: [
51
+ { location: 'Stockholm', summary: 'Clear' },
52
+ { location: 'Oslo', summary: 'Rain' },
53
+ ],
54
+ });
55
+ ```
56
+
57
+ They are written as one atomic commit rather than one request each, so either
58
+ every row lands or none does. The result comes back in the order you gave it,
59
+ not the order the batch settled, and carries whatever defaults the server
60
+ stamped. An empty list writes nothing rather than opening an empty commit.
61
+
62
+ ### Reading a whole collection, in as many words
63
+
64
+ `listAll({ where, maxPages, signal })` reads a complete collection by walking the
65
+ same cursor `list` returns, so the common case stops being a hand-rolled loop:
66
+
67
+ ```ts
68
+ const open = await ablo.weatherReports.listAll({
69
+ where: { status: ['draft', 'review'] },
70
+ maxPages: 20,
71
+ });
72
+ ```
73
+
74
+ It is bounded on purpose. `maxPages` is how you say how much you are willing to
75
+ read, and `signal` cancels a walk that is taking longer than the work is worth.
76
+ A complete read that cannot say when it will stop is how a page turns into an
77
+ outage.
78
+
79
+ ### A claimed write carries its stale guard again
80
+
81
+ Holding a claim and then writing gave mutual exclusion but not lost-update
82
+ detection, on the stateless transport agents run. The claim handle carries the
83
+ position the row was read at, and the write defaults to rejecting on a change
84
+ since then. Unwrapping the handle cleared the claim before that default was
85
+ read, so the guard was unreachable and every model write through the public
86
+ surface lost it.
87
+
88
+ It read as though both protections were present: claim, read, decide, write. The
89
+ watermark now travels with the handle, and your own `readAt` or `onStale` still
90
+ win where you set them.
91
+
92
+ ### A create on an id that already exists is refused
93
+
94
+ It reported success and returned a row. A caller-selected id is a claim about
95
+ which row this is, so a create that finds one already there is a conflict rather
96
+ than an update, and it now says so.
97
+
98
+ ### CLI: a session route that revalidates before it mints
99
+
100
+ `ablo init` scaffolds a Next.js session route that re-checks membership at mint
101
+ time rather than trusting the caller, and puts secret clients behind the
102
+ framework's `server-only` boundary so a key cannot be imported into a component
103
+ that ships to a browser.
104
+
105
+ ### Filtering a server read by a reference field
106
+
107
+ `list({ where: { issueId } })` matched nothing on a replicated plane. It raised
108
+ no error and returned a well-formed empty array, so the read looked like a
109
+ question with no answers rather than a filter that never ran. Filtering on
110
+ `id`, `title` or `body` worked, which made the failure look like a property of
111
+ the data instead of a property of the field name.
112
+
113
+ The cause was a key space. A row served from the log is a snapshot in the wire
114
+ shape, so its fields are spelled the way your schema spells them, while the
115
+ filter looked them up by their database column. Those two agree exactly when a
116
+ column is a single word, and part ways on every `issueId`, `teamId` or
117
+ `assigneeId`. Ordering, relation expansion and any field declared with
118
+ `.from()` were reading the same wrong spelling: a `related` list came back
119
+ empty, and a `.from()` field was simply absent from the row.
120
+
121
+ If you page a collection and filter it in your own code to work around this,
122
+ that code can go.
123
+
124
+ A filter naming a field the model does not declare is now refused, with the
125
+ same error the direct-database plane already gave it. It used to return
126
+ nothing, which reads as an answer.
127
+
128
+ ### A list read walks its own pages
129
+
130
+ `list` returns a page, and a page of 20 looks exactly like a complete answer of 20. Every caller either checked `hasMore` or, more often, reasoned about a
131
+ truncated collection without knowing there was more.
132
+
133
+ Iterate the result for the page. Walk it for the collection:
134
+
135
+ ```ts
136
+ for await (const issue of await ablo.issues.list({ where: { teamId } })) {
137
+
138
+ }
139
+ ```
140
+
141
+ `hasMore` and `nextCursor` are unchanged, and taking the cursor yourself is
142
+ still the right thing when the pages go somewhere other than a loop.
143
+
144
+ ### Clearing a field
145
+
146
+ `null` clears a field, and the types now say so. They used to accept only the
147
+ field's own type or `undefined`, and `undefined` means "leave this alone": it
148
+ is dropped from the payload, so an unassign written that way kept the old
149
+ assignee and reported success. The only spelling that both compiled and worked
150
+ was one that cast the payload, which turned off type checking for the whole
151
+ write.
152
+
153
+ Only a field your schema declares optional accepts `null`. A required field has
154
+ no empty value to move to, and the type says that too.
155
+
156
+ ### A write that does not name its row is refused
157
+
158
+ `delete({ where: { id } })` reads like it should work, and `where` is what the
159
+ commit protocol takes one layer down. It used to spell the missing id into the
160
+ request as the literal text `undefined`, match no row, and return an ordinary
161
+ receipt. It now fails at the call, naming the model, the action, and `{ id }`.
162
+
163
+ The same guard covers `update`.
164
+
165
+ ### A create honours the id you gave it
166
+
167
+ An id passed inside `data`, which the create input has always allowed, was
168
+ never read: the row was written under a generated id and you were handed back
169
+ one you had not named. Both spellings now work, and the standalone `id` wins if
170
+ they disagree.
171
+
172
+ ### Every response says what your allowance is
173
+
174
+ The limiter knew the allowance and the refill and told you neither, so the only
175
+ strategy available was to retry and find the wall again.
176
+
177
+ ```
178
+ RateLimit-Policy: "secret";q=600;w=12
179
+ RateLimit: "secret";r=573;t=8
180
+ Retry-After: 3
181
+ ```
182
+
183
+ `RateLimit-Policy` is the standing allowance and is always present.
184
+ `RateLimit` reports what is left and when it refills, once a request is
185
+ attributed to a key. A 429 adds `Retry-After` in whole seconds. Pace against
186
+ these rather than retrying blind.
187
+
188
+ ### A route says when it is going away
189
+
190
+ Every response carries `Ablo-Version`, a date stamp for the contract being
191
+ served, so a caller can notice the contract moved under it.
192
+
193
+ A route being withdrawn now says so on itself for at least 180 days first.
194
+ `Deprecation` (RFC 9745) carries when the deprecation took effect, and the route
195
+ keeps answering; `Sunset` (RFC 8594) carries when it stops. The same operations
196
+ are marked `deprecated: true` in the OpenAPI document, so a generated client
197
+ sees it too.
198
+
199
+ Breaking changes still arrive as a new path segment beside `/v1`, never as a
200
+ change to it. Additive ones land in `/v1`, so ignore what you do not recognise.
201
+
202
+ ### The documentation answers a reader that is not a browser
203
+
204
+ The surfaces `llms.txt` names are routes now rather than a promise:
205
+ `/llms-full.txt` for the whole corpus in one fetch, `/openapi.json` for the REST
206
+ contract, `/developers` naming every developer surface on one page,
207
+ `/.well-known/mcp.json` for the MCP manifest.
208
+
209
+ Every page also answers from its own URL in Markdown. Send
210
+ `Accept: text/markdown`, or append `.md` where a client cannot set headers.
211
+ Responses carry `Vary: Accept`, a client that will take neither type gets a 406
212
+ listing what is available, and a path that does not exist answers a real 404
213
+ rather than a 200 carrying a sign-in page.
214
+
215
+ ### Renamed and removed
216
+
217
+ `SourceRequestContext.requiredSyncGroups` is now `syncGroups`. Ablo populates
218
+ both spellings this release, so a source adapter still reading the old name gets
219
+ the groups rather than `undefined`, which on a routing field would read as "no
220
+ groups" rather than as a field that moved. The old spelling is removed in
221
+ 0.58.0.
222
+
223
+ `DeltaPosition`, `deltaPositionSchema`, `ReadSetWatermark`, and
224
+ `readSetWatermarkSchema` are removed, as 0.56.0 announced. Use `LogPosition` and
225
+ `logPositionSchema`, which they have resolved to since then.
226
+
3
227
  ## 0.56.0
4
228
 
5
- ### One customer no longer sees another's live work
229
+ ### Coordination reads are scoped to the customer, not the organization
230
+
231
+ Coordination has always been scoped to the organization, and through 0.51.0 that
232
+ was the whole boundary: a platform gave each customer its own organization, and
233
+ the sessions guide was explicit that sync groups decide which changes travel
234
+ rather than what a session may read.
235
+
236
+ This release moves that line. A platform's customers are rows in its own schema,
237
+ reached by the sync groups on the session, so many customers share one
238
+ organization and the organization is no longer the finest boundary. The delivery
239
+ path already applied the finer cut. The claim listing and the presence read did
240
+ not, so under that newer arrangement one customer could see which rows another
241
+ had claimed, who held them, what the work was called, and who was online. Row
242
+ contents were never exposed; everything around them was.
6
243
 
7
- A platform's customers are rows in its own schema, so they share one
8
- organization and one plane. The claim listing and the presence read were scoped
9
- to exactly that pair and nothing finer. One customer could therefore see which
10
- rows another had claimed, who held them, what the work was called, and who was
11
- online. Row contents were never exposed; everything around them was.
244
+ Both reads now take the same cut, from the groups each side already carries.
12
245
 
13
- Both reads now apply the same cut the delivery path already applies, taken from
14
- the groups each side already carries. A customer sees the coordination for the
15
- rows it can see, and nothing else. If you serve many customers from one
16
- organization, this closes the gap without any change on your side.
246
+ If you give each customer its own organization, nothing changes for you and
247
+ nothing was reachable across customers. If you serve many customers from one
248
+ organization, this closes the gap with no change on your side.
17
249
 
18
250
  ### A client converges on the head it was measured against
19
251
 
package/LICENSE CHANGED
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright 2025-2026 Lukas Andersson
189
+ Copyright 2025-2026 Ablo Inc.
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
package/NOTICE CHANGED
@@ -1,10 +1,10 @@
1
1
  @ablo/ablo
2
- Copyright 2025-2026 Lukas Andersson
2
+ Copyright 2025-2026 Ablo Inc.
3
3
 
4
- This product includes software developed by Lukas Andersson
4
+ This product includes software developed by Ablo Inc.
5
5
  (https://abloatai.com).
6
6
 
7
- "Ablo" is a trademark of Lukas Andersson. This license does not grant
7
+ "Ablo" is a trademark of Ablo Inc. This license does not grant
8
8
  permission to use the Ablo name, logo, or trademarks. Third parties
9
9
  may describe their use of or compatibility with Ablo factually (e.g.,
10
10
  "built with @ablo/ablo") but may not use the Ablo name in a way
package/docs/agents.md CHANGED
@@ -46,6 +46,63 @@ and `claim`. It does **not** expose stateful-only `local` reads or `onChange`
46
46
  subscriptions. Those need a live connection, so with `transport: 'http'` they
47
47
  are compile errors rather than runtime surprises.
48
48
 
49
+ ## Managed scoped agents
50
+
51
+ When this process owns the secret client and also runs the agent, prefer
52
+ `agents.create`. It mints the restricted credential, returns a schema-typed
53
+ client, and renews that credential for a long run. `sessions.create({ agent })`
54
+ is the raw-token path for handing identity to another runtime.
55
+
56
+ Derive identity and groups from the run row or trusted job payload—not from
57
+ model output or an HTTP request body. A serverless handler normally creates and
58
+ disposes one child per invocation:
59
+
60
+ ```ts
61
+ const run = await control.runs.get({ id: verifiedRunId });
62
+ if (!run) throw new Error('run not found');
63
+
64
+ const agent = await control.agents.create({
65
+ id: `run:${run.id}`,
66
+ name: 'run-worker',
67
+ can: { records: ['read', 'update'] },
68
+ syncGroups: [`workspace:${run.workspaceId}`],
69
+ });
70
+ try {
71
+ await executeRun(agent, run);
72
+ } finally {
73
+ await agent.dispose();
74
+ }
75
+ ```
76
+
77
+ Use a stable id only when one logical run is serialized; two concurrent workers
78
+ that share an id appear as the same participant. For independent concurrent
79
+ work, omit `id` and let Ablo create distinct identities.
80
+
81
+ A long-running worker may cache one managed client per stable scope, but the
82
+ cache owns lifecycle: evict idle clients, call `dispose()` on eviction, and
83
+ dispose every client during graceful shutdown. Never cache a client and later
84
+ reuse it for a different workspace or capability set.
85
+
86
+ ```ts
87
+ const agents: Record<
88
+ string,
89
+ Awaited<ReturnType<typeof control.agents.create>> | undefined
90
+ > = {};
91
+
92
+ async function agentFor(run: Run) {
93
+ const key = `${run.workspaceId}:${run.workerSlot}`;
94
+ const cached = agents[key];
95
+ if (cached) return cached;
96
+ const created = await control.agents.create({
97
+ id: `worker:${key}`,
98
+ can: { records: ['read', 'update'] },
99
+ syncGroups: [`workspace:${run.workspaceId}`],
100
+ });
101
+ agents[key] = created;
102
+ return created;
103
+ }
104
+ ```
105
+
49
106
  ## AI SDK tools
50
107
 
51
108
  Keep AI SDK in charge of the model loop and expose only the Ablo operations the
package/docs/api.md CHANGED
@@ -53,6 +53,7 @@ Each schema model becomes a typed model on the client:
53
53
 
54
54
  - `ablo.weatherReports.get({ id })` reads one row asynchronously (server read).
55
55
  - `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read).
56
+ - `ablo.weatherReports.listAll({ where })` explicitly reads every matching page.
56
57
  - `ablo.weatherReports.local.get(id)` reads one row synchronously from the local graph.
57
58
  - `ablo.weatherReports.create({ data })` creates a row.
58
59
  - `ablo.weatherReports.update({ id, data, ...options })` updates a row.
@@ -68,6 +69,7 @@ fallback removed — nothing to await, so they return a value.
68
69
  |---|---|---|
69
70
  | `get({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
70
71
  | `list({ where })` | `Promise<ModelList<T>>` | You need to hydrate a collection from local store and server. |
72
+ | `listAll({ where, maxPages?, signal? })` | `Promise<T[]>` | You deliberately need every matching row. |
71
73
  | `local.get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
72
74
  | `local.list(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
73
75
  | `local.count(options?)` | `number` | You want a synchronous count of local rows. |
@@ -79,24 +81,57 @@ fallback removed — nothing to await, so they return a value.
79
81
  through the server. The `local` reads work off the rows a session has already
80
82
  synced, so a cheap re-read needs no round-trip.
81
83
 
82
- ### Paging a collection
84
+ ### Reading a whole collection
83
85
 
84
- `list` returns a page. The result is an array, so it maps and iterates as
85
- before, and it carries `hasMore` and `nextCursor` alongside the rows:
86
+ Prefer a filtered `listAll` when the application truly needs one complete
87
+ array. It follows the same cursor loop as async iteration, defaults to at most
88
+ 100 pages, and checks an abort signal between requests and rows:
89
+
90
+ ```ts
91
+ const controller = new AbortController();
92
+ const open = await ablo.weatherReports.listAll({
93
+ where: { status: ['draft', 'review'] },
94
+ orderBy: { createdAt: 'asc' },
95
+ maxPages: 25,
96
+ signal: controller.signal,
97
+ });
98
+ ```
99
+
100
+ A complete traversal can be expensive in latency, memory, and read volume.
101
+ Narrow it with `where`; use `list` and its cursor when a UI or worker can process
102
+ one page at a time.
103
+
104
+ `for await` walks the pages:
86
105
 
87
106
  ```ts
88
- let cursor: string | null = null;
89
107
  const open = [];
90
- do {
91
- const page = await ablo.weatherReports.list({
92
- where: { status: ['draft', 'review'] },
93
- orderBy: { createdAt: 'asc' },
94
- limit: 100,
95
- ...(cursor ? { cursor } : {}),
96
- });
97
- open.push(...page);
98
- cursor = page.hasMore ? page.nextCursor : null;
99
- } while (cursor);
108
+ for await (const report of await ablo.weatherReports.list({
109
+ where: { status: ['draft', 'review'] },
110
+ orderBy: { createdAt: 'asc' },
111
+ })) {
112
+ open.push(report);
113
+ }
114
+ ```
115
+
116
+ `list` returns a page, because the server applies a default size and caps the
117
+ largest. The result is an array, so it maps and iterates as before, and it
118
+ carries `hasMore` and `nextCursor` alongside the rows. Iterate it to work with
119
+ the page you were handed; `for await` it to work with the collection.
120
+
121
+ ```ts
122
+ const page = await ablo.weatherReports.list({ where: { status: 'draft' } });
123
+ page.length; // the rows this page carries
124
+ page.hasMore; // whether the collection continues past them
125
+ ```
126
+
127
+ Take the cursor yourself when the pages go somewhere other than a loop — one
128
+ screenful at a time, or a job that stops and resumes:
129
+
130
+ ```ts
131
+ const page = await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100 });
132
+ const next = page.hasMore
133
+ ? await ablo.weatherReports.list({ where: { status: 'draft' }, limit: 100, cursor: page.nextCursor })
134
+ : null;
100
135
  ```
101
136
 
102
137
  Keep `where` and `orderBy` the same across pages: the cursor encodes the sort
@@ -106,6 +141,22 @@ position it was issued for, and a read that changes either starts a new walk.
106
141
  `{ status: ['draft', 'review'] }` is an `IN`, and tuple form spells the rest
107
142
  out, as in `[['title', 'ILIKE', '%storm%'], ['createdAt', '>=', cutoff]]`.
108
143
 
144
+ ### Changing a field, and clearing one
145
+
146
+ `null` clears a field:
147
+
148
+ ```ts
149
+ await ablo.weatherReports.update({ id, data: { reviewerId: null } }); // unassigned
150
+ await ablo.weatherReports.update({ id, data: { reviewerId: 'usr_2' } }); // reassigned
151
+ ```
152
+
153
+ An update is a patch, so a field you leave out keeps its value. That makes
154
+ `undefined` and "leave it alone" the same thing: `{ reviewerId: undefined }`
155
+ is dropped from the payload and the old reviewer stays. Reach for `null`
156
+ whenever a value is going away, and the type will hold you to it — only a
157
+ field your schema declares optional accepts one, since a required field has no
158
+ empty value to move to.
159
+
109
160
  ## Protected Writes
110
161
 
111
162
  Use `snapshot` when a write should reject if the row changed mid-flight:
@@ -2,8 +2,21 @@
2
2
 
3
3
  > One account, one schema, and a session scoped to the customer whose data it may read.
4
4
 
5
- Your customers live in your database, in a table you already have. Ablo reaches
6
- one of them by the groups a session is minted with.
5
+ Serving many customers from one backend has two shapes, and the first question
6
+ is whether isolating them is a security boundary or a routing convenience.
7
+
8
+ **One Ablo organization per customer** is the hard boundary. Every row carries
9
+ the organization, and the engine compares it on every read and every write,
10
+ below your code. Choose it when one customer reading another's rows would be an
11
+ incident.
12
+
13
+ **One organization, customers as rows told apart by sync groups** is delivery
14
+ and read routing. It is declarative, it depends on every model being covered,
15
+ and it is not enforced on every path. Choose it when cross-customer reads are
16
+ tolerable or intentional, not when they are a breach.
17
+
18
+ The rest of this page is the second shape. Read *Where the boundary is enforced*
19
+ before you rely on it.
7
20
 
8
21
  ```ts
9
22
  // 1. src/ablo/schema.ts — your customer table is a scope root.
@@ -82,29 +95,33 @@ session adds is which customer the person in front of it may read.
82
95
 
83
96
  ## Where the boundary is enforced
84
97
 
85
- Two mechanisms do different jobs, and it is worth knowing which is which before
86
- you rely on either.
98
+ Two mechanisms do different jobs, and the difference is the whole of this page.
87
99
 
88
100
  **Your account is the tenant boundary.** Every row Ablo stores carries your
89
- organization and project, and row-level security compares both against the
90
- credential on every read and every write. A client cannot reach past it by
91
- asking, because the values come from the key rather than the request.
92
-
93
- **Sync groups are the cut inside your account.** They decide which of your own
94
- rows a session is delivered and which it may read back over HTTP. This is the
95
- boundary between one of your customers and the next, and it is the one your
96
- schema declares.
97
-
98
- Reads go through it. `list` and `get` are cut to the session's groups, live
99
- delivery is cut to the same set, and the initial load is the intersection of
100
- what the session asked for with what it was minted with.
101
-
102
- Coordination reads are cut more coarsely today. Listing claims and reading
103
- presence are scoped to your account and plane rather than to the session's
104
- groups, so one customer's session can see that a row is claimed and who is
105
- present, though never the row's contents. Treat row ids and participant ids on
106
- those two surfaces as visible across your customers until this page says
107
- otherwise.
101
+ organization, project, and branch, and all three are compared on every read and
102
+ every write, from the credential rather than the request. A client cannot reach
103
+ past them by asking. This is the boundary that holds unconditionally.
104
+
105
+ **Sync groups are a cut inside your account, and they are not applied
106
+ everywhere.** They decide which changes are delivered and which rows a
107
+ log-served read returns. That is routing. It is not a universal authorization
108
+ boundary, and the gaps are specific:
109
+
110
+ | Path | Group cut applied |
111
+ |---|---|
112
+ | Live delivery and fan-out | Yes |
113
+ | HTTP read on a log-served plane (a connected database) | Yes |
114
+ | HTTP read on a hosted or direct-query plane | **No.** Scoped by organization |
115
+ | Writes | **No.** The groups are recorded on the change, never checked against the row |
116
+ | Claim listings and presence | Yes |
117
+
118
+ So a session cut to one customer, on a hosted plane, can read another
119
+ customer's rows over HTTP; and on any plane it can write to them. What stops it
120
+ today is the organization, which both customers share under this shape.
121
+
122
+ If isolating your customers is a security requirement, give each one its own
123
+ Ablo organization. The stronger row-and-subject authorization that would make
124
+ this shape safe on every path is not in the engine yet.
108
125
 
109
126
  ## Naming a group
110
127
 
@@ -120,16 +137,17 @@ Resolve `member.customerId` from the membership you just authenticated on the
120
137
  server. A signed-in person can put any value in a request body, and the session
121
138
  you mint is what decides what they can read.
122
139
 
123
- ## When a customer really is an account
140
+ ## When a customer should be its own organization
124
141
 
125
- There is one shape where each of your customers should be its own Ablo
126
- organization: when each is a separate paying business that signs in to Ablo
127
- itself, holds its own subscription, and invites its own developers. That is what
128
- an identity provider looks like, and it is what the `organization:act-as` scope
129
- on a secret key exists for.
142
+ Whenever their isolation has to hold. Give each customer its own Ablo
143
+ organization when one of them reading or writing another's rows would be an
144
+ incident rather than a bug, when you cannot audit group coverage across every
145
+ model, or when a customer is a separate paying business that signs in to Ablo
146
+ itself and invites its own developers.
130
147
 
131
- It is rare, and it is not what a platform serving customers from one product
132
- looks like. If your customers never see Ablo, they belong in your schema.
148
+ Your backend then names the customer's organization on the mint, which takes a
149
+ secret key carrying `organization:act-as`. The customer never sees Ablo; the
150
+ scope exists because the session leaves the organization the key belongs to.
133
151
 
134
152
  ## Onboarding a customer
135
153
 
@@ -45,6 +45,8 @@ has no credential and the engine fails to initialize with `session_expired`.
45
45
 
46
46
  ```ts
47
47
  // lib/ablo.ts — server-only
48
+ import 'server-only';
49
+
48
50
  import Ablo from '@abloatai/ablo';
49
51
  import { schema } from './ablo.schema';
50
52
 
@@ -58,30 +60,59 @@ export const ablo = Ablo({
58
60
  ## Session Route
59
61
 
60
62
  The browser can't hold `sk_`, so a backend route mints a scoped, short-lived
61
- `ek_` for the signed-in user. Guard it with your own auth.
63
+ `ek_` for the signed-in user. Being signed in is not workspace authorization:
64
+ revalidate the active membership immediately before every mint, and derive all
65
+ organization, workspace, team, and group ids on the server. Never accept them
66
+ from the request body.
62
67
 
63
68
  ```ts
64
69
  // app/api/ablo-session/route.ts
65
70
  import { ablo } from '@/lib/ablo';
66
71
  import { getCurrentUser } from '@/auth';
72
+ import { headers } from 'next/headers';
67
73
  import {
68
74
  credentialEndpointErrorSchema,
69
75
  credentialEndpointSuccessSchema,
70
76
  } from '@abloatai/ablo/auth';
71
77
 
72
- export async function POST() {
78
+ const noStore = { 'Cache-Control': 'no-store' };
79
+
80
+ export async function POST(request: Request) {
81
+ if (!(await isSameOrigin(request))) {
82
+ return Response.json(
83
+ credentialEndpointErrorSchema.parse({
84
+ error: { code: 'origin_mismatch', message: 'Cross-origin mint rejected' },
85
+ }),
86
+ { status: 403, headers: noStore },
87
+ );
88
+ }
89
+
73
90
  const user = await getCurrentUser();
74
91
  if (!user) {
75
92
  return Response.json(
76
93
  credentialEndpointErrorSchema.parse({
77
94
  error: { code: 'session_expired' },
78
95
  }),
79
- { status: 401, headers: { 'Cache-Control': 'no-store' } },
96
+ { status: 401, headers: noStore },
97
+ );
98
+ }
99
+
100
+ // Query your membership table now—not when the login session was created.
101
+ // The helper reads the active workspace from server-side session state and
102
+ // returns null when the membership is stale or revoked.
103
+ const scope = await authorizeActiveWorkspace(user.id);
104
+ if (!scope) {
105
+ return Response.json(
106
+ credentialEndpointErrorSchema.parse({
107
+ error: { code: 'policy_denied', message: 'Workspace membership is stale or revoked' },
108
+ }),
109
+ { status: 403, headers: noStore },
80
110
  );
81
111
  }
82
112
 
83
113
  const { token, expiresAt } = await ablo.sessions.create({
84
114
  user: { id: user.id },
115
+ syncGroups: scope.syncGroups,
85
116
  can: { records: ['read', 'create', 'update'] },
86
117
  });
87
118
  return Response.json(
@@ -90,11 +121,23 @@ export async function POST() {
90
121
  expiresAt,
91
122
  credentialKind: 'ephemeral',
92
123
  }),
93
- { headers: { 'Cache-Control': 'no-store' } },
124
+ { headers: noStore },
94
125
  );
95
126
  }
127
+
128
+ async function isSameOrigin(request: Request): Promise<boolean> {
129
+ const origin = request.headers.get('origin');
130
+ if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site';
131
+ const host = (await headers()).get('host');
132
+ return host !== null && new URL(origin).host === host;
133
+ }
96
134
  ```
97
135
 
136
+ `authorizeActiveWorkspace` is application code: it must query the authoritative
137
+ membership store and return server-derived sync groups. If fifteen-minute token
138
+ expiry is too slow for your revocation requirements, mint a shorter
139
+ `ttlSeconds` and revoke active sessions when membership changes.
140
+
98
141
  ## Provider
99
142
 
100
143
  The browser client points `authEndpoint` at that route and is handed to
@@ -494,6 +494,31 @@ const completeReport = tool({
494
494
 
495
495
  Keep agent writes on the same schema client surface as the app.
496
496
 
497
+ ## One command changes an Ablo model and an ORM-only table
498
+
499
+ Two independently committed calls are not one atomic command. If an Ablo write
500
+ lands and a following Prisma/Drizzle transaction fails—or the reverse—the
501
+ application must expect and repair the partial result. Calling that path
502
+ “coordinated” does not extend Ablo’s claims, stale-read checks, attribution, or
503
+ commit ordering into the ORM transaction.
504
+
505
+ The supported atomic answer is to model every invariant-bearing row in the
506
+ Ablo schema and submit the operations in one `commits.create` batch (the HTTP
507
+ equivalent is `POST /api/v1/commits`). This applies on both database paths:
508
+
509
+ - With direct logical replication, Ablo’s batch is one customer-database
510
+ transaction. A separate ORM transaction is still separate.
511
+ - With a signed Data Source endpoint, the adapter applies the Ablo batch,
512
+ idempotency record, and outbox entry in one customer-database transaction.
513
+ Unrelated ORM work outside that adapter is still separate.
514
+
515
+ There is no general transactional callback that can safely splice arbitrary
516
+ application SQL into the hosted direct-write path. If a table must remain
517
+ ORM-only, treat the command as a saga: give both steps the same durable business
518
+ operation id, make each step idempotent, record progress, retry unfinished
519
+ steps, and define compensation for a result that cannot be completed. State
520
+ that guarantee as eventual completion with repair—not atomicity.
521
+
497
522
  ## Optional Surface
498
523
 
499
524
  | Optional piece | Why it exists |
@@ -517,6 +542,7 @@ them.
517
542
  | -------------------------------------- | -------------------------------------------------------------------------------- |
518
543
  | `get({ id })` | Async read of one row from the server (await it). |
519
544
  | `list({ where })` | Async read of many rows from the server (await it). |
545
+ | `listAll({ where, maxPages?, signal? })` | Explicit bounded traversal of every matching page; filter before collecting. |
520
546
  | `local.get(id)` | Synchronous local read of one synced row (use in render). |
521
547
  | `local.list({ where })` | Synchronous local read of many synced rows. |
522
548
  | `local.count({ where })` | Synchronous local count of synced rows. |
@@ -50,6 +50,7 @@ context, whether or not you map anything:
50
50
  | `app.current_participant_id` | The participant making the write |
51
51
  | `app.current_participant_kind` | Whether that participant is a person, an agent, or the system |
52
52
  | `app.current_user_id` | The person on whose behalf the write is made |
53
+ | `app.current_subject_groups` | The subject groups the caller belongs to, as a JSON array of `group:value` strings |
53
54
 
54
55
  If your policies read these names directly, you need no mapping at all — this
55
56
  page is for the case where they read different ones.
@@ -62,6 +63,14 @@ from `app.current_org_id` can be useful as defense in depth, but it is not a
62
63
  substitute for the tenant policy and you should never loosen RLS to make an
63
64
  Ablo write pass.
64
65
 
66
+ `app.current_subject_groups` is the one a model with a `subject` rule reads. A
67
+ subject rule names a field and a group — `subject: { field: 'teamId', group:
68
+ 'team' }` — and Ablo provisions a policy asking whether the array contains
69
+ `team:` followed by that row's value. The setting is always present, and it is
70
+ `[]` when the caller belongs to no group, so a policy on a pooled connection
71
+ reads an empty membership as an empty membership rather than inheriting what the
72
+ previous transaction left behind.
73
+
65
74
  `app.current_user_id` is worth reading twice, because it has three states rather
66
75
  than two. It carries a person's id when a person is behind the write. It carries
67
76
  `*` when a backend credential is acting as the organization itself, which is the
@@ -114,10 +114,14 @@ export const handleAbloSource = dataSource({
114
114
  // own transaction. The example uses a synchronous in-memory
115
115
  // update; the surrounding `apply` helper shows where you would
116
116
  // open `db.transaction(async (tx) => { ... })`.
117
- commit({ operations, clientTxId }) {
117
+ commit({ operations, clientTxId, context }) {
118
+ // The routes an outbox event carries come from the trusted scope Ablo
119
+ // signed into the request, never from the row itself. Ablo adds the
120
+ // organization group on its side; these are the finer ones.
121
+ const syncGroups = context.scope?.syncGroups ?? [];
118
122
  const rows: RecordRow[] = [];
119
123
  for (const op of operations) {
120
- const row = applyOperation(op, clientTxId);
124
+ const row = applyOperation(op, clientTxId, syncGroups);
121
125
  if (row) rows.push(row);
122
126
  }
123
127
  return { rows };
@@ -145,6 +149,7 @@ export const handleAbloSource = dataSource({
145
149
  function applyOperation(
146
150
  op: SourceOperation,
147
151
  clientTxId: string | undefined,
152
+ syncGroups: readonly string[],
148
153
  ): RecordRow | null {
149
154
  if (op.model !== 'records') return null;
150
155
  const id = op.id ?? `record_${Math.random().toString(36).slice(2, 10)}`;
@@ -160,7 +165,7 @@ function applyOperation(
160
165
  : {}),
161
166
  };
162
167
  recordStore.set(id, row);
163
- appendOutbox({ operation: op, entityId: id, data: row, clientTxId });
168
+ appendOutbox({ operation: op, entityId: id, data: row, clientTxId, syncGroups });
164
169
  return row;
165
170
  }
166
171
 
@@ -169,7 +174,7 @@ function applyOperation(
169
174
  if (!existing) return null;
170
175
  const next: RecordRow = { ...existing, ...(op.input as Partial<RecordRow>) };
171
176
  recordStore.set(id, next);
172
- appendOutbox({ operation: op, entityId: id, data: next, clientTxId });
177
+ appendOutbox({ operation: op, entityId: id, data: next, clientTxId, syncGroups });
173
178
  return next;
174
179
  }
175
180
 
@@ -177,7 +182,7 @@ function applyOperation(
177
182
  const existing = recordStore.get(id);
178
183
  if (!existing) return null;
179
184
  recordStore.delete(id);
180
- appendOutbox({ operation: op, entityId: id, data: null, clientTxId });
185
+ appendOutbox({ operation: op, entityId: id, data: null, clientTxId, syncGroups });
181
186
  return existing;
182
187
  }
183
188
 
@@ -189,6 +194,7 @@ function appendOutbox(input: {
189
194
  entityId: string;
190
195
  data: RecordRow | null;
191
196
  clientTxId: string | undefined;
197
+ syncGroups: readonly string[];
192
198
  }): void {
193
199
  outboxSequence += 1;
194
200
  outbox.push(
@@ -197,6 +203,7 @@ function appendOutbox(input: {
197
203
  operation: input.operation,
198
204
  entityId: input.entityId,
199
205
  data: input.data,
206
+ syncGroups: input.syncGroups,
200
207
  ...(input.clientTxId ? { clientTxId: input.clientTxId } : {}),
201
208
  }),
202
209
  );
package/llms.txt CHANGED
@@ -6,6 +6,57 @@ Here is the problem it solves. Two agents reach for `report_stockholm` at once.
6
6
 
7
7
  Use AI SDK for the agent loop. Use Ablo when agent reads and writes must persist, coordinate with concurrent work, and leave an audit trail.
8
8
 
9
+ ## When to use Ablo
10
+
11
+ Reach for Ablo when MORE THAN ONE writer changes the same rows and the second one must not clobber the first. That is the whole judgement. If a single process owns the data, an ORM against your database is simpler and you should use it.
12
+
13
+ These are the jobs it is right for:
14
+
15
+ - **Two or more agents on one workload.** A planner and three workers editing the same records; a swarm assigned overlapping tickets; a retry that restarts while the first attempt is still running. Each takes `claim({ id })` before its slow step, so the second waits and is handed the row as the first left it instead of overwriting.
16
+ - **A read → LLM call → write gap.** The row can change during the seconds your model is thinking. Pass the `readAt` stamp from your read back into the write and a stale write is refused (`AbloStaleContextError`) rather than silently applied on top of someone else's.
17
+ - **A human and an agent in the same record.** A person editing in your app is just another holder of the claim. The agent queues behind them; nobody's edit is lost, and neither side needs to know the other exists.
18
+ - **Work that must survive the process.** Commits are durable and receipts are idempotent, so a crashed or re-scheduled agent replays its write with the same `idempotencyKey` and gets the original result instead of a duplicate.
19
+ - **Anything you will later have to explain.** Every accepted change lands in an append-only log with who wrote it, when, and against which read. `logs` answers "what did the agents actually do" without you having instrumented for the question in advance.
20
+ - **Data you are not allowed to move.** Ablo runs on the customer's own Postgres. It tails the replication stream and writes back through a scoped role; your rows never live in Ablo. If a data-residency or procurement rule forbids a vendor holding the records, this is the shape that passes.
21
+
22
+ These are the jobs it is WRONG for, and saying so saves you a wasted integration:
23
+
24
+ - **One writer, no concurrency.** Use Prisma, Drizzle, or SQL. Ablo's coordination costs a round trip and buys you nothing here.
25
+ - **Chat history, traces, or evals.** Append-only telemetry has no contention to resolve. Write it wherever you write logs.
26
+ - **A cache, a queue, or a pub/sub bus.** Ablo is a commit chokepoint over a system of record, not a message broker. Emit your events as usual and use Ablo for the state they are about.
27
+ - **Vector search, embeddings, or retrieval.** Different problem. Ablo coordinates the rows; your vector store indexes them.
28
+ - **A file, blob, or artifact store.** Coordinate the ROW that points at the object; put the bytes in object storage.
29
+
30
+ How to call it, shortest path first: install `@abloatai/ablo`, run `npx ablo init --yes --framework <nextjs|vite|remix|vanilla>`, and construct `Ablo({ schema, apiKey: process.env.ABLO_API_KEY })`. Read with `ablo.<model>.get({ id })` / `.list({ where })`, write with `ablo.<model>.update({ id, data, readAt })`, and wrap anything slow in `await using claim = await ablo.<model>.claim({ id })`. The sections below cover the rest; "Start here" is the first thing to run.
31
+
32
+ ## Machine-readable surfaces
33
+
34
+ Everything below is public, needs no credential, and is served from `https://www.abloatai.com`:
35
+
36
+ - `/llms.txt` — this file.
37
+ - `/llms-full.txt` — the entire published documentation corpus in one fetch.
38
+ - `/developers` — every developer surface named on one page: the SDK, the API reference, the OpenAPI document, the MCP server, the CLI.
39
+ - `/openapi.json` — the REST contract as OpenAPI 3.1: every route, a stable `operationId` and description on each, typed responses, and the `ErrorEnvelope` every 4xx and 5xx decodes through. Generate a client from it when no SDK exists for your runtime.
40
+ - `/mcp` — the integration-helper MCP server over Streamable HTTP. POST your JSON-RPC here; a GET returns a descriptor rather than the protocol.
41
+ - `/.well-known/mcp.json` — that server's manifest, in the MCP registry's `server.json` format.
42
+ - `/api/docs/<page>` — any documentation page as plain Markdown, for a client that fetches URLs rather than speaking MCP.
43
+ - `/sitemap.xml` — every indexable page on the domain.
44
+
45
+ Every page on that host serves a Markdown representation from its own URL: send
46
+ `Accept: text/markdown` (q-values are honoured), or append `.md` to the path if
47
+ your client cannot set the header. Responses carry `Vary: Accept`, a client that
48
+ will accept neither `text/html` nor `text/markdown` gets a `406` listing what is
49
+ available, and a path that does not exist answers a real `404` — never a `200`
50
+ carrying a sign-in page.
51
+
52
+ ## Versioning and deprecation
53
+
54
+ Every route lives under `/v1`, and that segment is part of the address you call. A change that would break you arrives as a new segment beside it, never as a change to this one. Additive changes do land in `/v1` — a new response field, a new optional parameter, a new error code — so ignore what you do not recognize.
55
+
56
+ Every response carries `Ablo-Version`, a date stamp for the contract being served. A route being withdrawn says so on itself for at least 180 days first: `Deprecation` (RFC 9745) carries when the deprecation took effect and the route keeps answering; `Sunset` (RFC 8594) carries when it stops. The same operations are marked `deprecated: true` in `/openapi.json`.
57
+
58
+ Responses also carry `RateLimit-Policy` (the standing allowance, e.g. `"secret";q=600;w=12`) and, once your request is attributed to a key, `RateLimit` (what is left and when it refills). A 429 adds `Retry-After` in whole seconds. Pace against these rather than retrying blind.
59
+
9
60
  ## Surfaces: pick by who is calling
10
61
 
11
62
  Every surface reaches the same coordinated state. They are not interchangeable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.56.0",
3
+ "version": "0.57.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",
@@ -139,8 +139,8 @@
139
139
  "directory": "packages/ablo"
140
140
  },
141
141
  "dependencies": {
142
- "@abloatai/humans": "^0.56.0",
143
- "@abloatai/transaction": "^0.56.0",
142
+ "@abloatai/humans": "^0.57.0",
143
+ "@abloatai/transaction": "^0.57.0",
144
144
  "zod": "^4.4.3"
145
145
  },
146
146
  "peerDependencies": {