@abloatai/ablo 0.6.0 → 0.8.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 (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -1,7 +1,14 @@
1
1
  # Guarantees
2
2
 
3
- This page is the short contract for what Ablo guarantees at the state
4
- boundary.
3
+ When an Ablo write succeeds, the server has accepted it and when two people or
4
+ agents touch the same row, Ablo coordinates them instead of letting one silently
5
+ overwrite the other. This page is the precise list of what you can count on:
6
+ confirmed writes, stale-write protection, claims, and the audit trail behind
7
+ every change.
8
+
9
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
10
+ re-reads the fresh row, then hands it to you — so two writers serialize instead
11
+ of clobbering.
5
12
 
6
13
  ## Confirmed Writes
7
14
 
@@ -17,8 +24,9 @@ const updated = await ablo.weatherReports.update(
17
24
  ```
18
25
 
19
26
  If the call resolves, the write was accepted by the server. If it rejects, the
20
- error explains whether the write was rejected for auth, validation, stale state,
21
- active claim conflict, idempotency, rate limit, or transport failure.
27
+ typed error tells you exactly why the most common reasons being failed
28
+ authorization, a schema validation error, or a stale-state or claim conflict
29
+ (each covered below).
22
30
 
23
31
  Schema model writes return the updated model row.
24
32
 
@@ -58,24 +66,28 @@ Advanced policies exist for controlled product flows:
58
66
  - `reject` fails the write when state moved.
59
67
  - `force` applies the write without stale protection.
60
68
  - `flag` accepts the write and marks it for product review.
61
- - `merge` is reserved for server-defined merge behavior.
69
+
70
+ `merge` is not yet available.
62
71
 
63
72
  ## Claim Coordination
64
73
 
74
+ > The guarantee, not the how-to. Methods, the claim-state object, and the `claim.queue`
75
+ > live in [Coordination](./coordination.md).
76
+
65
77
  Claims are live coordination signals. They are not database locks.
66
78
 
67
- Claims are **advisory** and **cooperative**. `ablo.<model>.claim(id, ...)`
68
- serializes on contention: if another human or agent already holds the row, the
69
- claim waits for them to finish, then re-reads the row before handing it back, so
70
- you proceed from fresh state. Reads are open by default —
71
- `ablo.<model>.claimState(id)` returns the current claim state (or `null`) without
72
- ever blocking. Server/model reads can opt into `ifClaimed: 'wait'` or
73
- `ifClaimed: 'fail'` when they should not read through active work.
79
+ `ablo.<model>.claim(id, ...)` serializes on contention: if another human or agent
80
+ already holds the row, the claim waits for them to finish, then re-reads the row
81
+ before handing it back, so you proceed from fresh state. Reads stay open while a
82
+ claim is held `ablo.<model>.claim.state(id)` returns the current claim state
83
+ (or `null`) without ever blocking. A server read can pass `ifClaimed: 'wait'` to
84
+ wait for the claim to clear, or `ifClaimed: 'fail'` to error out, when it should
85
+ not return a row while someone else is mid-edit.
74
86
 
75
87
  A claim does not reject or block other writers; it announces work so peers
76
88
  serialize behind it rather than racing. While you hold a claim, the matching
77
- `ablo.<model>.update(id, ...)` is stale-guarded and rejects with
78
- `AbloStaleContextError` if the row advanced past your claim point.
89
+ `ablo.<model>.update(id, ...)` is rejected with `AbloStaleContextError` if the row
90
+ changed underneath you after your claim point.
79
91
 
80
92
  ## Agent Runs
81
93
 
@@ -95,10 +107,10 @@ authorized it, which run did it, and what state was it based on?"
95
107
 
96
108
  ## Persistence
97
109
 
98
- Ablo defaults to volatile local persistence. That keeps the SDK focused on
99
- coordination and audit instead of silently becoming a browser storage product.
110
+ Ablo defaults to volatile in-memory persistence, so nothing is written to disk
111
+ unless you ask for it.
100
112
 
101
- Opt into durable browser cache and offline queueing when you need it:
113
+ Opt into a durable browser cache that survives reloads when you need it:
102
114
 
103
115
  ```ts
104
116
  const ablo = Ablo({
package/docs/identity.md CHANGED
@@ -30,12 +30,76 @@ A **sync group** is a named channel of shared state — a string like
30
30
  There is no built-in `org` / `team` / `user` concept in the engine. Those are
31
31
  *your* domain words. Ablo only knows sync-group strings. The mapping from "this
32
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.
33
+ something **you declare in your schema**. Here is that whole declaration in one
34
+ runnable place, so the concepts below have code to attach to.
34
35
 
35
- ### Two kinds of group — the whole mental model
36
+ ## Declare it, end to end
37
+
38
+ The entire declaration surface is: `identityRoles` (who may see what), and on
39
+ each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
40
+ optional `syncGroups` prop (narrowing). Read the three blocks first — a human
41
+ gets their `org` / `team` scope, an agent gets one `deck` — then the sections
42
+ after explain each.
43
+
44
+ ```ts
45
+ // 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
46
+ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
36
47
 
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:
48
+ export const schema = defineSchema(
49
+ {
50
+ // A scope root: its rows form the group `deck:<id>` (kind from `scope`).
51
+ decks: model(
52
+ { title: z.string(), status: z.enum(['draft', 'published']) },
53
+ {},
54
+ { orgScoped: true, scope: 'deck' },
55
+ ),
56
+ // A child: it has no group of its own; it inherits its deck's group via the
57
+ // `parent` edge. A write to a slide reaches everyone viewing the deck.
58
+ slides: model(
59
+ { deckId: z.string() },
60
+ { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
61
+ { orgScoped: true },
62
+ ),
63
+ },
64
+ {
65
+ // Each role is pure data: a `kind` (the group prefix) and the identity
66
+ // `source` field to read. No closures — so the schema stays JSON-serializable.
67
+ identityRoles: [
68
+ identityRole({ kind: 'org', source: 'organizationId' }),
69
+ identityRole({ kind: 'user', source: 'userId' }),
70
+ identityRole({ kind: 'team', source: 'teamIds', multi: true }),
71
+ ],
72
+ },
73
+ );
74
+ ```
75
+
76
+ ```tsx
77
+ // 2. app/providers.tsx — a HUMAN gets their full org / team scope
78
+ <AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
79
+ {children}
80
+ </AbloProvider>
81
+ ```
82
+
83
+ ```tsx
84
+ // 3. an AGENT run inherits its user, narrowed to the entities in play.
85
+ // Pass the MODEL form — { decks: id } — not a hand-built `deck:<id>` string;
86
+ // the engine derives the group from each model's `scope`.
87
+ <AbloProvider
88
+ schema={schema}
89
+ userId={user.id} // ceiling: the triggering user
90
+ scope={{ decks: deckId }} // floor: just the deck it's working on
91
+ >
92
+ {children}
93
+ </AbloProvider>
94
+ ```
95
+
96
+ That's the whole surface. The rest of this doc is the *why* behind each line.
97
+
98
+ ## Two kinds of group — the whole mental model
99
+
100
+ You just saw a human get `org` / `team` groups and an agent get one `deck`
101
+ group. That split is the model. Every sync group is named after one of two
102
+ things:
39
103
 
40
104
  - **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
41
105
  `user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
@@ -45,9 +109,10 @@ to hold the model in your head:
45
109
  They're granular — one per record — and any participant can be pointed at a
46
110
  specific set of them.
47
111
 
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):
112
+ Humans and agents fill that same space differently, and you declare the two in
113
+ different places. A human's groups come from who they are, so you declare them
114
+ once in the schema. An agent's groups come from what it's working on right now,
115
+ so you pass them in code when you start the run.
51
116
 
52
117
  | | Subscribed by | Declared where | Gets |
53
118
  | --- | --- | --- | --- |
@@ -57,12 +122,13 @@ identity) while entity scope is dynamic (a property of the task):
57
122
  > **One line:** humans subscribe by who they are; agents subscribe by what
58
123
  > they've been given.
59
124
 
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* (`entity: 'deck'` → `deck:{id}`);
125
+ That's why you never write per-user scope code, but you always pass an agent's
126
+ scope at the call site. A user's org/team/user don't change per request, so
127
+ their scope is a **rule the schema derives automatically**. An agent's reach
128
+ depends on *what it's working on*, which is only knowable at dispatch so you
129
+ pass its `syncGroups` **at the call site, in code**. The schema's only job for
130
+ entities is to declare *that* a model is
131
+ entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
66
132
  it never declares *which* entities a given agent gets. (A human can opt into the
67
133
  same runtime narrowing — a page scoped to one deck — but by default a human's
68
134
  scope is fully schema-derived.)
@@ -83,62 +149,6 @@ The identity is a **participant** — and a participant is either a human
83
149
  [Agents are participants too](#agents-are-participants-too) below. Everything in
84
150
  the next two sections applies to both.
85
151
 
86
- ## Declare it, end to end
87
-
88
- The entire declaration surface is three things: `identityRoles` (who may see
89
- what), `syncGroupFormat` (which group a row fans out on), and an optional
90
- `syncGroups` prop (narrowing). Here they are in one runnable place — the sections
91
- 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, model, z } from '@abloatai/ablo/schema';
96
-
97
- export const schema = defineSchema(
98
- {
99
- conversations: model(
100
- { title: z.string(), createdBy: z.string() },
101
- {},
102
- { orgScoped: true, syncGroupFormat: 'conversation:{id}' },
103
- ),
104
- decks: model(
105
- { title: z.string(), status: z.enum(['draft', 'published']) },
106
- {},
107
- { orgScoped: true, syncGroupFormat: 'deck:{id}' },
108
- ),
109
- },
110
- {
111
- // Each role is pure data: a `template` and the identity `source` field to
112
- // read. No closures — so the schema stays JSON-serializable end to end.
113
- identityRoles: [
114
- identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
115
- identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
116
- identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
117
- ],
118
- },
119
- );
120
- ```
121
-
122
- ```tsx
123
- // 2. app/providers.tsx — a HUMAN gets their full org / team scope
124
- <AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
125
- {children}
126
- </AbloProvider>
127
- ```
128
-
129
- ```tsx
130
- // 3. an AGENT run inherits its user, narrowed to the entities in play
131
- <AbloProvider
132
- schema={schema}
133
- userId={user.id} // ceiling: the triggering user
134
- syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]} // floor: just its work
135
- >
136
- {children}
137
- </AbloProvider>
138
- ```
139
-
140
- That's the whole surface. The rest of this doc is the *why* behind each line.
141
-
142
152
  ## The two halves of scoping
143
153
 
144
154
  Scoping is two declarations that meet in the middle. One describes the
@@ -148,15 +158,15 @@ row's sync group is in the participant's allowed set.
148
158
 
149
159
  ### Half 1 — `identityRoles`: identity → allowed groups
150
160
 
151
- Declared once, on the schema, via the `identityRole({ kind, template, source })`
152
- factory. Each role is **pure data**: a `template` with a single `{id}`
153
- placeholder, and the `source` — the identity field to read. The engine reads
154
- `source` off the identity *you* supply and substitutes each value into the
155
- `template` to build the participant's allowed set. There is no hardcoded `org:` /
156
- `user:` anywhere in the engine — the templates and sources are entirely yours.
161
+ Declared once, on the schema, via the `identityRole({ kind, source })` factory.
162
+ Each role is **pure data**: a `kind` (the group's prefix `org`, `user`, `team`)
163
+ and the `source` — the identity field to read. The engine reads `source` off the
164
+ identity *you* supply and mints `<kind>:<value>` for each value, building the
165
+ participant's allowed set. There is no hardcoded `org:` / `user:` anywhere in the
166
+ engine — the kinds and sources are entirely yours.
157
167
 
158
168
  ```ts
159
- // src/ablo.schema.ts
169
+ // src/ablo/schema.ts
160
170
  import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
161
171
 
162
172
  export const schema = defineSchema(
@@ -168,10 +178,10 @@ export const schema = defineSchema(
168
178
  },
169
179
  {
170
180
  identityRoles: [
171
- identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
172
- identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
173
- // `multi: true` reads an array field — one team group per id.
174
- identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
181
+ identityRole({ kind: 'org', source: 'organizationId' }),
182
+ identityRole({ kind: 'user', source: 'userId' }),
183
+ // `multi: true` reads an array field — one `team:<id>` group per id.
184
+ identityRole({ kind: 'team', source: 'teamIds', multi: true }),
175
185
  ],
176
186
  },
177
187
  );
@@ -189,27 +199,66 @@ in-process and on a hosted server that only ever sees the compiled JSON.
189
199
 
190
200
  ### Half 2 — per-model scope: row → group
191
201
 
192
- On each model's options, you declare how its rows are tenanted and which
193
- sync-group label they fan out on.
202
+ You never write a sync-group string for a row. You declare a model's *place* in
203
+ the entity graph and the engine derives the groups its rows fan out on. Three
204
+ declarations, in order of how often you reach for them:
205
+
206
+ **`scope` — this model is a scope root.** Its rows form a group of their own.
207
+ The kind comes from the model's `typename` by default, or pass a string to set
208
+ it explicitly (use the string form when the wire kind differs from the typename,
209
+ e.g. typename `SlideDeck` but group `deck:<id>`):
210
+
211
+ ```ts
212
+ decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
213
+ // a deck row → group `deck:<id>`
214
+ ```
215
+
216
+ **`parent` — this row lives inside another entity.** Mark the `belongsTo` edge
217
+ to its owner; the row inherits that owner's group. This is the Zanzibar/ReBAC
218
+ *parent* relation — "access inherits from parent" — and it chains transitively
219
+ (a layer → its slide → its deck), so a write to any descendant reaches everyone
220
+ viewing the root. A *reference* (a provenance/template pointer, not ownership)
221
+ must **not** be marked `parent`, or the row would leak into an unrelated scope:
194
222
 
195
223
  ```ts
196
- model(
197
- { /* fields */ },
198
- { /* relations */ },
224
+ slides: model(
225
+ { deckId: z.string(), sourceSlideId: z.string().optional() },
199
226
  {
200
- // Rows carry organization_id; bootstrap + fan-out filter on it.
201
- orgScoped: true,
227
+ deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership inherit deck:<id>
228
+ sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
229
+ },
230
+ { orgScoped: true },
231
+ );
232
+ ```
233
+
234
+ > **Declare the parent edge — don't infer it.** Optionality is not a proxy for
235
+ > ownership: many `parent` FKs are optional (a root folder, an inbox task), and
236
+ > some required FKs are mere references. Containment is a fact only you know, so
237
+ > it's declared, exactly as it is in OpenFGA/Zanzibar.
202
238
 
203
- // Per-entity anchor. Lets a session narrow into ONE row's scope,
204
- // e.g. open a single deck: syncGroupFormat.replace('{id}', deckId).
205
- syncGroupFormat: 'deck:{id}',
239
+ **`grants` a membership edge.** On a join model (e.g. `dataroomMember`), it
240
+ says "this row grants a *subject* access to a *scope root*." Both are relation
241
+ names on the model. The server resolves it at connect time — for user `U`, it
242
+ finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
243
+ set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
244
+ org membership is already covered by the `org:` identity role.
245
+
246
+ ```ts
247
+ dataroomMember: model(
248
+ { userId: z.string(), dataroomId: z.string() },
249
+ {
250
+ member: relation.belongsTo('users', 'userId'),
251
+ room: relation.belongsTo('datarooms', 'dataroomId'),
206
252
  },
253
+ { orgScoped: true, grants: { subject: 'member', scope: 'room' } },
207
254
  );
208
255
  ```
209
256
 
210
- For rows that don't carry `organization_id` directly but inherit tenancy
211
- through a foreign key, use `scopedVia` rather than `orgScoped: false` — the
212
- latter exposes the whole table cross-tenant. See
257
+ For the rare group keyed on a plain field rather than a relation (per-recipient
258
+ inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
259
+ escape hatch. For rows that inherit *tenancy* (not a sync group) through a
260
+ foreign key without carrying `organization_id`, use `scopedVia` rather than
261
+ `orgScoped: false` — the latter exposes the whole table cross-tenant. See
213
262
  `packages/sync-engine/src/schema/model.ts` for the full option set.
214
263
 
215
264
  ## How identity reaches Ablo — the proxy model
@@ -255,7 +304,7 @@ resolve the user in a Server Component and pass it down:
255
304
  ```tsx
256
305
  // app/providers.tsx — 'use client'
257
306
  import { AbloProvider } from '@abloatai/ablo/react';
258
- import { schema } from '@/ablo.schema';
307
+ import { schema } from '@/ablo/schema';
259
308
 
260
309
  export function Providers({
261
310
  children,
@@ -311,27 +360,29 @@ agent authority = (triggering user's allowed set) ← ceiling, inherited (on-
311
360
  ∩ (the model instances it touches) ← floor, least privilege per run
312
361
  ```
313
362
 
314
- Mechanically, this is the per-model anchor from
315
- [Half 2](#half-2--per-model-scope-row--group) doing the work. Declare an entity
316
- anchor on the models an agent operates on:
363
+ Concretely: each model an agent edits declares a `scope`
364
+ ([Half 2](#half-2--per-model-scope-row--group)), so each row forms its own
365
+ group. The agent subscribes only to the groups for the rows it touches. Declare
366
+ an entity anchor on the models an agent operates on:
317
367
 
318
368
  ```ts
319
- // a conversation and the deck an agent edits each get a per-entity group
320
- conversations: model({ /* … */ }, {}, { syncGroupFormat: 'conversation:{id}' }),
321
- decks: model({ /* … */ }, {}, { syncGroupFormat: 'deck:{id}' }),
369
+ // each scope-root model an agent edits forms a per-entity group
370
+ documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
371
+ decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
322
372
  ```
323
373
 
324
374
  Then a run subscribes only to the entity groups for the rows it works on — a
325
375
  subset of what its user could see:
326
376
 
327
377
  ```tsx
328
- // agent run triggered by `user`, working on one conversation + one deck
378
+ // agent run triggered by `user`, working on one document + one deck
329
379
  <AbloProvider
330
380
  schema={schema}
331
381
  // identity inherited from the triggering user (the ceiling)
332
382
  userId={user.id}
333
- // authority narrowed to just the entities in play (the floor)
334
- syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]}
383
+ // authority narrowed to just the entities in play (the floor).
384
+ // Model form — keyed by model, resolved to groups via each model's `scope`.
385
+ scope={{ documents: documentId, decks: deckId }}
335
386
  >
336
387
  ```
337
388
 
@@ -348,8 +399,8 @@ user it ran on behalf of, so audit answers "who did this, and on whose behalf."
348
399
  It never appears in an `identityRole`, because it changes *who's accountable*,
349
400
  not *what's reachable*.
350
401
 
351
- This is deliberately the shape the 2025–2026 agent-identity consensus converged
352
- on, expressed in Ablo's primitives rather than a bolted-on agent ACL:
402
+ Three rules make agent access safe, and they fall out of the model above rather
403
+ than needing a separate agent permission system:
353
404
 
354
405
  - **Inherit the user, and no more** — the OAuth
355
406
  [on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
@@ -368,24 +419,50 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
368
419
  `claim` loop is, to the scoping layer, that same participant — scoped to the row
369
420
  it claimed.
370
421
 
371
- ## Narrowing to a single entity
422
+ ## Narrowing to specific entities — the `scope` prop
372
423
 
373
- For a page that should only sync one record, combine a per-entity
374
- `syncGroupFormat` (Half 2) with the `syncGroups` prop:
424
+ A human gets their full membership automatically (`identityRoles`). To narrow a
425
+ session a page on one deck, or an agent pointed at the entities it's working
426
+ on — pass `scope`. You give it the **model and id(s)**; the engine builds the
427
+ group string from the model's `scope` (Half 2), so you never hand-write
428
+ `deck:<id>`.
375
429
 
376
- ```ts
377
- // schema: decks fan out on deck:{id}
378
- syncGroupFormat: 'deck:{id}'
379
- ```
430
+ `scope` accepts four shapes, all resolved through the schema:
431
+
432
+ | You pass | Resolves to | Use it for |
433
+ | --- | --- | --- |
434
+ | `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
435
+ | `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
436
+ | `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
437
+ | `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
380
438
 
381
439
  ```tsx
382
- // page provider: subscribe to just this deck (still inside what auth allows)
383
- <AbloProvider schema={schema} userId={user.id} syncGroups={[`deck:${deckId}`]}>
440
+ // a page on one deck
441
+ <AbloProvider schema={schema} userId={user.id} scope={{ decks: deckId }} />
442
+
443
+ // an agent working across two decks and a document
444
+ <AbloProvider
445
+ schema={schema}
446
+ userId={user.id}
447
+ scope={{ decks: [deckA, deckB], documents: docId }}
448
+ />
384
449
  ```
385
450
 
386
- The participant now receives deltas for that one deck instead of the whole org
387
- smaller bootstrap, less fan-out without weakening the server-enforced
388
- boundary.
451
+ The key is the **model** (`decks`), the value is **which id(s)** the `deck:`
452
+ prefix comes from that model's `scope: 'deck'`, never from a string you compose.
453
+
454
+ > **`scope` means one thing: sync-group scope.** It appears in two places that
455
+ > are the same concept — the model option `scope: 'deck'` (declares a scope root,
456
+ > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` prop (subscribe
457
+ > to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
458
+ > axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
459
+ > open/closed/all), precisely so it doesn't share the word.
460
+
461
+ > **`scope` requests, it never grants.** At connect, the server intersects your
462
+ > requested groups with what the identity is actually allowed (`requested ∩
463
+ > allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
464
+ > agent can't reach a deck its capability doesn't already permit, no matter what
465
+ > it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
389
466
 
390
467
  ## How this compares — and the best practices it follows
391
468
 
@@ -426,13 +503,13 @@ The best practices Ablo inherits from that lineage:
426
503
  the line precisely: [token parameters are trusted and usable for access
427
504
  control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
428
505
  In Ablo terms, the identity your server vouches for is the *trusted* claim that
429
- sets scope; the provider's `userId` / `syncGroups` props are *untrusted client
506
+ sets scope; the provider's `userId` / `scope` props are *untrusted client
430
507
  input* — convenient for app-owned fields and narrowing, but never the boundary.
431
508
  This is why changing `userId` in the browser grants nothing.
432
509
 
433
- 3. **Scope by a hierarchical naming convention, declared once.** Ablo's
434
- `identityRoles` templates (`org:{id}`, `team:{id}`, `deck:{id}`) are the same
435
- idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
510
+ 3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
511
+ group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
512
+ `scope`) is the same idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
436
513
  (`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities).
437
514
  Declaring the convention in one place — never composing scope strings in
438
515
  consumer code — is the practice all three enforce.
package/docs/index.md CHANGED
@@ -1,36 +1,47 @@
1
1
  # Ablo Docs
2
2
 
3
- Ablo is a state control API for **humans and AI agents editing the same
4
- typed state in real time, with attribution, conflict handling, and
5
- fast cutoff**.
3
+ You have a database, and you want an AI agent to edit the same rows your
4
+ users are editing without the two clobbering each other's work. Ablo gives
5
+ the agent a narrow, audited write path: you declare your models, then everyone
6
+ (React components, server actions, and agents) calls the same
7
+ `ablo.deck.update(...)`. Ablo streams confirmed changes to everyone live and
8
+ rejects any write based on stale data.
6
9
 
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.
10
+ The flow is four steps: declare your models, read the current rows, claim the
11
+ row you're about to change, then write — and the write is rejected if someone
12
+ changed the row first.
8
13
 
9
- Multiplayer is not a separate product mode. If humans, server actions, and agents
10
- use the same `Ablo({ schema, apiKey })` client and write through
11
- `ablo.<model>`, Ablo fans out confirmed deltas, exposes active claims, and
12
- rejects stale writes for every participant.
14
+ ```ts
15
+ // The same call, whether a person, a server action, or an agent makes it.
16
+ await ablo.deck.update(deckId, { title: "Q3 Strategy" });
17
+ ```
13
18
 
14
- ## What you get, in three commitments
19
+ Claims don't lock. If another writer holds the row, `claim` waits for them,
20
+ re-reads the fresh row, then hands it to you — so two writers serialize
21
+ instead of clobbering.
15
22
 
16
- These commitments drive every design choice in the rest of the docs; if
17
- they don't match what you're building, the trade-offs land elsewhere
18
- (Replicache, ElectricSQL, PowerSync for human-only real-time; Zero for
19
- query-shaped sync).
23
+ ## What you get
24
+
25
+ Three things stay true no matter how you use Ablo:
20
26
 
21
27
  - **One model API for every actor.** `ablo.<model>.update(...)` is the
22
28
  call from React components, server actions, background workers, and
23
29
  AI agents alike. No separate "agent SDK," no parallel mutation path.
24
30
  Attribution comes from the credential, not the call site.
25
- - **Server owns the scope convention.** Tenancy / per-entity scope
31
+ - **You declare tenancy scopes once.** Tenancy / per-entity scope
26
32
  prefixes (`org:`, `deck:`, or your own `region:` / `customer:`) are
27
- declared once on the schema's `identityRoles`. Consumer code never
28
- composes group strings. Same boundary Liveblocks (`prepareSession`),
29
- PowerSync (named streams), and Zero (synced queries) settled on.
33
+ declared once on the schema's `identityRoles`, so application code
34
+ never builds an `org:123` string by hand — which keeps tenant
35
+ boundaries from leaking.
36
+ - **Stale writes are rejected.** If the row changed after you read it,
37
+ your write is turned away instead of silently overwriting the change
38
+ you didn't see.
30
39
 
31
40
  ## Start here
32
41
 
33
42
  - [Quickstart](./quickstart.md) — Make your first schema-backed write.
43
+ - [Schema Contract](./schema-contract.md) — One schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
44
+ - [CLI & Migrations](./cli.md) — `init` / `migrate` / `push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
34
45
  - [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
35
46
  - [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
36
47
  - [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
@@ -38,6 +49,7 @@ query-shaped sync).
38
49
  - [API Reference](./api.md) — Model-by-model method shape.
39
50
  - [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
40
51
  - [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
52
+ - [React](./react.md) — Provider, hooks, and reactive reads for React apps.
41
53
  - [API Keys](./api-keys.md) — Bearer tokens for the public API.
42
54
 
43
55
  ## API shape
@@ -58,19 +70,23 @@ query-shaped sync).
58
70
 
59
71
  ## Concepts
60
72
 
73
+ - [Schema Contract](./schema-contract.md) — What the schema drives across SDK, React, agents, Data Source, and migrations.
61
74
  - [Model Methods](./api.md#model-methods) — Load and write typed state.
62
75
  - [Integration Guide](./integration-guide.md) — The normal app path and optional pieces.
63
76
  - [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
64
- - [Coordination](./coordination.md) — `claim`, `claimState`, and `queue` for active work.
77
+ - [Coordination](./coordination.md) — `claim`, `claim.state`, and `claim.queue` for active work.
65
78
  - [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
66
79
  - [Receipt](./api.md#receipt) — Confirm what landed.
67
80
  - [Usage](./api.md#usage) — Metering and audit dimensions.
81
+ - [Audit Log](./audit.md) — Trace any confirmed write back to the human behind it.
82
+ - [MCP](./mcp.md) — Expose Ablo models to MCP clients (Claude, Cursor).
68
83
 
69
84
  ## Examples
70
85
 
71
86
  - [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
72
87
  - [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
73
88
  - [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
89
+ - [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
74
90
  - [Server Agent](./examples/server-agent.md) — Schema-backed worker.
75
91
  - [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
76
92
 
@@ -83,3 +99,4 @@ query-shaped sync).
83
99
  - [README](../README.md) — product overview and first example.
84
100
  - [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
85
101
  - [Changelog](../CHANGELOG.md) — what shipped recently.
102
+ - [Roadmap](./roadmap.md) — what's planned next.