@abloatai/ablo 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/groups.md CHANGED
@@ -45,17 +45,17 @@ questions. Pick by the question you have.
45
45
 
46
46
  ```ts
47
47
  // A screen that stays current.
48
- ablo.documents.onChange((docs) => render(docs));
48
+ ablo.records.onChange((docs) => render(docs));
49
49
 
50
50
  // Who else is in here, and what are they holding.
51
- await using room = await ablo.documents.join(documentIds, { ttl: '5m' });
51
+ await using room = await ablo.records.join(documentIds, { ttl: '5m' });
52
52
  room.peers;
53
53
 
54
54
  // Stop this write if the thing I read moved while I composed it.
55
55
  await ablo.blocks.update({ id, data, reads: [{ group: 'workspace:abc', readAt, onStale: 'notify' }] });
56
56
 
57
57
  // Tell me later if this moves, even though I am not writing now.
58
- await ablo.documents.track({ id: 's-1' });
58
+ await ablo.records.track({ id: 's-1' });
59
59
  ```
60
60
 
61
61
  | Question | Channel | Arrives |
@@ -139,7 +139,7 @@ reaches `C`.
139
139
  The direction matters. The signal flows forward, A to B to C, and each hop is a
140
140
  real write an actor chose to make. The engine supplies the edges (group
141
141
  membership) and a stale signal on each edge (the premise check); the actors are
142
- the runtime that walks them. It is closer to a spreadsheet an analyst
142
+ the runtime that walks them. It is closer to a dataset an analyst
143
143
  recalculates cell by cell than to a reactive engine that recomputes the whole
144
144
  column for you.
145
145
 
@@ -206,7 +206,7 @@ you, arriving on the write you were going to make anyway.
206
206
 
207
207
  ```ts
208
208
  // Register interest and walk away — no write required.
209
- await ablo.documents.track({ id: 's-1' });
209
+ await ablo.records.track({ id: 's-1' });
210
210
 
211
211
  // …minutes of other work later, on your next commit…
212
212
  const res = await ablo.blocks.update({ id: 'block-C', data: { text: revised } });
@@ -223,7 +223,7 @@ You can also register a track as part of a write you are already making, the
223
223
  persisted companion to `reads`:
224
224
 
225
225
  ```ts
226
- await ablo.documents.update({
226
+ await ablo.records.update({
227
227
  id: 's-1',
228
228
  data: { title: revised },
229
229
  reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }], // guards THIS commit
@@ -242,8 +242,8 @@ A track says what a moved belief should do to your **next write**. Same enum the
242
242
  `reads` premise carries, minus the one mode that cannot apply:
243
243
 
244
244
  ```ts
245
- await ablo.documents.track({ id: 's-1' }); // notify (default)
246
- await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // gate
245
+ await ablo.records.track({ id: 's-1' }); // notify (default)
246
+ await ablo.records.track({ id: 's-1', onStale: 'reject' }); // gate
247
247
  ```
248
248
 
249
249
  - **`notify`** — the change rides your next receipt and the commit proceeds. You
@@ -263,12 +263,12 @@ re-read, then re-register the track to say so:
263
263
 
264
264
  ```ts
265
265
  try {
266
- await ablo.tasks.update({ id, data });
266
+ await ablo.records.update({ id, data });
267
267
  } catch (err) {
268
268
  if (err.code === 'stale_context') {
269
- const fresh = await ablo.documents.get({ id: 's-1' }); // read
270
- await ablo.documents.track({ id: 's-1', onStale: 'reject' }); // acknowledge
271
- await ablo.tasks.update({ id, data: reconsider(fresh) }); // now it lands
269
+ const fresh = await ablo.records.get({ id: 's-1' }); // read
270
+ await ablo.records.track({ id: 's-1', onStale: 'reject' }); // acknowledge
271
+ await ablo.records.update({ id, data: reconsider(fresh) }); // now it lands
272
272
  }
273
273
  }
274
274
  ```
@@ -7,10 +7,10 @@ whole model — everything below explains what it means and how to use it.
7
7
 
8
8
  ```ts
9
9
  // You call Ablo. Ablo lands the change in your database and confirms it.
10
- await ablo.tasks.update({ id: 'task_42', data: { status: 'done' } });
10
+ await ablo.records.update({ id: 'record_42', data: { status: 'done' } });
11
11
 
12
12
  // Reads come back live, kept current from your database.
13
- const task = ablo.tasks.local.get('task_42');
13
+ const record = ablo.records.local.get('record_42');
14
14
  ```
15
15
 
16
16
  ## The mental model: read this once
@@ -90,17 +90,17 @@ export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
90
90
 
91
91
  // 5. Write through Ablo. Local state changes immediately; await blocks until
92
92
  // the authoritative feed proves the row is there.
93
- await ablo.tasks.update({ id: 'task_42', data: { status: 'done' } });
93
+ await ablo.records.update({ id: 'record_42', data: { status: 'done' } });
94
94
 
95
95
  // 6. Read — live, no fetch loop.
96
- const task = ablo.tasks.local.get('task_42');
96
+ const record = ablo.records.local.get('record_42');
97
97
 
98
98
  // 7. Coordinate when more than one actor can touch a row. Hold a claim and Ablo
99
99
  // serializes writes on that key against everyone else; read after claiming,
100
100
  // then write. The lease releases automatically at the end of the scope.
101
- await using _hold = await ablo.tasks.claim('task_42');
102
- const latest = ablo.tasks.local.get('task_42'); // read after claiming, not from memory
103
- await ablo.tasks.update({ id: 'task_42', data: { status: 'done' } });
101
+ await using _hold = await ablo.records.claim('record_42');
102
+ const latest = ablo.records.local.get('record_42'); // read after claiming, not from memory
103
+ await ablo.records.update({ id: 'record_42', data: { status: 'done' } });
104
104
  ```
105
105
 
106
106
  For the setup details, see [Connect Your Database](./data-sources.md). For the
@@ -10,10 +10,10 @@ retry safe automatically. It cannot make a retry across a process restart safe,
10
10
  process generates a new key — so for anything that must survive a crash, supply your own.
11
11
 
12
12
  ```ts
13
- await ablo.tasks.update({
14
- id: taskId,
13
+ await ablo.records.update({
14
+ id: recordId,
15
15
  data: { status: 'done' },
16
- idempotencyKey: `task:${taskId}:mark-done:v1`,
16
+ idempotencyKey: `record:${recordId}:mark-done:v1`,
17
17
  });
18
18
  ```
19
19
 
@@ -22,7 +22,7 @@ await ablo.tasks.update({
22
22
  **Derive the key from the business event, not from the attempt.** A key built from
23
23
  `crypto.randomUUID()` at the call site is regenerated on every retry, so it protects nothing — each
24
24
  attempt looks like a new intention and the write lands twice. A key built from the thing that
25
- happened (`task:42:mark-done:v1`) is identical on every retry by construction, which is the whole
25
+ happened (`record:42:mark-done:v1`) is identical on every retry by construction, which is the whole
26
26
  point.
27
27
 
28
28
  The same rule stated as its failure: never derive a key from a timestamp, an attempt counter, or a
package/docs/identity.md CHANGED
@@ -38,7 +38,7 @@ scoped operation throws `CapabilityError`; compare its
38
38
 
39
39
  ```ts
40
40
  try {
41
- await ablo.tasks.update({ id, data: { status: 'done' } });
41
+ await ablo.records.update({ id, data: { status: 'done' } });
42
42
  } catch (error) {
43
43
  if (error instanceof CapabilityError) {
44
44
  console.error('missing grant', error.requiredCapability);
@@ -53,7 +53,7 @@ typed grant:
53
53
  ```ts
54
54
  const session = await control.sessions.create({
55
55
  agent: { id: agentId },
56
- can: { tasks: ['read', 'update'] },
56
+ can: { records: ['read', 'update'] },
57
57
  syncGroups: [syncGroup('workspace', workspaceId)],
58
58
  });
59
59
  ```
@@ -106,7 +106,7 @@ export const schema = defineSchema(
106
106
  ),
107
107
  // A child: it has no group of its own; it inherits its workspace's group via the
108
108
  // `parent` edge. A write to a document reaches everyone viewing the workspace.
109
- documents: model(
109
+ records: model(
110
110
  { workspaceId: z.string() },
111
111
  { relations: { workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }) } },
112
112
  ),
@@ -158,7 +158,7 @@ things:
158
158
  - **Membership groups:** named after *who you are*: `org:{id}`, `team:{id}`,
159
159
  `user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
160
160
  standing and durable — they don't change as you work.
161
- - **Entity groups:** named after *a thing*: `dataroom:{id}`, `workspace:{id}`,
161
+ - **Entity groups:** named after *a thing*: `archive:{id}`, `workspace:{id}`,
162
162
  `document:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
163
163
  They're granular — one per record — and any participant can be pointed at a
164
164
  specific set of them.
@@ -171,7 +171,7 @@ they are, so you declare them once in the schema.
171
171
  | | Subscribed by | Declared where | Gets |
172
172
  | --- | --- | --- | --- |
173
173
  | **Human** | *who they are*: membership | **the schema** (`identityRoles`): a rule, written once | every `org` / `team` / `user` group their identity implies: their whole standing world |
174
- | **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 documents it has read: never beyond what its user's membership could reach |
174
+ | **Agent** | *what it's been given*: entities | **code, at the spawn site**: chosen per run | a handful of entity groups: the archive it's in, the documents it has read: never beyond what its user's membership could reach |
175
175
 
176
176
  > **One line:** humans subscribe by who they are; agents subscribe by what
177
177
  > they've been given.
@@ -210,25 +210,24 @@ The default is simple: your schema lives in a **project**, you push it once, and
210
210
  every session you mint resolves against it. Your end-users **don't have Ablo
211
211
  accounts** — your server's `sk_` mints an `ek_` per user, and by default that
212
212
  session lands in your project's own org. All your users share one schema, one
213
- data tenant, isolated from each other by sync-groups. That's the whole story for
214
- most apps.
213
+ data tenant, and receive targeted realtime changes through sync-groups. Model
214
+ `policy` declarations govern which rows they may read; sync-groups are delivery
215
+ routing, not read authorization. That's the whole story for most apps.
215
216
 
216
217
  **Add-on — org-per-customer isolation.** If you need each customer to be its own
217
218
  hard tenant (separate row-level isolation, optionally a separate database) you'd
218
- otherwise have to re-push your schema into every customer's org. Instead, keep one
219
- project as the home of your schema and point each customer's session's *schema*
220
- at it while its *data* stays in the customer's org:
219
+ otherwise have to re-push your schema into every customer's org. Instead, keep
220
+ one project as the home of your schema. A cross-organization mint automatically
221
+ uses the owning key's project for the session's *schema* while its *data*
222
+ stays in the customer's org:
221
223
 
222
224
  ```ts
223
- await mintUserSessionKey({
224
- apiKey: platformKey, // sk_ with the `ephemeral:mint-any-org` scope
225
- userId,
226
- organizationId, // DATA this customer's org (RLS-isolated tenant)
227
- schemaProject: { // SCHEMAthe project that owns your schema
228
- organizationId: schemaOwnerOrgId,
229
- projectId: schemaProjectId,
230
- },
231
- operations: ['task.read', 'task.update'],
225
+ const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
226
+
227
+ await server.sessions.create({
228
+ user: { id: userId },
229
+ organizationId, // DATAthis customer's RLS-isolated organization
230
+ can: { records: ['read', 'update'] },
232
231
  ttlSeconds: 3600,
233
232
  });
234
233
  ```
@@ -236,15 +235,23 @@ await mintUserSessionKey({
236
235
  Server-side, the model **shape** loads from your schema project but column
237
236
  enrichment and the tenant connection still target `organizationId` — so the
238
237
  shared schema only *describes* the shape; the data plane stays the customer's and
239
- can't cross-leak. Omit these fields for the default above. Requires a platform
240
- `sk_` with `ephemeral:mint-any-org`.
238
+ can't cross-leak. `schemaProject: { organizationId, projectId }` remains
239
+ available as an explicit override for migrations or advanced routing. Omit
240
+ `organizationId` for the single-organization default above. Requires a dedicated
241
+ `sk_` with `ephemeral:mint-any-org`; see
242
+ [Customer Organizations](./customer-organizations.md).
241
243
 
242
244
  ## The two halves of scoping
243
245
 
244
- Scoping is two declarations that meet in the middle. One describes the
246
+ Delivery scoping is two declarations that meet in the middle. One describes the
245
247
  **participant** (what may I subscribe to?), the other describes each **row**
246
- (which group does this row belong to?). A participant sees a row **iff** the
247
- row's sync group is in the participant's allowed set.
248
+ (which group does this row belong to?). A participant receives a row's realtime
249
+ changes when the row's sync groups intersect the participant's allowed set.
250
+
251
+ That intersection does not itself authorize an HTTP read. A model's `policy`
252
+ governs read access. Treat sync-groups as change routing and `policy` (plus the
253
+ organization boundary beneath it) as authorization; declaring one never
254
+ silently creates the other.
248
255
 
249
256
  ### Half 1 (`identityRoles`): identity → allowed groups
250
257
 
@@ -296,7 +303,7 @@ declarations, in order of how often you reach for them:
296
303
  **`groups.root` — this model is a scope root.** Its rows form a group of their
297
304
  own. The kind comes from the model's `typename` by default, or pass a string to
298
305
  set it explicitly (use the string form when the wire kind differs from the
299
- typename, e.g. typename `SlideDeck` but group `workspace:<id>`):
306
+ typename, e.g. typename `EntryCollection` but group `workspace:<id>`):
300
307
 
301
308
  ```ts
302
309
  workspaces: model({ title: z.string() }, { groups: { root: 'workspace' } });
@@ -311,24 +318,24 @@ viewing the root. A *reference* (a provenance/template pointer, not ownership)
311
318
  must **not** be marked `parent`, or the row would leak into an unrelated scope:
312
319
 
313
320
  ```ts
314
- documents: model(
315
- { workspaceId: z.string(), sourceSlideId: z.string().optional() },
321
+ records: model(
322
+ { workspaceId: z.string(), sourceEntryId: z.string().optional() },
316
323
  {
317
324
  // default policy: row-local organization_id
318
325
  relations: {
319
326
  workspace: relation.belongsTo('workspaces', 'workspaceId', { parent: true }), // ownership → inherit workspace:<id>
320
- sourceSlide: relation.belongsTo('documents', 'sourceSlideId'), // reference → NOT routed
327
+ sourceEntry: relation.belongsTo('documents', 'sourceEntryId'), // reference → NOT routed
321
328
  },
322
329
  },
323
330
  );
324
331
  ```
325
332
 
326
333
  > **Declare the parent edge — don't infer it.** Optionality is not a proxy for
327
- > ownership: many `parent` FKs are optional (a root folder, an inbox task), and
334
+ > ownership: many `parent` FKs are optional (a root folder, an inbox record), and
328
335
  > some required FKs are mere references. Containment is a fact only you know, so
329
336
  > it's declared, exactly as it is in OpenFGA/Zanzibar.
330
337
 
331
- **`groups.grants` — a membership edge.** On a join model (e.g. `dataroomMember`),
338
+ **`groups.grants` — a membership edge.** On a join model (e.g. `archiveMember`),
332
339
  it says "this row grants a *subject* access to a *scope root*." Both are relation
333
340
  names on the model. The server resolves it at connect time — for user `U`, it
334
341
  finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
@@ -336,12 +343,12 @@ set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
336
343
  org membership is already covered by the `org:` identity role.
337
344
 
338
345
  ```ts
339
- dataroomMember: model(
340
- { userId: z.string(), dataroomId: z.string() },
346
+ archiveMember: model(
347
+ { userId: z.string(), archiveId: z.string() },
341
348
  {
342
349
  relations: {
343
350
  member: relation.belongsTo('users', 'userId'),
344
- room: relation.belongsTo('datarooms', 'dataroomId'),
351
+ room: relation.belongsTo('archives', 'archiveId'),
345
352
  },
346
353
  groups: { grants: { subject: 'member', scope: 'room' } },
347
354
  },
@@ -485,7 +492,7 @@ an entity anchor on the models an agent operates on:
485
492
 
486
493
  ```ts
487
494
  // each scope-root model an agent edits forms a per-entity group
488
- documents: model({ /* … */ }, { groups: { root: 'document' } }),
495
+ records: model({ /* … */ }, { groups: { root: 'document' } }),
489
496
  workspaces: model({ /* … */ }, { groups: { root: 'workspace' } }),
490
497
  ```
491
498
 
@@ -499,7 +506,7 @@ subset of what its user could see:
499
506
  const session = await server.sessions.create({
500
507
  agent: { id: agentId },
501
508
  can: { Document: ['read', 'update'], Workspace: ['read', 'update'] },
502
- syncGroups: [syncGroup('document', documentId), syncGroup('workspace', workspaceId)],
509
+ syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
503
510
  });
504
511
  // identity (the ceiling) is inherited from the triggering user via your
505
512
  // session-mint logic; the agent runtime connects with the minted token.
@@ -526,7 +533,7 @@ than needing a separate agent permission system:
526
533
  - **Inherit the user, and no more:** the OAuth
527
534
  [on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
528
535
  agent's reach is tied to the consenting user, never the org.
529
- - **Least privilege, just-in-time:** scoped to the task's entities, not standing
536
+ - **Least privilege, just-in-time:** scoped to the record's entities, not standing
530
537
  org-wide access (the over-privilege pattern
531
538
  [OWASP's NHI Top 10](https://www.token.security/assets/the-ultimate-non-human-identity-security-guide)
532
539
  flags as the dominant agent risk).
@@ -557,8 +564,8 @@ an agent pointed at the entities it's working on. You **never hand-write**
557
564
  agent: { id: agentId },
558
565
  can: { Workspace: ['read', 'update'], Document: ['read'] },
559
566
  syncGroups: [
560
- syncGroup('workspace', deckA),
561
- syncGroup('workspace', deckB),
567
+ syncGroup('workspace', collectionA),
568
+ syncGroup('workspace', collectionB),
562
569
  syncGroup('document', docId),
563
570
  ],
564
571
  });
package/docs/index.md CHANGED
@@ -155,6 +155,7 @@ default caller, not a special one.
155
155
  - [Projects](./projects.md) — one organization, many apps; each with its own schema, planes, and keys.
156
156
  - [API Keys](./api-keys.md) — the credential that carries an agent's identity and its scopes.
157
157
  - [Sessions](./sessions.md) — short-lived scoped credentials your backend mints.
158
+ - [Customer Organizations](./customer-organizations.md) — serve many isolated customer organizations from one schema and backend.
158
159
  - [Audit Log](./audit.md) — trace any confirmed write back to the person behind it.
159
160
  - [Operating on Your Database](./operating-on-your-database.md) — which actions run freely, which to verify first, and which belong to a human.
160
161
  - [Session Settings](./session-settings.md) — point your row-level-security policies at Ablo's writes, by naming the settings they already read.
@@ -163,7 +164,7 @@ default caller, not a special one.
163
164
 
164
165
  - [Quickstart](./quickstart.md) — make your first coordinated write.
165
166
  - [Integration Guide](./integration-guide.md) — the canonical end-to-end integration.
166
- - [Integrations](./integrations.md) — long-running tasks, ingestion, and other application-edge runtimes.
167
+ - [Integrations](./integrations.md) — long-running records, ingestion, and other application-edge runtimes.
167
168
  - [CLI & Migrations](./cli.md) — `init` / `connect` / `push` / `migrate` / `generate`.
168
169
  - [Connect Your Database](./data-sources.md) — where rows land when your own database is canonical.
169
170
  - [Deployment](./deployment.md) — the database, the keys, and the schema push that take an integration to production.
@@ -215,7 +215,7 @@ export async function POST() {
215
215
  const session = await auth(); // your own auth — returns the signed-in user
216
216
  const { token, expiresAt } = await sync.sessions.create({
217
217
  user: { id: session.userId },
218
- can: { tasks: ['read', 'update'] },
218
+ can: { records: ['read', 'update'] },
219
219
  });
220
220
  return Response.json(
221
221
  credentialEndpointSuccessSchema.parse({
@@ -1,6 +1,6 @@
1
- # Inngest for long-running tasks
1
+ # Inngest for long-running records
2
2
 
3
- > Run event-driven, retryable agent tasks with Inngest while Ablo makes each
3
+ > Run event-driven, retryable agent records with Inngest while Ablo makes each
4
4
  > shared-state effect typed, idempotent, and authoritative.
5
5
 
6
6
  Inngest and Ablo solve different parts of a durable agent system:
@@ -1,6 +1,6 @@
1
- # Temporal for long-running tasks
1
+ # Temporal for long-running records
2
2
 
3
- > Run long-lived, retryable agent tasks with Temporal while Ablo makes each
3
+ > Run long-lived, retryable agent records with Temporal while Ablo makes each
4
4
  > shared-state effect typed, idempotent, and authoritative.
5
5
 
6
6
  Temporal and Ablo solve different parts of a durable agent system:
@@ -10,7 +10,7 @@ Temporal and Ablo solve different parts of a durable agent system:
10
10
  | Workflow history, replay, timers, retries, cancellation | Temporal |
11
11
  | Provider, messages, tools, approval, model loop | AI SDK |
12
12
  | Typed reads and writes, idempotency, claims, confirmation | Ablo |
13
- | Workflow names, task queues, retry policy, business behavior | Your application |
13
+ | Workflow names, record queues, retry policy, business behavior | Your application |
14
14
 
15
15
  The short version is:
16
16
 
@@ -13,8 +13,8 @@ into authoritative application state.
13
13
 
14
14
  | Category | Integration | Status | Use it for |
15
15
  |---|---|---|---|
16
- | Long-running tasks | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls |
17
- | Long-running tasks | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls |
16
+ | Long-running records | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls |
17
+ | Long-running records | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls |
18
18
  | Data ingestion | Connector runtimes | Planned | Bringing external data into Ablo-backed models without creating a second write authority |
19
19
 
20
20
  An integration gets its own guide when there is runnable application code and
@@ -22,7 +22,7 @@ the boundary has been tested. A dedicated package comes later still: only
22
22
  repeated production integrations that reveal substantial reusable behavior
23
23
  justify adding another public runtime dependency.
24
24
 
25
- ## Long-running tasks
25
+ ## Long-running records
26
26
 
27
27
  Use [Temporal](./integrations/temporal.md) when work must survive process
28
28
  failure, retry Activities, wait on timers, or preserve a durable model loop.
package/docs/react.md CHANGED
@@ -201,9 +201,9 @@ write interest in it.
201
201
 
202
202
  import { useJoin } from '@abloatai/ablo/react';
203
203
 
204
- export function DeckPresence({ workspaceId }: { workspaceId: string }) {
204
+ export function CollectionPresence({ workspaceId }: { workspaceId: string }) {
205
205
  const { peers, claims, status } = useJoin({
206
- scope: { slideDecks: workspaceId },
206
+ scope: { entryCollections: workspaceId },
207
207
  claim: true, // I intend to write — pin the scope + let peers observe the claim
208
208
  hydrate: true, // backfill the workspace's current rows if not already loaded
209
209
  });
@@ -217,7 +217,7 @@ Options (`UseJoinOptions`):
217
217
 
218
218
  | Option | Default | Effect |
219
219
  | --- | --- | --- |
220
- | `scope` |: | Model-form scope (`{ slideDecks: id }`), resolved through the schema. Omit for engine-wide. |
220
+ | `scope` |: | Model-form scope (`{ entryCollections: id }`), resolved through the schema. Omit for engine-wide. |
221
221
  | `claim` | `false` | Acquire a write-claim on the scope (sent so peers observe it; pins the scope so it never warm-drops while held). A viewer is not a claimant: leave `false` for read-only. |
222
222
  | `hydrate` | `false` | Backfill the scope's current rows into the pool once on enter, then keep them fresh via the live tail. Set `true` for deep-linked / never-opened entities. Single-flight; soft-fails. |
223
223
  | `ttlSeconds` |: | Lease TTL for the scope claim. |
@@ -241,7 +241,7 @@ connection is subscribed to.
241
241
  import { usePeers } from '@abloatai/ablo/react';
242
242
 
243
243
  export function CursorBroadcaster({ workspaceId }: { workspaceId: string }) {
244
- const peers = usePeers({ slideDecks: workspaceId });
244
+ const peers = usePeers({ entryCollections: workspaceId });
245
245
  const alone = !peers.some((p) => p.participantKind === 'user');
246
246
  // suppress live-cursor broadcasts while alone
247
247
  }
package/docs/sessions.md CHANGED
@@ -13,13 +13,13 @@ One resource mints both:
13
13
  // A logged-in person's browser session — only the operations this UI needs.
14
14
  const userSession = await ablo.sessions.create({
15
15
  user: { id: currentUser.id },
16
- can: { tasks: ['read', 'update'], workspaces: ['read'] },
16
+ can: { records: ['read', 'update'], workspaces: ['read'] },
17
17
  });
18
18
 
19
19
  // Recommended agent path — returns a ready, scoped client.
20
20
  const agent = await ablo.agents.create({
21
- name: 'task-writer',
22
- can: { tasks: ['read', 'update'], workspaces: ['read'] },
21
+ name: 'record-writer',
22
+ can: { records: ['read', 'update'], workspaces: ['read'] },
23
23
  });
24
24
  ```
25
25
 
@@ -51,7 +51,7 @@ import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
51
51
 
52
52
  const { token, expiresAt } = await ablo.sessions.create({
53
53
  user: { id: currentUser.id }, // who the session acts as
54
- can: { tasks: ['read', 'update'] },
54
+ can: { records: ['read', 'update'] },
55
55
  // syncGroups: [...], // optional; defaults to the user's org + user
56
56
  });
57
57
  return Response.json(
@@ -104,18 +104,18 @@ error, not a silent over-grant:
104
104
 
105
105
  ```ts
106
106
  const agent = await ablo.agents.create({
107
- name: 'task-writer',
108
- can: { tasks: ['update'] }, // typed off the schema — no magic strings
107
+ name: 'record-writer',
108
+ can: { records: ['update'] }, // typed off the schema — no magic strings
109
109
  ttlSeconds: 600,
110
110
  });
111
111
 
112
- await agent.tasks.update({ id, data });
112
+ await agent.records.update({ id, data });
113
113
  await agent.dispose();
114
114
  ```
115
115
 
116
116
  The returned client refreshes its own short-lived credential. A write grant
117
117
  automatically includes the corresponding read, so
118
- `can: { tasks: ['update'] }` is enforced as `task.update` plus `task.read`.
118
+ `can: { records: ['update'] }` is enforced as `record.update` plus `record.read`.
119
119
  Operations are `'read' | 'create' | 'update' | 'delete'`.
120
120
 
121
121
  For a reusable grant, use TypeScript's `satisfies`. It checks the object against
@@ -126,14 +126,14 @@ second permission model:
126
126
  import type { CapabilityGrant } from '@abloatai/ablo/auth';
127
127
  import { schema } from './ablo.schema';
128
128
 
129
- const taskWriterCan = {
130
- tasks: ['update'],
129
+ const recordWriterCan = {
130
+ records: ['update'],
131
131
  } satisfies CapabilityGrant<typeof schema>;
132
132
 
133
- const agent = await ablo.agents.create({ can: taskWriterCan });
133
+ const agent = await ablo.agents.create({ can: recordWriterCan });
134
134
  ```
135
135
 
136
- `documents` instead of `tasks`, or `'write'` instead of `'update'`, is a compile
136
+ `documents` instead of `records`, or `'write'` instead of `'update'`, is a compile
137
137
  error. At runtime the SDK parses the same grant with the schema-bound Zod
138
138
  contract before minting, and the server validates it again against the active
139
139
  pushed schema.
@@ -154,6 +154,8 @@ for the actor.
154
154
  |---|---|---|
155
155
  | `user` / `agent` | both | The actor. `id` becomes the token's `participantId`. Pass exactly one. |
156
156
  | `can` | both | Required non-empty per-model operation allowlist, typed off the schema. |
157
+ | `organizationId` | user | Mint into a customer organization instead of the key's own. Requires `ephemeral:mint-any-org`. |
158
+ | `schemaProject` | user | Override the schema project for a cross-org mint. Usually omitted because the owning key's project is the default. |
157
159
  | `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. |
158
160
  | `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). |
159
161
  | `userMeta` | both | Opaque identity blob echoed back to the client. |
@@ -217,7 +219,7 @@ errors.
217
219
 
218
220
  A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`),
219
221
  derived from the identity you minted it for. **Dynamic, relation-driven
220
- membership** (e.g. a `dataroom:<id>` the user was just added to) is resolved
222
+ membership** (e.g. a `archive:<id>` the user was just added to) is resolved
221
223
  **server-side at connect** and unioned on top — so scope stays live, not frozen
222
224
  at mint time. Pass `syncGroups` only when you want to *narrow* below the default.
223
225
 
@@ -243,25 +245,40 @@ Some apps need each customer to be its **own** tenant — a hard data boundary
243
245
  scoping. The law-firm shape (Legora): every firm is its own org, many users
244
246
  inside it.
245
247
 
248
+ Choose the boundary before minting sessions:
249
+
250
+ | Customer model | Isolation guarantee | Use when |
251
+ |---|---|---|
252
+ | One Ablo organization, customer scope roots | Every model's declared `policy` | Cross-customer access is intentional or every model explicitly partitions by the customer root |
253
+ | One Ablo organization per customer | Structural organization filtering and RLS on every row | Customers must be isolated even when a model has no customer policy |
254
+
255
+ Sync-group routing controls which changes are delivered; it does not grant or
256
+ deny reads. Do not use scope roots as a tenant security boundary unless every
257
+ model declares the matching policy. If that invariant is difficult to audit,
258
+ use one organization per customer.
259
+
260
+ For the complete key, backend-route, browser, lifecycle, and troubleshooting
261
+ flow, see [Customer Organizations](./customer-organizations.md).
262
+
246
263
  The problem that creates: if each customer is a separate org, a naïve setup would
247
264
  make you re-push your schema into every new customer's org. You don't have to.
248
- Keep **one** project as the home of your schema, and point each customer's
249
- session's *schema* at it while its *data* stays in the customer's own org:
265
+ Keep **one** project as the home of your schema. When its key mints into another
266
+ organization, Ablo automatically resolves the session's *schema* from that key's
267
+ project while its *data* stays in the customer's own org:
250
268
 
251
269
  ```ts
252
- const { token } = await mintUserSessionKey({
253
- apiKey: process.env.ABLO_PLATFORM_KEY, // sk_ with the ephemeral:mint-any-org scope
254
- userId,
255
- organizationId, // DATA → this customer's org (its own isolated tenant)
256
- schemaProject: { // SCHEMA the project that owns your schema
257
- organizationId: schemaOwnerOrgId,
258
- projectId: schemaProjectId,
259
- },
260
- operations: ['task.read', 'task.update'],
270
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_PLATFORM_KEY });
271
+ const { token } = await ablo.sessions.create({
272
+ user: { id: userId },
273
+ organizationId, // DATA → this customer's isolated org
274
+ can: { records: ['read', 'update'] },
261
275
  ttlSeconds: 3600,
262
276
  });
263
277
  ```
264
278
 
279
+ For migrations or advanced routing, `sessions.create` also accepts an explicit
280
+ `schemaProject: { organizationId, projectId }` override.
281
+
265
282
  Server-side the split is clean: the model **shape** loads from your schema
266
283
  project, but column enrichment and the tenant connection target the customer's
267
284
  `organizationId` — so the shared schema only *describes* the shape; the data
@@ -269,10 +286,10 @@ plane (connection + row-level isolation) stays the customer's. A shared schema
269
286
  can't leak data across orgs.
270
287
 
271
288
  <Note>
272
- This requires a platform `sk_` carrying the `ephemeral:mint-any-org` scope —
273
- only a trusted first-party key can mint a session into another org and bind its
274
- schema to your project. Omit these fields and you get the default above: one
275
- project, one schema, all your users.
289
+ This requires a dedicated `sk_` carrying the `ephemeral:mint-any-org` scope —
290
+ only a trusted cross-organization key can mint a session into another org. Omit
291
+ `organizationId` and you get the default above: one project, one schema, all
292
+ your users in the key's own organization.
276
293
  </Note>
277
294
 
278
295
  ## Security
package/docs/webhooks.md CHANGED
@@ -39,7 +39,7 @@ Every delivery is a batch of events. Each event:
39
39
 
40
40
  | field | meaning |
41
41
  |---|---|
42
- | `type` | `"<model>.<verb>"` with the model name lowercased, e.g. `task.updated` |
42
+ | `type` | `"<model>.<verb>"` with the model name lowercased, e.g. `record.updated` |
43
43
  | `model` | the model name exactly as declared in your schema: the table to write |
44
44
  | `objectId` | the changed row's id |
45
45
  | `data` | the post-change row, or `null` on delete |
@@ -127,7 +127,7 @@ Scope which models fire and label the endpoint at creation with `--events` and
127
127
 
128
128
  ```bash
129
129
  npx ablo webhooks create https://yourapp.com/api/ablo/[...all] \
130
- --events task,project --description "prod mirror"
130
+ --events record,project --description "prod mirror"
131
131
  ```
132
132
 
133
133
  Manage and inspect endpoints:
@@ -30,13 +30,13 @@ For read-reason-write work, pass the exact returned rows that informed the
30
30
  decision. Their watermarks stay opaque:
31
31
 
32
32
  ```ts
33
- const task = await ablo.tasks.get({ id: taskId });
33
+ const record = await ablo.records.get({ id: recordId });
34
34
  const policy = await ablo.policies.get({ id: policyId });
35
- const result = await model({ task, policy });
36
- await ablo.tasks.update({
37
- id: task.id,
35
+ const result = await model({ record, policy });
36
+ await ablo.records.update({
37
+ id: record.id,
38
38
  data: result,
39
- reads: [task, policy],
39
+ reads: [record, policy],
40
40
  });
41
41
  ```
42
42
 
@@ -61,7 +61,7 @@ root and a bare `quickstart.ts` won't be found.
61
61
  ```bash
62
62
  cd packages/ablo
63
63
  ABLO_API_KEY=sk_... npx tsx examples/quickstart.ts
64
- ABLO_API_KEY=sk_... TASK_ID=task_... npx tsx examples/agent-turn.ts
64
+ ABLO_API_KEY=sk_... RECORD_ID=record_... npx tsx examples/agent-turn.ts
65
65
  ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/expensive-agent-turn.ts
66
66
  ```
67
67