@abloatai/ablo 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +248 -124
  3. package/dist/BaseSyncedStore.d.ts +3 -3
  4. package/dist/BaseSyncedStore.js +3 -3
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +91 -93
  8. package/dist/client/Ablo.js +122 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +116 -90
  14. package/dist/client/createModelProxy.js +128 -128
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +5 -5
  18. package/dist/coordination/index.d.ts +6 -0
  19. package/dist/coordination/index.js +6 -0
  20. package/dist/coordination/schema.d.ts +329 -0
  21. package/dist/coordination/schema.js +209 -0
  22. package/dist/core/QueryView.d.ts +4 -1
  23. package/dist/core/QueryView.js +1 -1
  24. package/dist/core/index.d.ts +2 -0
  25. package/dist/core/index.js +7 -0
  26. package/dist/core/query-utils.d.ts +7 -10
  27. package/dist/core/query-utils.js +2 -3
  28. package/dist/errorCodes.d.ts +264 -0
  29. package/dist/errorCodes.js +251 -0
  30. package/dist/errors.d.ts +59 -14
  31. package/dist/errors.js +73 -12
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +8 -12
  34. package/dist/interfaces/index.d.ts +2 -10
  35. package/dist/mutators/Transaction.d.ts +2 -2
  36. package/dist/mutators/Transaction.js +2 -2
  37. package/dist/mutators/mutateActions.d.ts +44 -0
  38. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  39. package/dist/mutators/readerActions.d.ts +32 -0
  40. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  41. package/dist/policy/index.d.ts +1 -1
  42. package/dist/policy/index.js +1 -1
  43. package/dist/policy/types.d.ts +31 -0
  44. package/dist/policy/types.js +15 -0
  45. package/dist/query/types.d.ts +1 -1
  46. package/dist/react/AbloProvider.d.ts +13 -1
  47. package/dist/react/AbloProvider.js +14 -6
  48. package/dist/react/context.d.ts +4 -4
  49. package/dist/react/index.d.ts +4 -5
  50. package/dist/react/index.js +3 -7
  51. package/dist/react/useAblo.d.ts +14 -14
  52. package/dist/react/useAblo.js +26 -26
  53. package/dist/react/useIntent.d.ts +2 -2
  54. package/dist/react/useIntent.js +2 -2
  55. package/dist/react/useMutators.d.ts +1 -1
  56. package/dist/react/usePresence.d.ts +3 -3
  57. package/dist/react/usePresence.js +4 -4
  58. package/dist/react/useUndoScope.d.ts +1 -1
  59. package/dist/schema/ddl.d.ts +62 -0
  60. package/dist/schema/ddl.js +317 -0
  61. package/dist/schema/diff.d.ts +167 -0
  62. package/dist/schema/diff.js +280 -0
  63. package/dist/schema/field.d.ts +16 -19
  64. package/dist/schema/field.js +30 -17
  65. package/dist/schema/generate.d.ts +19 -0
  66. package/dist/schema/generate.js +87 -0
  67. package/dist/schema/index.d.ts +9 -3
  68. package/dist/schema/index.js +14 -2
  69. package/dist/schema/model.d.ts +87 -25
  70. package/dist/schema/model.js +33 -3
  71. package/dist/schema/relation.d.ts +17 -0
  72. package/dist/schema/roles.d.ts +148 -0
  73. package/dist/schema/roles.js +149 -0
  74. package/dist/schema/schema.d.ts +10 -69
  75. package/dist/schema/schema.js +58 -24
  76. package/dist/schema/select.d.ts +25 -0
  77. package/dist/schema/select.js +55 -0
  78. package/dist/schema/serialize.d.ts +96 -0
  79. package/dist/schema/serialize.js +231 -0
  80. package/dist/schema/sugar.d.ts +20 -3
  81. package/dist/schema/sugar.js +5 -1
  82. package/dist/schema/tenancy.d.ts +66 -0
  83. package/dist/schema/tenancy.js +58 -0
  84. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  85. package/dist/sync/HydrationCoordinator.js +23 -17
  86. package/dist/sync/SyncWebSocket.d.ts +17 -0
  87. package/dist/sync/SyncWebSocket.js +46 -1
  88. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  89. package/dist/sync/awaitIntentGrant.js +60 -0
  90. package/dist/sync/createIntentStream.d.ts +2 -1
  91. package/dist/sync/createIntentStream.js +89 -5
  92. package/dist/sync/createPresenceStream.js +1 -1
  93. package/dist/sync/participants.d.ts +2 -2
  94. package/dist/sync/participants.js +9 -18
  95. package/dist/types/global.d.ts +43 -52
  96. package/dist/types/global.js +16 -18
  97. package/dist/types/streams.d.ts +90 -42
  98. package/docs/api-keys.md +44 -0
  99. package/docs/api.md +72 -173
  100. package/docs/audit.md +5 -5
  101. package/docs/cli.md +212 -0
  102. package/docs/client-behavior.md +42 -43
  103. package/docs/coordination.md +343 -0
  104. package/docs/data-sources.md +16 -16
  105. package/docs/examples/agent-human.md +30 -32
  106. package/docs/examples/ai-sdk-tool.md +32 -33
  107. package/docs/examples/existing-python-backend.md +38 -36
  108. package/docs/examples/nextjs.md +24 -25
  109. package/docs/examples/scoped-agent.md +78 -0
  110. package/docs/examples/server-agent.md +20 -61
  111. package/docs/guarantees.md +34 -56
  112. package/docs/identity.md +529 -0
  113. package/docs/index.md +18 -24
  114. package/docs/integration-guide.md +130 -144
  115. package/docs/interaction-model.md +32 -95
  116. package/docs/mcp/claude-code.md +3 -3
  117. package/docs/mcp/cursor.md +1 -1
  118. package/docs/mcp/windsurf.md +1 -1
  119. package/docs/mcp.md +11 -26
  120. package/docs/quickstart.md +43 -49
  121. package/docs/react.md +74 -24
  122. package/docs/roadmap.md +17 -7
  123. package/llms.txt +34 -39
  124. package/package.json +8 -1
  125. package/dist/react/useMutate.d.ts +0 -83
  126. package/dist/react/useQuery.d.ts +0 -123
  127. package/dist/react/useQuery.js +0 -145
  128. package/dist/react/useReader.d.ts +0 -69
  129. package/docs/capabilities.md +0 -163
@@ -0,0 +1,529 @@
1
+ # Identity & Sync Groups
2
+
3
+ This is the doc the Quickstart skips: **who is connecting, and which slice
4
+ of shared state do they get?** If you've wired `<AbloProvider schema={schema}>`
5
+ and wondered where org / team / user actually come from — start here.
6
+
7
+ ## Ablo does not do auth
8
+
9
+ Ablo is not an identity provider. It has no login, no password store, no
10
+ session of its own. You keep whatever you already use — Clerk, Auth0,
11
+ NextAuth, WorkOS, your own session table. Ablo's job begins **after** you've
12
+ authenticated the user: you hand Ablo the already-authenticated identity, and
13
+ Ablo decides which **sync groups** that identity may read and write.
14
+
15
+ So the integration question is never "how do I log into Ablo?" It's: *"My app
16
+ already knows this request is user `U` in org `O`. How do I tell Ablo, so it
17
+ scopes their realtime data correctly?"* The rest of this doc answers exactly
18
+ that.
19
+
20
+ ## What a sync group is
21
+
22
+ A **sync group** is a named channel of shared state — a string like
23
+ `org:acme` or `deck:abc123`. It is simultaneously:
24
+
25
+ - **the unit of fan-out** — a confirmed write to a row publishes a delta to
26
+ every participant subscribed to that row's sync group(s), and
27
+ - **the unit of access** — a participant receives a row's deltas *only if* the
28
+ row's sync group is in their allowed set.
29
+
30
+ There is no built-in `org` / `team` / `user` concept in the engine. Those are
31
+ *your* domain words. Ablo only knows sync-group strings. The mapping from "this
32
+ is user U in org O" to "they may subscribe to `org:acme` and `user:U`" is
33
+ something **you declare in your schema** — covered next.
34
+
35
+ ### Two kinds of group — the whole mental model
36
+
37
+ Every sync group is named after one of two things, and that's the cleanest way
38
+ to hold the model in your head:
39
+
40
+ - **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
41
+ `user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
42
+ standing and durable — they don't change as you work.
43
+ - **Entity groups** — named after *a thing*: `dataroom:{id}`, `deck:{id}`,
44
+ `slide:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
45
+ They're granular — one per record — and any participant can be pointed at a
46
+ specific set of them.
47
+
48
+ Humans and agents fill that same space differently — and, crucially, the two
49
+ are **declared in different places**, because membership is static (a property of
50
+ identity) while entity scope is dynamic (a property of the task):
51
+
52
+ | | Subscribed by | Declared where | Gets |
53
+ | --- | --- | --- | --- |
54
+ | **Human** | *who they are* — membership | **the schema** (`identityRoles`) — a rule, written once | every `org` / `team` / `user` group their identity implies — their whole standing world |
55
+ | **Agent** | *what it's been given* — entities | **code, at the spawn site** — chosen per run | a handful of entity groups: the dataroom it's in, the slides it has read — never beyond what its user's membership could reach |
56
+
57
+ > **One line:** humans subscribe by who they are; agents subscribe by what
58
+ > they've been given.
59
+
60
+ The asymmetry is the point. A user's org/team/user don't change per request, so
61
+ their scope is a **rule the schema derives automatically** — you never write
62
+ per-user scope code. An agent's reach depends on *what it's working on*, which is
63
+ only knowable at dispatch — so you pass its `syncGroups` **at the call site, in
64
+ code**. The schema's only job for entities is to declare *that* a model is
65
+ entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
66
+ it never declares *which* entities a given agent gets. (A human can opt into the
67
+ same runtime narrowing — a page scoped to one deck — but by default a human's
68
+ scope is fully schema-derived.)
69
+
70
+ So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed
71
+ at a few entity groups, bounded above by its triggering user's membership. That
72
+ boundary is the whole safety story, and it's covered in
73
+ [Agents are participants too](#agents-are-participants-too).
74
+
75
+ ```txt
76
+ your auth → identity { kind, userId|agentId, organizationId, teamIds }
77
+ → identityRoles (schema) → allowed sync groups
78
+ → participant receives deltas for rows in those groups
79
+ ```
80
+
81
+ The identity is a **participant** — and a participant is either a human
82
+ (`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see
83
+ [Agents are participants too](#agents-are-participants-too) below. Everything in
84
+ the next two sections applies to both.
85
+
86
+ ## Declare it, end to end
87
+
88
+ The entire declaration surface is: `identityRoles` (who may see what), and on
89
+ each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
90
+ optional `syncGroups` prop (narrowing). Here they are in one runnable place — the
91
+ sections after this explain each.
92
+
93
+ ```ts
94
+ // 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
95
+ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
96
+
97
+ export const schema = defineSchema(
98
+ {
99
+ // A scope root: its rows form the group `deck:<id>` (kind from `scope`).
100
+ decks: model(
101
+ { title: z.string(), status: z.enum(['draft', 'published']) },
102
+ {},
103
+ { orgScoped: true, scope: 'deck' },
104
+ ),
105
+ // A child: it has no group of its own; it inherits its deck's group via the
106
+ // `parent` edge. A write to a slide reaches everyone viewing the deck.
107
+ slides: model(
108
+ { deckId: z.string() },
109
+ { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
110
+ { orgScoped: true },
111
+ ),
112
+ },
113
+ {
114
+ // Each role is pure data: a `kind` (the group prefix) and the identity
115
+ // `source` field to read. No closures — so the schema stays JSON-serializable.
116
+ identityRoles: [
117
+ identityRole({ kind: 'org', source: 'organizationId' }),
118
+ identityRole({ kind: 'user', source: 'userId' }),
119
+ identityRole({ kind: 'team', source: 'teamIds', multi: true }),
120
+ ],
121
+ },
122
+ );
123
+ ```
124
+
125
+ ```tsx
126
+ // 2. app/providers.tsx — a HUMAN gets their full org / team scope
127
+ <AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
128
+ {children}
129
+ </AbloProvider>
130
+ ```
131
+
132
+ ```tsx
133
+ // 3. an AGENT run inherits its user, narrowed to the entities in play.
134
+ // Pass the MODEL form — { decks: id } — not a hand-built `deck:<id>` string;
135
+ // the engine derives the group from each model's `scope`.
136
+ <AbloProvider
137
+ schema={schema}
138
+ userId={user.id} // ceiling: the triggering user
139
+ scope={{ decks: deckId }} // floor: just the deck it's working on
140
+ >
141
+ {children}
142
+ </AbloProvider>
143
+ ```
144
+
145
+ That's the whole surface. The rest of this doc is the *why* behind each line.
146
+
147
+ ## The two halves of scoping
148
+
149
+ Scoping is two declarations that meet in the middle. One describes the
150
+ **participant** (what may I subscribe to?), the other describes each **row**
151
+ (which group does this row belong to?). A participant sees a row **iff** the
152
+ row's sync group is in the participant's allowed set.
153
+
154
+ ### Half 1 — `identityRoles`: identity → allowed groups
155
+
156
+ Declared once, on the schema, via the `identityRole({ kind, source })` factory.
157
+ Each role is **pure data**: a `kind` (the group's prefix — `org`, `user`, `team`)
158
+ and the `source` — the identity field to read. The engine reads `source` off the
159
+ identity *you* supply and mints `<kind>:<value>` for each value, building the
160
+ participant's allowed set. There is no hardcoded `org:` / `user:` anywhere in the
161
+ engine — the kinds and sources are entirely yours.
162
+
163
+ ```ts
164
+ // src/ablo/schema.ts
165
+ import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
166
+
167
+ export const schema = defineSchema(
168
+ {
169
+ decks: model({
170
+ title: z.string(),
171
+ status: z.enum(['draft', 'published']),
172
+ }),
173
+ },
174
+ {
175
+ identityRoles: [
176
+ identityRole({ kind: 'org', source: 'organizationId' }),
177
+ identityRole({ kind: 'user', source: 'userId' }),
178
+ // `multi: true` reads an array field — one `team:<id>` group per id.
179
+ identityRole({ kind: 'team', source: 'teamIds', multi: true }),
180
+ ],
181
+ },
182
+ );
183
+ ```
184
+
185
+ The identity these `source` fields read is what your app resolves from its own
186
+ auth — Ablo never invents it. Roles are pure data (no closures) on purpose: a
187
+ `Schema` stays JSON-serializable end to end, so the same declaration works
188
+ in-process and on a hosted server that only ever sees the compiled JSON.
189
+
190
+ > **Single field per role.** `source` reads one field. An agent doesn't need its
191
+ > own role: it runs on behalf of a user and carries that user's `userId`, so the
192
+ > `user:{id}` role above already covers it — see
193
+ > [Agents are participants too](#agents-are-participants-too).
194
+
195
+ ### Half 2 — per-model scope: row → group
196
+
197
+ You never write a sync-group string for a row. You declare a model's *place* in
198
+ the entity graph and the engine derives the groups its rows fan out on. Three
199
+ declarations, in order of how often you reach for them:
200
+
201
+ **`scope` — this model is a scope root.** Its rows form a group of their own.
202
+ The kind comes from the model's `typename` by default, or pass a string to set
203
+ it explicitly (use the string form when the wire kind differs from the typename,
204
+ e.g. typename `SlideDeck` but group `deck:<id>`):
205
+
206
+ ```ts
207
+ decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
208
+ // a deck row → group `deck:<id>`
209
+ ```
210
+
211
+ **`parent` — this row lives inside another entity.** Mark the `belongsTo` edge
212
+ to its owner; the row inherits that owner's group. This is the Zanzibar/ReBAC
213
+ *parent* relation — "access inherits from parent" — and it chains transitively
214
+ (a layer → its slide → its deck), so a write to any descendant reaches everyone
215
+ viewing the root. A *reference* (a provenance/template pointer, not ownership)
216
+ must **not** be marked `parent`, or the row would leak into an unrelated scope:
217
+
218
+ ```ts
219
+ slides: model(
220
+ { deckId: z.string(), sourceSlideId: z.string().optional() },
221
+ {
222
+ deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership → inherit deck:<id>
223
+ sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
224
+ },
225
+ { orgScoped: true },
226
+ );
227
+ ```
228
+
229
+ > **Declare the parent edge — don't infer it.** Optionality is not a proxy for
230
+ > ownership: many `parent` FKs are optional (a root folder, an inbox task), and
231
+ > some required FKs are mere references. Containment is a fact only you know, so
232
+ > it's declared, exactly as it is in OpenFGA/Zanzibar.
233
+
234
+ **`grants` — a membership edge.** On a join model (e.g. `dataroomMember`), it
235
+ says "this row grants a *subject* access to a *scope root*." Both are relation
236
+ names on the model. The server resolves it at connect time — for user `U`, it
237
+ finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
238
+ set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
239
+ org membership is already covered by the `org:` identity role.
240
+
241
+ ```ts
242
+ dataroomMember: model(
243
+ { userId: z.string(), dataroomId: z.string() },
244
+ {
245
+ member: relation.belongsTo('users', 'userId'),
246
+ room: relation.belongsTo('datarooms', 'dataroomId'),
247
+ },
248
+ { orgScoped: true, grants: { subject: 'member', scope: 'room' } },
249
+ );
250
+ ```
251
+
252
+ For the rare group keyed on a plain field rather than a relation (per-recipient
253
+ inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
254
+ escape hatch. For rows that inherit *tenancy* (not a sync group) through a
255
+ foreign key without carrying `organization_id`, use `scopedVia` rather than
256
+ `orgScoped: false` — the latter exposes the whole table cross-tenant. See
257
+ `packages/sync-engine/src/schema/model.ts` for the full option set.
258
+
259
+ ## How identity reaches Ablo — the proxy model
260
+
261
+ This is the part the README's "authenticates with the signed-in user's
262
+ session" glossed over. Concretely:
263
+
264
+ 1. **Your `ABLO_API_KEY` lives only on your trusted server**, scoped to your
265
+ account. It signs your app's relationship with Ablo. It must never reach a
266
+ browser bundle — treat it like a Stripe secret key.
267
+ 2. **Your server authenticates the user with your own system.** That's the
268
+ request that knows "this is user `U`, org `O`, teams `[...]`".
269
+ 3. **Your server hands that authenticated identity to Ablo**, and the browser
270
+ talks to the realtime plane as an already-scoped participant. The browser
271
+ never holds the API key and cannot widen its own scope — the security
272
+ boundary is the identity your **server** vouched for, not anything the client
273
+ asserts.
274
+ 4. **Ablo runs your `identityRoles` over that identity** to compute the allowed
275
+ sync groups, and the participant subscribes to exactly that set.
276
+
277
+ The Ablo web app (`apps/web`) is the reference implementation of this shape:
278
+ its server resolves the signed-in user and active organization from its own
279
+ auth, and the sync layer composes the participant's sync groups from that
280
+ resolved identity — the API key stays server-side throughout. The generic,
281
+ library-agnostic name for "my server tells Ablo which of my users is acting" is
282
+ the `Ablo-Acting-User` request dimension; the web app realizes it through its
283
+ own session, but the contract is the same: **identity is asserted by your
284
+ server, never by the browser.**
285
+
286
+ > **Why the proxy, not a client API key?** A browser is a hostile runtime. If
287
+ > the client could name its own org or sync groups, any user could read another
288
+ > tenant's data by editing a request. By keeping the API key server-side and
289
+ > deriving scope from the identity your server already authenticated, the trust
290
+ > boundary lands in the one place you control. This is the same reason
291
+ > Liveblocks resolves scope in `prepareSession` and Stripe mints ephemeral keys
292
+ > server-side.
293
+
294
+ ## Wiring the provider
295
+
296
+ The provider props carry the identity your server resolved. In a Next.js app,
297
+ resolve the user in a Server Component and pass it down:
298
+
299
+ ```tsx
300
+ // app/providers.tsx — 'use client'
301
+ import { AbloProvider } from '@abloatai/ablo/react';
302
+ import { schema } from '@/ablo/schema';
303
+
304
+ export function Providers({
305
+ children,
306
+ user, // { id, teamIds } — resolved server-side from YOUR auth
307
+ }: {
308
+ children: React.ReactNode;
309
+ user: { id: string; teamIds: string[] };
310
+ }) {
311
+ return (
312
+ <AbloProvider
313
+ schema={schema}
314
+ userId={user.id}
315
+ teamIds={user.teamIds}
316
+ fallback={<AppSkeleton />}
317
+ >
318
+ {children}
319
+ </AbloProvider>
320
+ );
321
+ }
322
+ ```
323
+
324
+ What each identity-related prop does — and just as importantly, does *not* do:
325
+
326
+ | Prop | Purpose |
327
+ | ------------ | ------------------------------------------------------------------------------------------------ |
328
+ | `userId` | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
329
+ | `teamIds` | Team ids expanded into team sync groups via your `identityRoles`. |
330
+ | `syncGroups` | Optional. **Narrows** the subscription to a subset of what auth already allows — it can never widen it. Use it to scope a page to one entity (e.g. `['deck:abc123']`). |
331
+
332
+ Because the server is the boundary, a client that changes `userId` to another
333
+ user's id does not gain their data — the server resolves and enforces the real
334
+ identity on the connection. The props are how your app *tells* Ablo who it
335
+ already authenticated, not how it *proves* it.
336
+
337
+ ## Agents are participants too
338
+
339
+ An agent and a human **authenticate through the exact same path** — same proxy,
340
+ same `identityRoles`, same server-enforced boundary. An agent is a participant;
341
+ the only data difference is that it carries `kind: 'agent'` and an `agentId`
342
+ where a human carries `userId`. There is no separate identity model to learn.
343
+
344
+ What differs is **authority, not identity** — and the distinction is the whole
345
+ point. An agent always runs *on behalf of* the user who set it off, so its
346
+ **ceiling is exactly that user's access**: the same conversations, messages, and
347
+ models the triggering user can reach, and nothing that user couldn't. But within
348
+ that ceiling it is **narrowed to the model instances it is touching, or has
349
+ touched** — never the user's whole org.
350
+
351
+ Scope is therefore an intersection:
352
+
353
+ ```txt
354
+ agent authority = (triggering user's allowed set) ← ceiling, inherited (on-behalf-of)
355
+ ∩ (the model instances it touches) ← floor, least privilege per run
356
+ ```
357
+
358
+ Mechanically, this is the per-model anchor from
359
+ [Half 2](#half-2--per-model-scope-row--group) doing the work. Declare an entity
360
+ anchor on the models an agent operates on:
361
+
362
+ ```ts
363
+ // each scope-root model an agent edits forms a per-entity group
364
+ documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
365
+ decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
366
+ ```
367
+
368
+ Then a run subscribes only to the entity groups for the rows it works on — a
369
+ subset of what its user could see:
370
+
371
+ ```tsx
372
+ // agent run triggered by `user`, working on one document + one deck
373
+ <AbloProvider
374
+ schema={schema}
375
+ // identity inherited from the triggering user (the ceiling)
376
+ userId={user.id}
377
+ // authority narrowed to just the entities in play (the floor).
378
+ // Model form — keyed by model, resolved to groups via each model's `scope`.
379
+ scope={{ documents: documentId, decks: deckId }}
380
+ >
381
+ ```
382
+
383
+ As the run touches more entities its set **accretes** to cover them; it never
384
+ widens past the user's ceiling, and it carries no standing access to entities it
385
+ isn't working on. The `identityRoles` need no agent-specific entry: the agent
386
+ carries the triggering user's `userId`, so the same `user:{id}` role that scopes
387
+ a human already scopes the agent. Nothing about the *identity* declaration
388
+ branches on agent vs human.
389
+
390
+ `kind` is what attribution uses — not access. `kind: 'agent'` plus `agentId` is
391
+ connection metadata that tags every write with the executing agent **and** the
392
+ user it ran on behalf of, so audit answers "who did this, and on whose behalf."
393
+ It never appears in an `identityRole`, because it changes *who's accountable*,
394
+ not *what's reachable*.
395
+
396
+ This is deliberately the shape the 2025–2026 agent-identity consensus converged
397
+ on, expressed in Ablo's primitives rather than a bolted-on agent ACL:
398
+
399
+ - **Inherit the user, and no more** — the OAuth
400
+ [on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
401
+ agent's reach is tied to the consenting user, never the org.
402
+ - **Least privilege, just-in-time** — scoped to the task's entities, not standing
403
+ org-wide access (the over-privilege pattern
404
+ [OWASP's NHI Top 10](https://www.token.security/assets/the-ultimate-non-human-identity-security-guide)
405
+ flags as the dominant agent risk).
406
+ - **Dual-principal attribution** — record both the executing agent and the
407
+ triggering human.
408
+
409
+ Identity is 1:1 with a human participant; authority is narrowed to the work. That
410
+ split is what lets Ablo keep *one model API for every actor* without ever
411
+ granting an agent standing access to everything its user can see. The agent that
412
+ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-work)
413
+ `claim` loop is, to the scoping layer, that same participant — scoped to the row
414
+ it claimed.
415
+
416
+ ## Narrowing to specific entities — the `scope` prop
417
+
418
+ A human gets their full membership automatically (`identityRoles`). To narrow a
419
+ session — a page on one deck, or an agent pointed at the entities it's working
420
+ on — pass `scope`. You give it the **model and id(s)**; the engine builds the
421
+ group string from the model's `scope` (Half 2), so you never hand-write
422
+ `deck:<id>`.
423
+
424
+ `scope` accepts four shapes, all resolved through the schema:
425
+
426
+ | You pass | Resolves to | Use it for |
427
+ | --- | --- | --- |
428
+ | `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
429
+ | `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
430
+ | `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
431
+ | `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
432
+
433
+ ```tsx
434
+ // a page on one deck
435
+ <AbloProvider schema={schema} userId={user.id} scope={{ decks: deckId }} />
436
+
437
+ // an agent working across two decks and a document
438
+ <AbloProvider
439
+ schema={schema}
440
+ userId={user.id}
441
+ scope={{ decks: [deckA, deckB], documents: docId }}
442
+ />
443
+ ```
444
+
445
+ The key is the **model** (`decks`), the value is **which id(s)** — the `deck:`
446
+ prefix comes from that model's `scope: 'deck'`, never from a string you compose.
447
+
448
+ > **`scope` means one thing: sync-group scope.** It appears in two places that
449
+ > are the same concept — the model option `scope: 'deck'` (declares a scope root,
450
+ > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` prop (subscribe
451
+ > to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
452
+ > axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
453
+ > open/closed/all), precisely so it doesn't share the word.
454
+
455
+ > **`scope` requests, it never grants.** At connect, the server intersects your
456
+ > requested groups with what the identity is actually allowed (`requested ∩
457
+ > allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
458
+ > agent can't reach a deck its capability doesn't already permit, no matter what
459
+ > it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
460
+
461
+ ## How this compares — and the best practices it follows
462
+
463
+ Ablo's identity model is not novel; it's the convergent answer every serious
464
+ realtime / sync SDK arrived at. Knowing which industry pattern it *is* tells you
465
+ how to reason about it.
466
+
467
+ **Realtime authorization splits into two shapes.** Ablo is firmly in the first:
468
+
469
+ - **Server derives scope from authenticated identity** — the server decides what
470
+ a participant may read/write and the client cannot override it. This is Ablo's
471
+ proxy model. It's the same shape as
472
+ [Supabase Realtime's RLS-on-connect](https://supabase.com/docs/guides/realtime/authorization)
473
+ (policies evaluated at subscribe, cached for the connection),
474
+ [Liveblocks **ID tokens**](https://liveblocks.io/docs/authentication) ("Liveblocks
475
+ checks the permissions for you" — recommended for production), and
476
+ [ElectricSQL **proxy auth**](https://electric-sql.com/docs/guides/auth) (a
477
+ reverse-proxy sets shape params server-side before forwarding).
478
+ - **Client proposes, server authorizes the exact request** — the client names
479
+ the room/shape and the server signs off, as in
480
+ [Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
481
+ [ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
482
+ and Liveblocks **access tokens**. Ablo's `syncGroups` prop is the *narrowing*
483
+ half of this — but it can only ever shrink the server-derived set, never grow
484
+ it.
485
+
486
+ The best practices Ablo inherits from that lineage:
487
+
488
+ 1. **The secret never reaches the client.** Your `ABLO_API_KEY` lives only on a
489
+ trusted server — exactly as
490
+ [Ably mandates](https://ably.com/docs/auth/token) ("never use API keys in
491
+ client-side code; they don't expire, so once compromised they grant indefinite
492
+ access") and
493
+ [PowerSync's flow](https://docs.powersync.com/installation/authentication-setup/custom)
494
+ (app auth → backend mints a signed token → client connects with the token).
495
+
496
+ 2. **Trusted vs untrusted claims is the whole security argument.** PowerSync draws
497
+ the line precisely: [token parameters are trusted and usable for access
498
+ control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
499
+ In Ablo terms, the identity your server vouches for is the *trusted* claim that
500
+ sets scope; the provider's `userId` / `scope` props are *untrusted client
501
+ input* — convenient for app-owned fields and narrowing, but never the boundary.
502
+ This is why changing `userId` in the browser grants nothing.
503
+
504
+ 3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
505
+ group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
506
+ `scope`) is the same idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
507
+ (`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities).
508
+ Declaring the convention in one place — never composing scope strings in
509
+ consumer code — is the practice all three enforce.
510
+
511
+ 4. **Attribution and presence ride the authenticated identity.** Just as
512
+ [Pusher attaches `channel_data` to presence at auth time](https://pusher.com/docs/channels/server_api/authorizing-users/),
513
+ Ablo's participant identity (the one your server vouched for) is what powers
514
+ presence and per-write attribution — not a value the client asserts after the
515
+ fact.
516
+
517
+ The one practice that differs by deployment: short-lived, auto-refreshed bearer
518
+ tokens ([Ably](https://ably.com/docs/auth/token),
519
+ [Supabase's `access_token` refresh](https://supabase.com/docs/guides/realtime/authorization))
520
+ are the right shape when an untrusted client holds a credential directly. Ablo's
521
+ proxy model keeps the credential server-side instead, so token rotation is the
522
+ server's concern, not the browser's — the same trade ElectricSQL's proxy pattern
523
+ makes versus its gatekeeper tokens.
524
+
525
+ ## See also
526
+
527
+ - [Integration Guide](./integration-guide.md) — `identityRoles`, backing modes, and the full app path.
528
+ - [React](./react.md) — the complete `<AbloProvider>` prop surface.
529
+ - [API Keys](./api-keys.md) — server-side keys for the public API.
package/docs/index.md CHANGED
@@ -4,11 +4,11 @@ Ablo is a state control API for **humans and AI agents editing the same
4
4
  typed state in real time, with attribution, conflict handling, and
5
5
  fast cutoff**.
6
6
 
7
- It gives agents a narrow way to write production state: declare models, load current state, coordinate active work, and write with stale-state checks. Capabilities, tasks, commits, and receipts are the protocol underneath, not first-integration ceremony.
7
+ It gives agents a narrow way to write production state: declare models, load current state, coordinate active work, and write with stale-state checks.
8
8
 
9
9
  Multiplayer is not a separate product mode. If humans, server actions, and agents
10
10
  use the same `Ablo({ schema, apiKey })` client and write through
11
- `ablo.<model>`, Ablo fans out confirmed deltas, exposes active intents, and
11
+ `ablo.<model>`, Ablo fans out confirmed deltas, exposes active claims, and
12
12
  rejects stale writes for every participant.
13
13
 
14
14
  ## What you get, in three commitments
@@ -27,38 +27,32 @@ query-shaped sync).
27
27
  declared once on the schema's `identityRoles`. Consumer code never
28
28
  composes group strings. Same boundary Liveblocks (`prepareSession`),
29
29
  PowerSync (named streams), and Zero (synced queries) settled on.
30
- - **Capabilities, not API keys, for agents.** Per-run, per-scope, leased
31
- credentials with per-request signature verification and instant
32
- revocation. The 2025-26 AI-agent auth consensus (OAuth 2.1 / MCP,
33
- AWS STS, Vault leases, Auth0 Token Vault) converged on this shape;
34
- capabilities are Ablo's instance. See
35
- [Capabilities](./capabilities.md#why-capabilities-not-api-keys) for
36
- the design rationale.
37
30
 
38
31
  ## Start here
39
32
 
40
33
  - [Quickstart](./quickstart.md) — Make your first schema-backed write.
34
+ - [CLI & Migrations](./cli.md) — `init` / `migrate` / `schema push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
35
+ - [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
41
36
  - [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
42
- - [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and intents guarantee.
43
- - [Interaction Model](./interaction-model.md) — The commit plane and control plane.
44
- - [API Reference](./api.md) — Resource-by-resource method shape.
37
+ - [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
38
+ - [Interaction Model](./interaction-model.md) — The schema, claim, update, confirmation loop.
39
+ - [API Reference](./api.md) — Model-by-model method shape.
45
40
  - [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
46
41
  - [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
42
+ - [React](./react.md) — Provider, hooks, and reactive reads for React apps.
47
43
  - [API Keys](./api-keys.md) — Bearer tokens for the public API.
48
44
 
49
45
  ## API shape
50
46
 
51
47
  | Plane | Primitives | Purpose |
52
48
  |---|---|---|
53
- | State | `Schema`, `Model`, `Intent`, `Receipt` | The product path. Load, coordinate, write, confirm. |
54
- | Commit | `Resource`, `Commit` | Advanced protocol path for custom runtimes and batches. |
55
- | Control | `Capability`, `Task`, `Usage` | The authority path. Scope, attribute, meter, audit. |
49
+ | State | `Schema`, `Model`, `Claim`, `Receipt` | The product path. Load, coordinate, write, confirm. |
56
50
  | Storage | `Managed State`, `Data Source` | Ablo stores declared models by default; existing app tables use a signed Data Source. |
57
51
 
58
52
  ## Use cases
59
53
 
60
54
  - **Let agents write to shared state** — Give an AI agent scoped, revocable write access to your typed data.
61
- - **Coordinate multiple actors** — Use intents to broadcast pre-write declarations across humans and agents.
55
+ - **Coordinate multiple actors** — Use claims to show pre-write work across humans and agents.
62
56
  - **Audit every agent action** — Trace any write back to a human in one query.
63
57
  - **Build collaborative editors** — Humans and agents on the same record, with realtime updates and stale-read protection.
64
58
  - **Meter and gate API usage** — Per-key, per-team usage reports and quota enforcement.
@@ -69,29 +63,29 @@ query-shaped sync).
69
63
  - [Model Methods](./api.md#model-methods) — Load and write typed state.
70
64
  - [Integration Guide](./integration-guide.md) — The normal app path and optional pieces.
71
65
  - [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
72
- - [Intent](./api.md#intent) — Broadcast proposed work before committing.
73
- - [Advanced Commit API](./api.md#advanced-commit-api) — Apply custom batch mutations.
66
+ - [Coordination](./coordination.md) — `claim`, `claimState`, and `queue` for active work.
74
67
  - [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
75
68
  - [Receipt](./api.md#receipt) — Confirm what landed.
76
- - [Capability](./api.md#capability) — Signed credentials for a bounded actor, operation set, sync group, and lease.
77
- - [Task](./api.md#task) — One agent run; the audit envelope for commits and cost.
78
69
  - [Usage](./api.md#usage) — Metering and audit dimensions.
70
+ - [Audit Log](./audit.md) — Trace any confirmed write back to the human behind it.
71
+ - [MCP](./mcp.md) — Expose Ablo models to MCP clients (Claude, Cursor).
79
72
 
80
73
  ## Examples
81
74
 
82
75
  - [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
83
76
  - [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
84
- - [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same task.
85
- - [Server Agent](./examples/server-agent.md) — Schema-backed worker plus advanced schema-less run.
77
+ - [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
78
+ - [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
79
+ - [Server Agent](./examples/server-agent.md) — Schema-backed worker.
86
80
  - [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
87
81
 
88
82
  ## Runtime builds
89
83
 
90
- - `@abloatai/ablo` — schema-powered sync client for typed model operations, realtime, intents, and receipts.
91
- - `Ablo({ apiKey })` — advanced resource client for runtimes that intentionally cannot import a schema.
84
+ - `@abloatai/ablo` — schema-powered sync client for typed model operations, realtime claims, and receipts.
92
85
 
93
86
  ## More
94
87
 
95
88
  - [README](../README.md) — product overview and first example.
96
89
  - [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
97
90
  - [Changelog](../CHANGELOG.md) — what shipped recently.
91
+ - [Roadmap](./roadmap.md) — what's planned next.