@abloatai/ablo 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +64 -35
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +1 -1
  5. package/dist/client/Ablo.d.ts +1 -0
  6. package/dist/client/Ablo.js +1 -0
  7. package/dist/client/createModelProxy.d.ts +26 -3
  8. package/dist/client/createModelProxy.js +4 -1
  9. package/dist/client/validateAbloOptions.js +2 -2
  10. package/dist/coordination/index.d.ts +6 -0
  11. package/dist/coordination/index.js +6 -0
  12. package/dist/coordination/schema.d.ts +329 -0
  13. package/dist/coordination/schema.js +209 -0
  14. package/dist/core/QueryView.d.ts +4 -1
  15. package/dist/core/QueryView.js +1 -1
  16. package/dist/core/query-utils.d.ts +7 -10
  17. package/dist/core/query-utils.js +2 -3
  18. package/dist/errorCodes.d.ts +264 -0
  19. package/dist/errorCodes.js +251 -0
  20. package/dist/errors.d.ts +51 -6
  21. package/dist/errors.js +56 -3
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +2 -2
  24. package/dist/policy/index.d.ts +1 -1
  25. package/dist/policy/index.js +1 -1
  26. package/dist/policy/types.d.ts +31 -0
  27. package/dist/policy/types.js +15 -0
  28. package/dist/react/AbloProvider.d.ts +12 -0
  29. package/dist/react/AbloProvider.js +11 -3
  30. package/dist/schema/ddl.d.ts +62 -0
  31. package/dist/schema/ddl.js +317 -0
  32. package/dist/schema/diff.d.ts +6 -0
  33. package/dist/schema/diff.js +21 -3
  34. package/dist/schema/field.d.ts +16 -19
  35. package/dist/schema/field.js +30 -17
  36. package/dist/schema/index.d.ts +7 -4
  37. package/dist/schema/index.js +9 -3
  38. package/dist/schema/model.d.ts +87 -25
  39. package/dist/schema/model.js +33 -3
  40. package/dist/schema/relation.d.ts +17 -0
  41. package/dist/schema/roles.d.ts +148 -0
  42. package/dist/schema/roles.js +149 -0
  43. package/dist/schema/schema.d.ts +2 -112
  44. package/dist/schema/schema.js +50 -62
  45. package/dist/schema/select.d.ts +25 -0
  46. package/dist/schema/select.js +55 -0
  47. package/dist/schema/serialize.d.ts +13 -9
  48. package/dist/schema/serialize.js +14 -10
  49. package/dist/schema/sugar.d.ts +20 -3
  50. package/dist/schema/sugar.js +5 -1
  51. package/dist/schema/tenancy.d.ts +66 -0
  52. package/dist/schema/tenancy.js +58 -0
  53. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  54. package/dist/sync/HydrationCoordinator.js +23 -17
  55. package/dist/sync/createIntentStream.d.ts +2 -1
  56. package/dist/sync/createIntentStream.js +46 -1
  57. package/dist/sync/participants.js +5 -14
  58. package/dist/types/streams.d.ts +53 -33
  59. package/docs/api-keys.md +44 -0
  60. package/docs/api.md +11 -22
  61. package/docs/cli.md +212 -0
  62. package/docs/client-behavior.md +1 -1
  63. package/docs/coordination.md +61 -12
  64. package/docs/data-sources.md +2 -2
  65. package/docs/examples/existing-python-backend.md +3 -3
  66. package/docs/examples/scoped-agent.md +78 -0
  67. package/docs/guarantees.md +5 -2
  68. package/docs/identity.md +139 -68
  69. package/docs/index.md +6 -0
  70. package/docs/integration-guide.md +31 -35
  71. package/docs/interaction-model.md +3 -0
  72. package/docs/react.md +3 -3
  73. package/docs/roadmap.md +14 -2
  74. package/package.json +8 -1
package/docs/cli.md ADDED
@@ -0,0 +1,212 @@
1
+ # CLI
2
+
3
+ The `ablo` CLI gets you from an empty project to live-syncing data: scaffold a
4
+ schema, authenticate, push the schema, and watch it sync. Your
5
+ `defineSchema(...)` is the single source of truth — the CLI and the hosted
6
+ server lower it to **the same SQL** through one engine
7
+ (`generateProvisionPlan` / `generateMigrationPlan` in `@abloatai/ablo/schema`).
8
+
9
+ ```bash
10
+ npx ablo init # scaffold ablo/schema.ts + client
11
+ npx ablo login # authorize in the browser
12
+ npx ablo dev # push schema to the test sandbox + watch
13
+ ```
14
+
15
+ ## Authenticate
16
+
17
+ `ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
18
+ **log in** or **create an account** and approve, and the CLI provisions a
19
+ **test + live key pair** (90-day, restricted) and stores them locally. This
20
+ mirrors `stripe login`.
21
+
22
+ | Command | What it does |
23
+ | --- | --- |
24
+ | `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
25
+ | `ablo logout` | Remove the stored keys. |
26
+ | `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
27
+ | `ablo mode [test\|live]` | Switch the active mode. With no argument, prompts. |
28
+
29
+ Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
30
+ log in — set `ABLO_API_KEY`, which always overrides the stored key.
31
+
32
+ ## Test vs live
33
+
34
+ Like Stripe, every account has a **test** mode and a **live** mode, and a key
35
+ belongs to one of them. Test keys are bound to an isolated sandbox: their reads
36
+ and writes never touch live data. Switch with `ablo mode`; `ablo dev` is always
37
+ test mode by design.
38
+
39
+ The schema, however, is **shared** across the org — pushing a schema (from
40
+ either mode) defines the same models test and live see; only the rows differ.
41
+
42
+ ## Commands
43
+
44
+ | Command | What it does | Flags |
45
+ | --- | --- | --- |
46
+ | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
47
+ | `ablo login` / `logout` / `status` | Authentication & status (above). | — |
48
+ | `ablo mode [test\|live]` | Switch active mode. | — |
49
+ | `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
50
+ | `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
51
+ | `ablo schema push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
52
+ | `ablo pull` | **BYO** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
53
+ | `ablo check` | **BYO** — verify your *existing* tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
54
+ | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
55
+
56
+ ## `ablo dev`
57
+
58
+ The development loop. It pushes `ablo/schema.ts` to your **test sandbox**,
59
+ prints the env line your app needs, then watches the file and re-pushes on every
60
+ save (300 ms debounce). It refuses live keys so a tight save loop can never
61
+ churn production data.
62
+
63
+ ```bash
64
+ npx ablo dev # push + watch
65
+ npx ablo dev --no-watch # push once and exit
66
+ ```
67
+
68
+ ## `ablo logs`
69
+
70
+ Tail commit activity, like `stripe logs tail`. Scope comes from the key — a test
71
+ key streams only its sandbox's writes, a live key the org's — so you never pass
72
+ an org. Follows by default; `--no-follow` prints recent and exits.
73
+
74
+ ```bash
75
+ npx ablo logs # last 50, then stream
76
+ npx ablo logs -n 100 --model task # backfill 100, one model
77
+ npx ablo logs --since 15m --json # last 15m as NDJSON, then stream
78
+ ```
79
+
80
+ Each line is `time · op · model · id · actor`. `--json` emits one event per line
81
+ (NDJSON) for piping to `jq` or an agent.
82
+
83
+ ## `ablo pull`
84
+
85
+ Generate `defineSchema(...)` from the tables you already have — the inverse of
86
+ provisioning, and read-only (like `prisma db pull`). It introspects
87
+ `DATABASE_URL`, emits a model per adoptable table (one that has `id` +
88
+ `organization_id`), maps Postgres types back to Zod, and writes `ablo/schema.ts`.
89
+
90
+ ```bash
91
+ DATABASE_URL=postgres://… npx ablo pull
92
+ ```
93
+
94
+ It never touches the database, and won't overwrite an existing schema without
95
+ `--force`. Introspection is lossy — enum members, JSON shape, relations, and
96
+ defaults can't be recovered from columns — so treat the output as a starting
97
+ point: review it, then run `ablo check`.
98
+
99
+ ## `ablo check`
100
+
101
+ The BYO front door. Instead of migrating (DDL on your database), Ablo *adopts*
102
+ the tables you already have: `ablo check` introspects `DATABASE_URL`, compares it
103
+ to your `defineSchema(...)`, and reports — per model — whether the table is
104
+ adoptable. It never writes or alters anything.
105
+
106
+ A table is adoptable when it has a primary key `id` and (for org-scoped models)
107
+ an `organization_id` column — the tenancy marker the engine isolates on. Every
108
+ other table in your database is ignored.
109
+
110
+ **Why `organization_id`?** It's the one column that makes a table safe to
111
+ multiplayer-sync. Row-level security scopes every read and write by it (org A
112
+ can't see org B's rows), and the engine routes realtime deltas by `org:<id>`. A
113
+ table without a tenancy key has no isolation boundary, so Ablo excludes it
114
+ **by default** rather than risk exposing it across tenants. If your tenancy
115
+ column has a different name, keep that table behind a
116
+ [Data Source endpoint](/data-sources) for now.
117
+
118
+ ```bash
119
+ DATABASE_URL=postgres://… npx ablo check
120
+ ```
121
+
122
+ ```text
123
+ ✓ tasks → tasks (id, organization_id ok)
124
+ ✗ projects → projects
125
+ • missing "organization_id" — add it, or move this model behind a Data Source
126
+ 2 models · 1 ok · 1 error
127
+ 12 other tables in your database — ignored by Ablo
128
+ ```
129
+
130
+ If a table can't carry `organization_id` (or has business logic Ablo shouldn't
131
+ bypass), keep it behind a [Data Source endpoint](/data-sources) rather than
132
+ reshaping it. `ablo check` is read-only; it never proposes a migration.
133
+
134
+ ## `migrate` vs `schema push`
135
+
136
+ Two front doors to the same engine. Use `migrate` when your app owns the
137
+ database (it applies to `DATABASE_URL`); use `schema push` (and `dev`) on the
138
+ hosted path (the server applies to Ablo-managed Postgres and version-gates
139
+ connecting clients).
140
+
141
+ ```bash
142
+ ablo migrate --dry-run # preview the exact SQL
143
+ ablo migrate # apply to DATABASE_URL
144
+ ablo migrate --output schema.sql # write SQL to a file
145
+ ```
146
+
147
+ ## Zod → Postgres type mapping
148
+
149
+ The one type map, shared by both paths (there is no second mapping):
150
+
151
+ | Zod | Postgres |
152
+ | --- | --- |
153
+ | `z.string()` | `TEXT` |
154
+ | `z.number()` | `DOUBLE PRECISION` — never `INTEGER`; a Zod number may be fractional, and truncating is silent data loss |
155
+ | `z.boolean()` | `BOOLEAN` |
156
+ | `z.date()` | `TIMESTAMPTZ` |
157
+ | `z.enum([...])` | `TEXT` + a `CHECK (col IN (...))` constraint |
158
+ | `z.object` / `z.array` / `z.record` / `z.union` / `z.custom` | `JSONB` |
159
+ | `.optional()` / `.nullable()` | nullable column |
160
+
161
+ Each table also gets the platform columns (`id`, `organization_id`,
162
+ `created_by`, `created_at`, `updated_at`), an `organization_id` index, and
163
+ row-level security keyed on `current_setting('app.current_org_id')` for tenant
164
+ isolation.
165
+
166
+ `.default(...)` is **not** emitted as a SQL column default — Zod applies the
167
+ default at write time (`create`), in one place, so a DB default and a schema
168
+ default can't drift.
169
+
170
+ ## Structured errors
171
+
172
+ A failed migration aborts the whole transaction (nothing partial lands) and
173
+ reports the same `migration_failed` shape on both paths — naming the statement
174
+ that broke and the Postgres SQLSTATE, not just "migration failed".
175
+
176
+ `ablo migrate` (local) logs it:
177
+
178
+ ```txt
179
+ [migrate] migration plan failed {
180
+ code: 'migration_failed',
181
+ failedStatement: 'ALTER TABLE "public"."tasks" RENAME COLUMN a TO b;',
182
+ failedStatementIndex: 4,
183
+ pgCode: '42P01',
184
+ durationMs: 133
185
+ }
186
+ ```
187
+
188
+ `ablo schema push` (hosted) returns the canonical error envelope (HTTP 500),
189
+ which the SDK reconstructs as a typed `AbloServerError`:
190
+
191
+ ```json
192
+ {
193
+ "type": "AbloServerError",
194
+ "code": "migration_failed",
195
+ "message": "schema migration failed: relation \"...\" does not exist",
196
+ "doc_url": "https://docs.abloatai.com/errors#migration_failed",
197
+ "failedStatement": "ALTER TABLE ... RENAME COLUMN a TO b;",
198
+ "pgCode": "42P01"
199
+ }
200
+ ```
201
+
202
+ The pushed artifact is recorded `failed` and is never activated, so a broken
203
+ migration can't leave clients gated against tables that don't match.
204
+
205
+ ## Environment
206
+
207
+ | Variable | Purpose | Default |
208
+ | --- | --- | --- |
209
+ | `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. | — |
210
+ | `ABLO_API_URL` | Control-plane / API host (`schema push`, `dev`, `status`). | `https://api.abloatai.com` |
211
+ | `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://abloatai.com` |
212
+ | `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` |
@@ -28,7 +28,7 @@ Common options:
28
28
  | `schema` | Required for typed model clients. |
29
29
  | `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
30
30
  | `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
31
- | `persistence` | `volatile` by default. Use `indexeddb` for browser durable cache and offline queueing. |
31
+ | `persistence` | `volatile` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
32
32
  | `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
33
33
  | `defaultHeaders` | Extra headers attached to every HTTP request. |
34
34
  | `defaultQuery` | Extra query parameters attached to every HTTP request. |
@@ -11,10 +11,61 @@ does not poll. Reads are open by default; reading a claimed row is allowed unles
11
11
  the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed
12
12
  holder is auto-released and the queue advances.
13
13
 
14
- This reference has three sections: the [claim state object](#the-claim-state-object),
15
- the SDK [methods](#methods) (`claim` · `claimState` · `queue` · `release` ·
16
- [writing under a claim](#writing-under-a-claim)), and the [errors](#errors) you
17
- can catch.
14
+ This reference opens with [the model](#the-model--three-layers-one-decision) — the
15
+ one answer to "how do two agents not clobber each other" — then covers the
16
+ [claim state object](#the-claim-state-object), the SDK [methods](#methods)
17
+ (`claim` · `claimState` · `queue` · `release` · [writing under a
18
+ claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
19
+
20
+ ---
21
+
22
+ ## The model — three layers, one decision
23
+
24
+ Ablo has exactly **three** coordination layers. They are **not** three competing
25
+ answers to the same question — they stack, and only one of them is a decision you
26
+ make:
27
+
28
+ | layer | kind | what it does | enforces? |
29
+ |---|---|---|---|
30
+ | **Presence** (`claimState`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
31
+ | **Claim** (`claim`/`queue`/`release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
32
+ | **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time — lost-update detection. |
33
+
34
+ **The one decision: do you hold the row across a slow gap (read → LLM call →
35
+ write)?**
36
+
37
+ - **No** (the common case — a single quick `update`): do nothing. `ablo.<model>.update`
38
+ is optimistically guarded by stale-context already; it rejects with
39
+ `AbloStaleContextError` if the row moved under you. This is the default and
40
+ needs no ceremony.
41
+ - **Yes** (you'll reason for seconds while holding the row): `claim` it. The claim
42
+ excludes other participants for the duration, queues contenders fairly, and —
43
+ see below — your own writes under it stay stale-guarded too.
44
+
45
+ **How they compose (what wins):**
46
+
47
+ 1. **Claim supersedes stale-context for *foreign* writers.** A non-holder writing
48
+ to a claimed row is rejected by the claim guard (`AbloClaimedError`,
49
+ `claim_conflict`/`entity_claimed`) *before* any watermark check — `readAt` is
50
+ irrelevant when you don't hold the lease. Pessimistic exclusion is the outer
51
+ gate.
52
+ 2. **Stale-context is the always-on backstop for *unclaimed* writes.** No claim
53
+ held → the watermark check is the only protection, and it's automatic. This is
54
+ why the no-claim path is safe by default.
55
+ 3. **Inside a claim, both apply.** A claim is not a license to clobber yourself:
56
+ writes under a held claim carry the claim's snapshot as `readAt` with
57
+ `onStale: 'reject'` (see [Writing under a claim](#writing-under-a-claim)), so a
58
+ `bypass` write or a row that moved between snapshot and write still rejects.
59
+ Claim = "no one else"; stale-context = "and not against a moved snapshot."
60
+ 4. **Presence never decides.** It is the visualization of (1)–(3), not a fourth
61
+ gate. Never branch enforcement logic on `claimState` — read it to render, act
62
+ on the errors above.
63
+
64
+ Claims and stale-context are **orthogonal by construction**, not wired into each
65
+ other on the server: the claim guard runs pre-transaction; the watermark check
66
+ runs inside it. The SDK attaches `readAt`/`onStale` for you when writing under a
67
+ claim — that coupling lives in the SDK, deliberately, so the server's two checks
68
+ stay independent and individually testable.
18
69
 
19
70
  ---
20
71
 
@@ -36,7 +87,6 @@ a model row. It's what `claimState()` returns and what observers render.
36
87
  | `expiresAt` | `string` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
37
88
 
38
89
  ```jsonc
39
- // A live claim state, as returned by claimState():
40
90
  {
41
91
  "id": "claim_8fJ2",
42
92
  "status": "active",
@@ -95,7 +145,6 @@ release hook for manual scopes.
95
145
  **Example**
96
146
 
97
147
  ```ts
98
- // Callback form — works on any toolchain:
99
148
  const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
100
149
  const weather = await weatherAgent.getWeather(report.location);
101
150
  await ablo.weatherReports.update(report.id, { forecast: weather });
@@ -114,8 +163,8 @@ held. Server/model reads can choose a claimed policy:
114
163
 
115
164
  ```ts
116
165
  await ablo.model('weatherReports').retrieve('report_stockholm', {
117
- ifClaimed: 'wait', // wait until the active claim clears
118
- claimedTimeout: 30_000, // maximum wait
166
+ ifClaimed: 'wait',
167
+ claimedTimeout: 30_000,
119
168
  });
120
169
  ```
121
170
 
@@ -145,11 +194,12 @@ is free.
145
194
 
146
195
  ```ts
147
196
  const who = ablo.weatherReports.claimState('report_stockholm');
148
- if (who) console.log(`${who.heldBy} is ${who.action}`); // 'agent:forecaster is editing'
197
+ if (who) console.log(`${who.heldBy} is ${who.action}`);
149
198
  ```
150
199
 
200
+ Returns the active claim state when the row is held, or `null` when it's free:
201
+
151
202
  ```jsonc
152
- // Resolved value when the row is held:
153
203
  {
154
204
  "id": "claim_8fJ2",
155
205
  "status": "active",
@@ -159,7 +209,6 @@ if (who) console.log(`${who.heldBy} is ${who.action}`); // 'agent:forecaster is
159
209
  "participantKind": "agent",
160
210
  "expiresAt": "1748160030000"
161
211
  }
162
- // → null when free.
163
212
  ```
164
213
 
165
214
  ### `queue`
@@ -190,7 +239,7 @@ the active holder; `[]` when no one is waiting.
190
239
  ```ts
191
240
  const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
192
241
  console.log(`${waiting.length} ahead of you`);
193
- console.log(waiting.map((i) => i.heldBy)); // ['agent:b', 'agent:c']
242
+ console.log(waiting.map((i) => i.heldBy));
194
243
  ```
195
244
 
196
245
  ### `release`
@@ -24,7 +24,7 @@ Use the SDK with an API key:
24
24
 
25
25
  ```ts
26
26
  import Ablo from '@abloatai/ablo';
27
- import { schema } from './ablo.schema';
27
+ import { schema } from './ablo/schema';
28
28
 
29
29
  export const ablo = Ablo({
30
30
  schema,
@@ -100,7 +100,7 @@ The shape is the same as a production webhook integration:
100
100
  ```ts
101
101
  // app/api/ablo/source/route.ts
102
102
  import { dataSource } from '@abloatai/ablo';
103
- import { schema } from '@/ablo.schema';
103
+ import { schema } from '@/ablo/schema';
104
104
  import { db } from '@/db';
105
105
 
106
106
  export const POST = dataSource({
@@ -26,7 +26,7 @@ Browser UI
26
26
  Create a schema for the records that need realtime coordination.
27
27
 
28
28
  ```ts
29
- // web/ablo.schema.ts
29
+ // web/ablo/schema.ts
30
30
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
31
31
 
32
32
  export const schema = defineSchema({
@@ -42,7 +42,7 @@ export const schema = defineSchema({
42
42
  ```ts
43
43
  // web/ablo.ts
44
44
  import Ablo from '@abloatai/ablo';
45
- import { schema } from './ablo.schema';
45
+ import { schema } from './ablo/schema';
46
46
 
47
47
  export const ablo = Ablo({
48
48
  schema,
@@ -58,7 +58,7 @@ model clients without importing server credentials.
58
58
  'use client';
59
59
 
60
60
  import { AbloProvider } from '@abloatai/ablo/react';
61
- import { schema } from '@/ablo.schema';
61
+ import { schema } from '@/ablo/schema';
62
62
 
63
63
  export function Providers({ children }: { children: React.ReactNode }) {
64
64
  return <AbloProvider schema={schema}>{children}</AbloProvider>;
@@ -0,0 +1,78 @@
1
+ # Agent Scoped to One Deck
2
+
3
+ An agent that edits **one deck** and receives realtime updates for **only that
4
+ deck** — not the whole org. Shows the sync-group model end to end: a scope root,
5
+ a containment (`parent`) edge, identity roles, and the model-form `scope`.
6
+
7
+ See [Identity & Sync Groups](../identity.md) for the full reference.
8
+
9
+ ## 1. Schema — declare the scope, once
10
+
11
+ ```ts
12
+ import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/schema';
13
+
14
+ export const schema = defineSchema(
15
+ {
16
+ // A scope root: deck rows form the group `deck:<id>`.
17
+ decks: model(
18
+ { title: z.string() },
19
+ {},
20
+ { orgScoped: true, scope: 'deck' },
21
+ ),
22
+ // A child: no group of its own. It inherits its deck's group via the
23
+ // `parent` edge, so a slide write reaches everyone viewing the deck —
24
+ // even a slide edit that doesn't touch `deckId` (routing is keyed on the
25
+ // row's id, not the changed columns).
26
+ slides: model(
27
+ { deckId: z.string(), body: z.string() },
28
+ { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
29
+ { orgScoped: true },
30
+ ),
31
+ },
32
+ {
33
+ // Humans get their full org scope automatically from these.
34
+ identityRoles: [
35
+ identityRole({ kind: 'org', source: 'organizationId' }),
36
+ identityRole({ kind: 'user', source: 'userId' }),
37
+ ],
38
+ },
39
+ );
40
+ ```
41
+
42
+ ## 2. Dispatch — narrow the agent to the deck it's working on
43
+
44
+ The agent inherits the triggering user's identity (its ceiling) and is narrowed
45
+ to one deck (the floor). You pass the **model and id** — never a `deck:<id>`
46
+ string; the engine builds the group from the model's `scope`.
47
+
48
+ ```ts
49
+ const ablo = Ablo({
50
+ schema,
51
+ url: process.env.ABLO_URL,
52
+ kind: 'agent',
53
+ agentId: 'agent:slide-writer',
54
+ userId: triggeringUser.id, // ceiling: can't exceed this user's reach
55
+ organizationId: triggeringUser.organizationId,
56
+ scope: { decks: deckId }, // floor: just this deck → deck:<deckId>
57
+ });
58
+ await ablo.ready();
59
+ ```
60
+
61
+ ## 3. Write — it fans out to everyone on that deck
62
+
63
+ ```ts
64
+ // Other participants subscribed to deck:<deckId> — the human in the editor,
65
+ // a reviewer agent — receive this delta in realtime. Participants on other
66
+ // decks never see it.
67
+ await ablo.slides.update(slideId, { body: 'Q4 revenue up 12% YoY' });
68
+ ```
69
+
70
+ The slide's delta is stamped `deck:<deckId>` (derived server-side from the
71
+ slide → deck `parent` edge), so it reaches the deck's audience authoritatively —
72
+ regardless of which groups the agent happened to subscribe to. And `scope` only
73
+ ever *narrows*: the agent can't reach a deck its triggering user couldn't.
74
+
75
+ ## See also
76
+
77
+ - [Identity & Sync Groups](../identity.md) — the full scope / parent / grants model.
78
+ - [Agent + Human](./agent-human.md) — yielding when a human edits the same row.
@@ -62,6 +62,9 @@ Advanced policies exist for controlled product flows:
62
62
 
63
63
  ## Claim Coordination
64
64
 
65
+ > The guarantee, not the how-to. Methods, the claim-state object, and the `queue`
66
+ > live in [Coordination](./coordination.md).
67
+
65
68
  Claims are live coordination signals. They are not database locks.
66
69
 
67
70
  Claims are **advisory** and **cooperative**. `ablo.<model>.claim(id, ...)`
@@ -95,10 +98,10 @@ authorized it, which run did it, and what state was it based on?"
95
98
 
96
99
  ## Persistence
97
100
 
98
- Ablo defaults to volatile local persistence. That keeps the SDK focused on
101
+ Ablo defaults to volatile in-memory persistence. That keeps the SDK focused on
99
102
  coordination and audit instead of silently becoming a browser storage product.
100
103
 
101
- Opt into durable browser cache and offline queueing when you need it:
104
+ Opt into a durable browser cache that survives reloads when you need it:
102
105
 
103
106
  ```ts
104
107
  const ablo = Ablo({