@abloatai/ablo 0.11.1 → 0.12.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 (85) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -2
  3. package/dist/Model.d.ts +39 -0
  4. package/dist/Model.js +68 -0
  5. package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
  6. package/dist/ai-sdk/claim-broadcast.js +2 -2
  7. package/dist/ai-sdk/wrap.d.ts +5 -4
  8. package/dist/ai-sdk/wrap.js +3 -3
  9. package/dist/auth/credentialPolicy.d.ts +145 -0
  10. package/dist/auth/credentialPolicy.js +130 -0
  11. package/dist/cli.cjs +42 -7
  12. package/dist/client/Ablo.d.ts +64 -91
  13. package/dist/client/Ablo.js +43 -103
  14. package/dist/client/ApiClient.d.ts +10 -1
  15. package/dist/client/ApiClient.js +45 -22
  16. package/dist/client/auth.d.ts +12 -5
  17. package/dist/client/auth.js +2 -1
  18. package/dist/client/createModelProxy.d.ts +64 -17
  19. package/dist/client/createModelProxy.js +18 -12
  20. package/dist/client/httpClient.d.ts +17 -3
  21. package/dist/client/httpClient.js +1 -0
  22. package/dist/client/identity.js +134 -122
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/sessionMint.d.ts +15 -0
  25. package/dist/client/sessionMint.js +86 -0
  26. package/dist/coordination/schema.d.ts +1 -1
  27. package/dist/coordination/schema.js +3 -1
  28. package/dist/errorCodes.d.ts +2 -0
  29. package/dist/errorCodes.js +2 -0
  30. package/dist/errors.d.ts +6 -3
  31. package/dist/errors.js +9 -3
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.js +4 -7
  34. package/dist/mutators/RecordingTransaction.js +14 -42
  35. package/dist/react/AbloProvider.d.ts +12 -13
  36. package/dist/react/AbloProvider.js +10 -10
  37. package/dist/react/context.d.ts +10 -45
  38. package/dist/react/context.js +12 -17
  39. package/dist/react/index.d.ts +8 -10
  40. package/dist/react/index.js +8 -11
  41. package/dist/react/useMutators.js +3 -2
  42. package/dist/react/useSyncStatus.d.ts +1 -1
  43. package/dist/react/useUndoScope.js +3 -2
  44. package/dist/realtime/index.d.ts +1 -1
  45. package/dist/schema/generate.js +1 -2
  46. package/dist/schema/model.d.ts +10 -3
  47. package/dist/schema/schema.d.ts +13 -2
  48. package/dist/schema/schema.js +26 -0
  49. package/dist/surface.d.ts +29 -0
  50. package/dist/surface.js +60 -0
  51. package/dist/sync/ConnectionManager.d.ts +16 -5
  52. package/dist/sync/ConnectionManager.js +42 -7
  53. package/dist/sync/createClaimStream.js +5 -4
  54. package/dist/sync/participants.js +1 -1
  55. package/dist/transactions/TransactionQueue.d.ts +0 -11
  56. package/dist/transactions/TransactionQueue.js +12 -56
  57. package/dist/types/global.d.ts +3 -0
  58. package/dist/types/streams.d.ts +17 -29
  59. package/dist/utils/mobx-setup.js +1 -0
  60. package/docs/api-keys.md +49 -0
  61. package/docs/api.md +3 -2
  62. package/docs/client-behavior.md +1 -0
  63. package/docs/coordination.md +75 -21
  64. package/docs/examples/existing-python-backend.md +9 -5
  65. package/docs/examples/scoped-agent.md +1 -1
  66. package/docs/guarantees.md +4 -3
  67. package/docs/identity.md +89 -82
  68. package/docs/integration-guide.md +19 -10
  69. package/docs/migration.md +11 -3
  70. package/docs/quickstart.md +6 -2
  71. package/docs/react.md +3 -3
  72. package/docs/schema-contract.md +23 -5
  73. package/llms-full.txt +18 -16
  74. package/llms.txt +6 -6
  75. package/package.json +1 -1
  76. package/dist/api/index.d.ts +0 -10
  77. package/dist/api/index.js +0 -9
  78. package/dist/principal.d.ts +0 -44
  79. package/dist/principal.js +0 -49
  80. package/dist/react/SyncGroupProvider.d.ts +0 -19
  81. package/dist/react/SyncGroupProvider.js +0 -44
  82. package/dist/react/useClaim.d.ts +0 -29
  83. package/dist/react/useClaim.js +0 -42
  84. package/dist/react/usePresence.d.ts +0 -32
  85. package/dist/react/usePresence.js +0 -41
@@ -57,28 +57,6 @@ export interface AgentDelta {
57
57
  causedByTaskId?: string | null;
58
58
  createdAt: string;
59
59
  }
60
- /**
61
- * A reference to whoever's authority bounds a joined participant.
62
- * The spawned participant can never see or do more than this principal.
63
- * Enforced server-side: the spawned agent gets its own restricted
64
- * (`rk_`) key whose scope is a subset of the parent's.
65
- *
66
- * • `SessionRef` — human is joining an agent (chat assistant flow)
67
- * • `AgentRef` — agent spawning a sub-agent (attenuation chain)
68
- * • omitted — the API key on the Ablo client is the ceiling
69
- */
70
- export type Principal = SessionRef | AgentRef;
71
- export interface SessionRef {
72
- readonly kind: 'session';
73
- readonly id: string;
74
- readonly userId: string;
75
- readonly organizationId: string;
76
- }
77
- export interface AgentRef {
78
- readonly kind: 'agent';
79
- readonly id: string;
80
- readonly capabilityToken: string;
81
- }
82
60
  /**
83
61
  * Flat snapshot view returned from `participant.snapshot(...)`.
84
62
  *
@@ -382,7 +360,7 @@ export interface ClaimStream {
382
360
  /**
383
361
  * Reactive view of the wait queue on one target — the FIFO line of
384
362
  * `status: 'queued'` claims behind the current holder, each with its
385
- * `action`, `heldBy`, and `position`. Synced from the server's per-entity
363
+ * `reason`, `heldBy`, and `position`. Synced from the server's per-entity
386
364
  * `claim_queue` frame; empty when no one's waiting. Pair with
387
365
  * `subscribe(...)` for change notifications.
388
366
  */
@@ -479,10 +457,12 @@ export interface ClaimDeclaration {
479
457
  /** Human-readable reason — "rewriting title" / "restyling chart". */
480
458
  readonly reason: string;
481
459
  /**
482
- * Expiry auto-revoke if the participant doesn't finish in time.
483
- * Number = seconds (back-compat); string = duration (`'3m'`).
460
+ * Seconds remaining until the server auto-expires this claim. An OUTPUT
461
+ * field carrying a concrete countdown, so it's a plain `number` — distinct
462
+ * from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
463
+ * from `expiresAt - now`.
484
464
  */
485
- readonly ttlSeconds?: Duration;
465
+ readonly ttlSeconds?: number;
486
466
  }
487
467
  /**
488
468
  * Handle returned from `announce(...)` / `analyzing(...)` / etc.
@@ -529,7 +509,13 @@ export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposabl
529
509
  readonly range?: TargetRange;
530
510
  readonly meta?: Record<string, unknown>;
531
511
  };
532
- readonly action: string;
512
+ /**
513
+ * The human-readable phase this claim represents — `'editing'`, `'writing'`,
514
+ * `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
515
+ * distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
516
+ * to `'editing'`. Serialized on the wire as `action`.
517
+ */
518
+ readonly reason: string;
533
519
  readonly description?: string;
534
520
  /** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
535
521
  readonly data?: T;
@@ -587,8 +573,10 @@ export interface Claim {
587
573
  readonly status: ClaimStatus;
588
574
  /** What is being coordinated. */
589
575
  readonly target: EntityRef;
590
- /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. */
591
- readonly action: string;
576
+ /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
577
+ * field on every claim surface; distinct from the CRUD operation. Serialized
578
+ * on the wire as `action`. */
579
+ readonly reason: string;
592
580
  /** Peer-visible explanation of the work being performed. */
593
581
  readonly description?: string;
594
582
  /** Participant holding it. */
@@ -145,6 +145,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
145
145
  'propertyChanged',
146
146
  'markAsPersisted',
147
147
  'clearChanges',
148
+ 'consumeModifiedFields',
148
149
  'updateFromData',
149
150
  'applyChanges',
150
151
  ];
package/docs/api-keys.md CHANGED
@@ -12,6 +12,55 @@ The key identifies the Ablo account. Application code does not pass an organizat
12
12
 
13
13
  "Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently — they never carry a secret key.
14
14
 
15
+ ## Which credential to use
16
+
17
+ There's **one field — `apiKey`** — and what you pass depends on **where the code runs**.
18
+ Pick your row:
19
+
20
+ | Where your code runs | What to pass | Example |
21
+ |---|---|---|
22
+ | **Server / worker / CLI** (can hold a secret) | your secret `sk_` — it defaults to `ABLO_API_KEY`, so usually pass **nothing** | `Ablo({ schema })` |
23
+ | **Browser — read-only** | a publishable `pk_` (safe to ship, like a Stripe `pk_`) | `Ablo({ schema, apiKey: process.env.NEXT_PUBLIC_ABLO_PUBLISHABLE_KEY })` |
24
+ | **Browser — writing as the signed-in user** | a function that fetches a short-lived per-user token from your own backend | `Ablo({ schema, apiKey: () => fetch('/api/ablo-session').then((r) => r.text()) })` |
25
+
26
+ That's the whole story: one knob, filled by audience.
27
+
28
+ **Coming from Stripe? It's the same key model, same prefixes:**
29
+
30
+ | Stripe | Ablo | Where it goes |
31
+ |---|---|---|
32
+ | publishable `pk_` (client-safe) | `pk_` | browser — read-only |
33
+ | secret `sk_` (server, full) | `sk_` | server — full authority |
34
+ | restricted `rk_` (granular) | `rk_` | scoped agent sessions (`sessions.create({ agent, can })`) |
35
+ | ephemeral key (client, customer-scoped) | `ek_` | per-user browser sessions (`sessions.create({ user })`) |
36
+
37
+ Mode lives in the prefix too — `sk_test_` / `sk_live_` — exactly like Stripe. The
38
+ `apiKey` resolver fetching an `ek_` is Ablo's ephemeral-key flow: server mints, client holds.
39
+
40
+ **Why a function for browser writes?** Anything you ship to a browser must be public, and a
41
+ public `pk_` is **read-only** — it can't carry one specific user's write authority. So when
42
+ the browser writes *as the logged-in user*, your backend (which holds the secret `sk_` and
43
+ knows who's signed in) mints a short-lived per-user token with `sessions.create({ user })`,
44
+ and the browser's `apiKey` function fetches it. You don't manage refresh — the SDK calls the
45
+ function once before connecting and then keeps the token fresh (re-mint before expiry, and on
46
+ tab-focus / network-online / device-wake). This is the Stripe ephemeral-key / Supabase
47
+ session model. For a read-only app you don't need any of this — just the `pk_` above.
48
+
49
+ Server-side, because `apiKey` defaults to `process.env.ABLO_API_KEY`, most backend and agent
50
+ code passes nothing. The secret `sk_` (and `databaseUrl`) are **server-only** — never in a
51
+ browser bundle. There is no `getToken`, `authEndpoint`, or `as` option — `apiKey` (a string,
52
+ or a function for the browser-write case) is the single credential knob.
53
+
54
+ ### Minting per-user / agent tokens (server-side, with your `sk_`)
55
+
56
+ | Mint | Call | Result |
57
+ |---|---|---|
58
+ | Human end-user session | `await server.sessions.create({ user: { id } })` | `ek_` (full user authority) |
59
+ | Scoped delegated agent | `await server.sessions.create({ agent: { id }, can: { Task: ['update'] } })` | `rk_` (scoped to `can`) |
60
+ | Connect Ablo to your own Postgres | `Ablo({ schema, apiKey, databaseUrl })` (server-only) | dedicated tenant |
61
+
62
+ The principal kind comes from *which* shape you pass — `{ user }` → `user`, `{ agent, can }` → `agent`.
63
+
15
64
  ## Server-Side API Keys
16
65
 
17
66
  Use API keys from trusted (server-side) runtimes:
package/docs/api.md CHANGED
@@ -167,8 +167,9 @@ side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if s
167
167
  already holds the row, `claim` waits for them to finish, re-reads the fresh row,
168
168
  then hands it to you, so you always proceed from current state. Default reads
169
169
  return the row even while someone is mid-edit; if a server read should not
170
- return a row while it's claimed, pass `ifClaimed: 'wait'` to wait for the claim
171
- to clear, or `ifClaimed: 'fail'` to error out instead.
170
+ return a row while it's claimed, pass `ifClaimed: 'fail'` to error out instead.
171
+ Reads never block on a claim — to wait for a row to free up, `claim({ id })` it
172
+ (the claim queues fairly behind the holder).
172
173
 
173
174
  ```ts
174
175
  const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
@@ -32,6 +32,7 @@ Common options:
32
32
  | `databaseUrl` | Optional, server-only. Registers your Postgres directly (the connection-string path). Pass it explicitly — it is **not** auto-read from the environment. Omit it for a signed Data Source endpoint or the hosted sandbox. The SDK throws if it sees this in a browser. |
33
33
  | `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
34
34
  | `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
35
+ | `transport` | `'websocket'` (default) is the live, stateful client — a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (`get`/`getAll`, `onChange`, `watch`) are compile errors rather than runtime gaps. |
35
36
  | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
36
37
  | `defaultHeaders` | Extra headers attached to every HTTP request. |
37
38
  | `defaultQuery` | Extra query parameters attached to every HTTP request. |
@@ -1,9 +1,10 @@
1
1
  # Coordination Reference
2
2
 
3
3
  Coordinate long-running work on a row so humans and agents don't clobber each
4
- other. Most writes need none of this — `ablo.<model>.update({ id, data })` is optimistic
5
- and the server rejects it if the row moved. Reach for `claim` only when you'll
6
- **hold a row across a slow gap** (read LLM call write).
4
+ other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
5
+ is **last-write-wins** by default. For lost-update detection, take a claim or pass
6
+ `readAt` / `onStale` yourself. Reach for `claim` only when you'll **hold a row
7
+ across a slow gap** (read → LLM call → write).
7
8
 
8
9
  Claims don't lock. If another writer holds the row, `claim` waits for them,
9
10
  re-reads the fresh row, then hands it to you — so two writers serialize instead
@@ -36,10 +37,12 @@ make:
36
37
  **The one decision: do you hold the row across a slow gap (read → LLM call →
37
38
  write)?**
38
39
 
39
- - **No** (the common case — a single quick `update`): do nothing. `ablo.<model>.update`
40
- is optimistically guarded by stale-context already; it rejects with
41
- `AbloStaleContextError` if the row moved under you. This is the default and
42
- needs no ceremony.
40
+ - **No** (the common case — a single quick `update`): a plain `ablo.<model>.update`
41
+ is **last-write-wins** it carries no `readAt`, so the server skips the stale
42
+ check and the write simply lands. That's fine for most fields. If you need
43
+ lost-update detection on a no-claim write, pass `readAt` + `onStale: 'reject'`
44
+ yourself and it rejects with `AbloStaleContextError` when the row moved under
45
+ you.
43
46
  - **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
44
47
  excludes other participants for the duration, queues contenders fairly, and —
45
48
  see below — your own writes under it stay stale-guarded too.
@@ -48,12 +51,13 @@ write)?**
48
51
  non-holder writing to a claimed row is rejected (`AbloClaimedError`) regardless of
49
52
  `readAt`. If you do hold it, your own writes are still stale-checked — a row that
50
53
  moved between your snapshot and your write still rejects with
51
- `AbloStaleContextError`. With no claim held, the stale check is the only
52
- protection, and it's automatic, which is why the no-claim path is safe by default.
53
- Presence (`claim.state`) never decides anything — read it to render, act on the
54
- errors. The two checks are independent: one rejects writes from people who don't
55
- hold the claim, the other rejects writes based on a stale snapshot, and the SDK
56
- adds the stale-check for you when you write under a claim, so you don't pass
54
+ `AbloStaleContextError`. With no claim held and no `readAt`, there is **no**
55
+ stale protection the plain write is last-write-wins; opt into lost-update
56
+ detection by passing `readAt` + `onStale` yourself. Presence (`claim.state`)
57
+ never decides anything read it to render, act on the errors. The two checks are
58
+ independent: one rejects writes from people who don't hold the claim, the other
59
+ rejects writes based on a stale snapshot, and the SDK adds the stale-check for you
60
+ when you write under a claim **you took on this client**, so there you don't pass
57
61
  anything extra.
58
62
 
59
63
  ---
@@ -92,6 +96,17 @@ a model row. It's what `claim.state()` returns and what observers render.
92
96
 
93
97
  ## Methods
94
98
 
99
+ One word — "claim" — names four distinct things; keep them separate as you read:
100
+
101
+ - **the lease (claim handle)** — the *object* returned by `ablo.<model>.claim({ id })`
102
+ (`ClaimHandle`, an `AsyncDisposable` with `.data` and `.release()`).
103
+ - **acquiring a claim/lease** — the *verb* `ablo.<model>.claim({ id })`, the call
104
+ that takes the lease.
105
+ - **`claim.state` / `claim.queue`** — the *inspection namespace* hanging off the
106
+ model, for reading who holds the row and who's lined up.
107
+ - **the write's `claim` param** — `update({ id, data, claim })`, where you pass a
108
+ lease the proxy didn't take itself.
109
+
95
110
  Each method below follows one fixed shape: **signature · what it does ·
96
111
  parameters · returns · example**.
97
112
 
@@ -149,15 +164,19 @@ held. Server/model reads can choose a claimed policy:
149
164
  ```ts
150
165
  await ablo.weatherReports.retrieve({
151
166
  id: 'report_stockholm',
152
- ifClaimed: 'wait',
153
- claimedTimeout: 30_000,
167
+ ifClaimed: 'fail',
154
168
  });
155
169
  ```
156
170
 
157
- - `ifClaimed: 'return'` reads now and includes active work metadata.
158
- - `ifClaimed: 'wait'` waits for the active claim to clear before reading.
171
+ - `ifClaimed: 'return'` (the default) reads now and includes active work metadata.
159
172
  - `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
160
173
 
174
+ Reads never block on a claim — there is no `ifClaimed: 'wait'`. Waiting for a row
175
+ to free up is a **claim-side** concern: take `ablo.<model>.claim({ id })` (it
176
+ queues fairly behind the current holder and re-reads the fresh row once it's
177
+ yours). Use `ifClaimed: 'fail'` when a read should simply refuse to proceed
178
+ against a row someone else is mid-editing.
179
+
161
180
  ### `claim.state`
162
181
 
163
182
  ```ts
@@ -272,19 +291,54 @@ try {
272
291
  }
273
292
  ```
274
293
 
294
+ ### `watch` — presence for a set of rows
295
+
296
+ Reading or claiming a row auto-enrolls you in its sync group, which is enough for
297
+ `claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
298
+ presence on a known set of rows — a deck's slides, a board's cards — and react to
299
+ who joins or leaves, use `watch`:
300
+
301
+ ```ts
302
+ await using room = await ablo.slides.watch(slideIds, { ttl: '5m' });
303
+ room.peers; // who else is here, live
304
+ ```
305
+
306
+ `watch(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
307
+ a participant handle (`.peers`, the scoped claim stream, `.leave()` / `await using`
308
+ disposal). It is the model-scoped successor to the old top-level
309
+ `ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
310
+ socket, so `watch` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
311
+ throws on any non-ws construction.
312
+
275
313
  ### Writing under a claim
276
314
 
277
315
  There is no separate "write" method on a claim — use the normal
278
- `ablo.<model>.update({ id, data })`. While you hold a claim on `id`, that `update` is
279
- automatically stale-guarded against the snapshot the claim took (`readAt` =
280
- snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
281
- it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
316
+ `ablo.<model>.update({ id, data })`. The auto-guarding holds **only when this same
317
+ client took the claim** via `ablo.<model>.claim({ id })` (the proxy remembers the
318
+ lease in-process): that `update` is then stale-guarded against the snapshot the
319
+ claim took (`readAt` = snapshot watermark, `onStale: 'reject'`) and attributed to
320
+ the claim's lease, so it rejects with [`AbloStaleContextError`](#errors) if the
321
+ row changed under you.
282
322
 
283
323
  ```ts
284
324
  await using claim = await ablo.weatherReports.claim({ id });
285
325
  await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
286
326
  ```
287
327
 
328
+ A claim handle minted by **another client** (or returned over HTTP) is not known
329
+ to this proxy, so a plain `update` won't pick it up. Pass it explicitly:
330
+
331
+ ```ts
332
+ await ablo.weatherReports.update({ id, data: { status: 'ready' }, claim: handle });
333
+ ```
334
+
335
+ **Self-stale on a second write.** The claim's watermark is fixed at claim time
336
+ and is **not** re-baselined as you write. So a *second* `update` under one held
337
+ claim is stale-checked against the snapshot the claim took — which your *first*
338
+ write already moved past — and rejects with `AbloStaleContextError` against your
339
+ own earlier write. Re-read (and re-claim) between writes if you need to write the
340
+ same row more than once under one claim.
341
+
288
342
  Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
289
343
  participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
290
344
  'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
@@ -57,8 +57,9 @@ export const ablo = Ablo({
57
57
  ```
58
58
 
59
59
  Mount the React provider near the app root. Build the browser client first —
60
- with an `authEndpoint` so it mints a short-lived session token instead of
61
- carrying the secret key then pass it to the provider via `client`.
60
+ with an `apiKey` resolver (an async `() => Promise<string | null>`) that fetches
61
+ the short-lived session token your backend minted, instead of carrying the
62
+ secret key — then pass it to the provider via `client`.
62
63
 
63
64
  ```tsx
64
65
  // web/app/providers.tsx
@@ -68,9 +69,12 @@ import Ablo from '@abloatai/ablo';
68
69
  import { AbloProvider } from '@abloatai/ablo/react';
69
70
  import { schema } from '@/ablo/schema';
70
71
 
71
- // Browser client: no secret key — `authEndpoint` mints the session token
72
- // server-side (see the session route below).
73
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
72
+ // Browser client: no secret key — the `apiKey` resolver fetches the session
73
+ // token your server route mints (see the session route below).
74
+ const ablo = Ablo({
75
+ schema,
76
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
77
+ });
74
78
 
75
79
  export function Providers({ children }: { children: React.ReactNode }) {
76
80
  return <AbloProvider client={ablo}>{children}</AbloProvider>;
@@ -81,7 +81,7 @@ import { schema } from './schema';
81
81
 
82
82
  const ablo = Ablo({
83
83
  schema,
84
- getToken: async () => mintDeckAgentSession(deckId, agentId),
84
+ apiKey: async () => mintDeckAgentSession(deckId, agentId),
85
85
  });
86
86
 
87
87
  // The agent run is mounted on behalf of its triggering user.
@@ -82,9 +82,10 @@ Claims are live coordination signals. They are not database locks.
82
82
  already holds the row, the claim waits for them to finish, then re-reads the row
83
83
  before handing it back, so you proceed from fresh state. Reads stay open while a
84
84
  claim is held — `ablo.<model>.claim.state({ id })` returns the current claim state
85
- (or `null`) without ever blocking. A server read can pass `ifClaimed: 'wait'` to
86
- wait for the claim to clear, or `ifClaimed: 'fail'` to error out, when it should
87
- not return a row while someone else is mid-edit.
85
+ (or `null`) without ever blocking. A server read can pass `ifClaimed: 'fail'` to
86
+ error out, when it should not return a row while someone else is mid-edit. Reads
87
+ never block on a claim — to wait for a row to free up, `claim({ id })` it (the
88
+ claim queues fairly behind the holder).
88
89
 
89
90
  A claim does not reject or block other writers; it announces work so peers
90
91
  serialize behind it rather than racing. While you hold a claim, the matching
package/docs/identity.md CHANGED
@@ -36,8 +36,8 @@ runnable place, so the concepts below have code to attach to.
36
36
  ## Declare it, end to end
37
37
 
38
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 `scope` setting on the client (narrowing). Read the three blocks first —
39
+ each model `scope` / `parent` / `grants` (which group a row fans out on), plus
40
+ optional `syncGroups` at session-mint time (narrowing). Read the three blocks first —
41
41
  a human gets their `org` / `team` scope, an agent gets one `deck` — then the
42
42
  sections after explain each.
43
43
 
@@ -84,18 +84,17 @@ export const schema = defineSchema(
84
84
 
85
85
  ```ts
86
86
  // 3. an AGENT run inherits its user, narrowed to the entities in play.
87
- // Pass the MODEL form — { decks: id } not a hand-built `deck:<id>` string;
88
- // the engine derives the group from each model's `scope`. The narrowing lives
89
- // on the client you build, then the provider mounts it.
90
- const ablo = Ablo({
91
- schema,
92
- authEndpoint: '/api/ablo-session',
93
- scope: { decks: deckId }, // floor: just the deck it's working on
87
+ // You narrow at SESSION-MINT time: your backend calls `sessions.create` with the
88
+ // agent's allowed `syncGroups`, built from each model's scope via the
89
+ // `syncGroup(kind, id)` helper never a hand-built `deck:<id>` string. The agent's
90
+ // runtime then connects with the minted token.
91
+ const session = await server.sessions.create({
92
+ agent: { id: agentId },
93
+ can: { Deck: ['read', 'update'] },
94
+ syncGroups: [syncGroup('deck', deckId)], // floor: just the deck it's working on
94
95
  });
95
-
96
- <AbloProvider client={ablo} userId={user.id /* ceiling: the triggering user */}>
97
- {children}
98
- </AbloProvider>;
96
+ // the agent runtime authenticates with the minted token
97
+ const ablo = Ablo({ schema, apiKey: session.token });
99
98
  ```
100
99
 
101
100
  That's the whole surface. The rest of this doc is the *why* behind each line.
@@ -127,11 +126,12 @@ so you pass them in code when you start the run.
127
126
  > **One line:** humans subscribe by who they are; agents subscribe by what
128
127
  > they've been given.
129
128
 
130
- That's why you never write per-user scope code, but you always pass an agent's
131
- scope at the call site. A user's org/team/user don't change per request, so
129
+ That's why you never write per-user scope code, but you always choose an agent's
130
+ groups at the dispatch site. A user's org/team/user don't change per request, so
132
131
  their scope is a **rule the schema derives automatically**. An agent's reach
133
132
  depends on *what it's working on*, which is only knowable at dispatch — so you
134
- set its `scope` on the client **at the dispatch site, in code**. The schema's
133
+ pass its `syncGroups` **when your backend mints the agent session**
134
+ (`sessions.create({ agent, can, syncGroups })`). The schema's
135
135
  only job for entities is to declare *that* a model is
136
136
  entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
137
137
  it never declares *which* entities a given agent gets. (A human can opt into the
@@ -305,8 +305,9 @@ server, never by the browser.**
305
305
 
306
306
  The identity your server resolved is carried by the client you build and the
307
307
  `userId` prop. In a Next.js app, resolve the user in a Server Component and pass
308
- it down. Build the client once (the schema, `teamIds`, and any `scope` narrowing
309
- live here), then hand it to the provider:
308
+ it down. Build the client once (the schema, `teamIds`, and the `apiKey` resolver
309
+ live here; entity narrowing rides the minted session's `syncGroups`), then hand
310
+ it to the provider:
310
311
 
311
312
  ```ts
312
313
  // lib/ablo.ts
@@ -318,7 +319,10 @@ import { schema } from '@/ablo/schema';
318
319
  export function makeAblo(user: { teamIds: string[] }) {
319
320
  return Ablo({
320
321
  schema,
321
- authEndpoint: '/api/ablo-session',
322
+ // The browser holds no secret — the `apiKey` resolver fetches the
323
+ // short-lived session token your `/api/ablo-session` route minted, and the
324
+ // client keeps it fresh before expiry.
325
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
322
326
  teamIds: user.teamIds,
323
327
  });
324
328
  }
@@ -354,7 +358,7 @@ What carries identity — and just as importantly, what does *not* set the bound
354
358
  | ------------ | ------------------------------------------------------------------------------------------------ |
355
359
  | `userId` prop | 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. |
356
360
  | `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. |
357
- | `scope` (on the client) | 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. `{ decks: 'abc123' }`). |
361
+ | `syncGroups` (at session mint) | Optional. **Narrows** a minted session's subscription to a subset of what auth already allows — it can never widen it. Passed to `sessions.create({ user \| agent, syncGroups })`; build entries with `syncGroup(kind, id)`. Use it to scope an agent (or a focused page's session) to one entity, e.g. `[syncGroup('deck', 'abc123')]`. |
358
362
 
359
363
  Because the server is the boundary, a client that changes `userId` to another
360
364
  user's id does not gain their data — the server resolves and enforces the real
@@ -398,20 +402,20 @@ subset of what its user could see:
398
402
 
399
403
  ```ts
400
404
  // agent run triggered by `user`, working on one document + one deck.
401
- // The narrowing lives on the client; the provider just mounts it.
402
- const ablo = Ablo({
403
- schema,
404
- authEndpoint: '/api/ablo-session',
405
- // authority narrowed to just the entities in play (the floor).
406
- // Model form — keyed by model, resolved to groups via each model's `scope`.
407
- scope: { documents: documentId, decks: deckId },
405
+ // Your backend mints the agent session narrowed to just the entities in play
406
+ // (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
407
+ const session = await server.sessions.create({
408
+ agent: { id: agentId },
409
+ can: { Document: ['read', 'update'], Deck: ['read', 'update'] },
410
+ syncGroups: [syncGroup('document', documentId), syncGroup('deck', deckId)],
408
411
  });
409
-
410
- // identity inherited from the triggering user (the ceiling)
411
- <AbloProvider client={ablo} userId={user.id}>
412
+ // identity (the ceiling) is inherited from the triggering user via your
413
+ // session-mint logic; the agent runtime connects with the minted token.
414
+ const ablo = Ablo({ schema, apiKey: session.token });
412
415
  ```
413
416
 
414
- As the run touches more entities its set **accretes** to cover them; it never
417
+ As the run touches more entities, claim or read them and the client auto-enrolls
418
+ in their entity groups — its set **accretes** to cover them; it never
415
419
  widens past the user's ceiling, and it carries no standing access to entities it
416
420
  isn't working on. The `identityRoles` need no agent-specific entry: the agent
417
421
  carries the triggering user's `userId`, so the same `user:{id}` role that scopes
@@ -444,52 +448,55 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
444
448
  `claim` loop is, to the scoping layer, that same participant — scoped to the row
445
449
  it claimed.
446
450
 
447
- ## Narrowing to specific entities — the `scope` setting
448
-
449
- A human gets their full membership automatically (`identityRoles`). To narrow a
450
- session — a page on one deck, or an agent pointed at the entities it's working
451
- on set `scope` on the client you build (`Ablo({ schema, scope })`). You give it
452
- the **model and id(s)**; the engine builds the group string from the model's
453
- `scope` (Half 2), so you never hand-write `deck:<id>`.
454
-
455
- `scope` accepts four shapes, all resolved through the schema:
456
-
457
- | You pass | Resolves to | Use it for |
458
- | --- | --- | --- |
459
- | `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
460
- | `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
461
- | `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
462
- | `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
463
-
464
- ```tsx
465
- // a page on one deck
466
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', scope: { decks: deckId } });
467
- <AbloProvider client={ablo} userId={user.id} />;
468
-
469
- // an agent working across two decks and a document
470
- const ablo = Ablo({
471
- schema,
472
- authEndpoint: '/api/ablo-session',
473
- scope: { decks: [deckA, deckB], documents: docId },
474
- });
475
- <AbloProvider client={ablo} userId={user.id} />;
476
- ```
477
-
478
- The key is the **model** (`decks`), the value is **which id(s)** the `deck:`
479
- prefix comes from that model's `scope: 'deck'`, never from a string you compose.
480
-
481
- > **`scope` means one thing: sync-group scope.** It appears in two places that
482
- > are the same concept — the model option `scope: 'deck'` (declares a scope root,
483
- > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` client setting
484
- > (subscribe to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
485
- > axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
486
- > open/closed/all), precisely so it doesn't share the word.
487
-
488
- > **`scope` requests, it never grants.** At connect, the server intersects your
489
- > requested groups with what the identity is actually allowed (`requested ∩
490
- > allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
491
- > agent can't reach a deck its capability doesn't already permit, no matter what
492
- > it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
451
+ ## Narrowing to specific entities
452
+
453
+ A human gets their full membership automatically (`identityRoles`). There are
454
+ three ways to narrow a participant to specific entities — a page on one deck, or
455
+ an agent pointed at the entities it's working on. You **never hand-write**
456
+ `deck:<id>`; build groups from the model's `scope` (Half 2) with the typed
457
+ `syncGroup(kind, id)` helper from `@abloatai/ablo/schema`.
458
+
459
+ 1. **At session mint — `syncGroups`.** When your backend mints a session, pass the
460
+ exact groups it may subscribe to. This is the floor for a delegated agent (and
461
+ the way to scope a focused page's session):
462
+
463
+ ```ts
464
+ // an agent working across two decks and a document
465
+ const session = await server.sessions.create({
466
+ agent: { id: agentId },
467
+ can: { Deck: ['read', 'update'], Document: ['read'] },
468
+ syncGroups: [
469
+ syncGroup('deck', deckA),
470
+ syncGroup('deck', deckB),
471
+ syncGroup('document', docId),
472
+ ],
473
+ });
474
+ const ablo = Ablo({ schema, apiKey: session.token });
475
+ ```
476
+
477
+ 2. **Automatically, on read or claim.** Reading a row (`retrieve`/`get`/
478
+ `claim.state`) auto-enrolls the client in that row's entity group
479
+ (**read-interest**), and `claim`-ing it pins a **write-intent** subscription.
480
+ So an agent's reachable set **accretes** as it works — no extra subscribe call.
481
+
482
+ 3. **Explicitly, for presence `watch`.** To hold presence on a known set of rows
483
+ and react to peers, use the WebSocket-only `ablo.<model>.watch(ids, { ttl })`
484
+ (it returns a participant handle with `.peers`). See
485
+ [Coordination](./coordination.md).
486
+
487
+ > **`scope` is the schema model option, not a client setting.** `scope: 'deck'`
488
+ > in `model(...)` declares a scope root ([Half 2](#half-2--per-model-scope-row--group))
489
+ > it names the group (`deck:<id>`) that the mechanisms above then subscribe to.
490
+ > There is no `Ablo({ scope })` constructor option. The lifecycle filter on
491
+ > [`list()`](./api.md#model-methods) is a separate axis named **`state`**
492
+ > (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it
493
+ > doesn't share the word.
494
+
495
+ > **Requested groups never grant.** At connect, the server intersects the session's
496
+ > `syncGroups` with what the identity is actually allowed (`requested ∩ allowed`).
497
+ > So `syncGroups` only ever *narrows* within a participant's ceiling — an agent
498
+ > can't reach a deck its capability doesn't already permit, no matter what it
499
+ > passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
493
500
 
494
501
  ## How this compares — and the best practices it follows
495
502
 
@@ -512,7 +519,7 @@ how to reason about it.
512
519
  the room/shape and the server signs off, as in
513
520
  [Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
514
521
  [ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
515
- and Liveblocks **access tokens**. Ablo's client `scope` setting is the
522
+ and Liveblocks **access tokens**. Ablo's session-mint `syncGroups` is the
516
523
  *narrowing* half of this — but it can only ever shrink the server-derived set,
517
524
  never grow it.
518
525
 
@@ -529,10 +536,10 @@ The best practices Ablo inherits from that lineage:
529
536
  2. **Trusted vs untrusted claims is the whole security argument.** PowerSync draws
530
537
  the line precisely: [token parameters are trusted and usable for access
531
538
  control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
532
- In Ablo terms, the identity your server vouches for is the *trusted* claim that
533
- sets scope; the `userId` prop and the client's `scope` setting are *untrusted
534
- client input* — convenient for app-owned fields and narrowing, but never the
535
- boundary. This is why changing `userId` in the browser grants nothing.
539
+ In Ablo terms, the identity your server vouches for and the session's
540
+ `syncGroups`, minted server-side are the *trusted* claims that set scope; the
541
+ `userId` prop is *untrusted client input* — convenient for app-owned fields, but
542
+ never the boundary. This is why changing `userId` in the browser grants nothing.
536
543
 
537
544
  3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
538
545
  group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's