@abloatai/ablo 0.11.2 → 0.13.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 (70) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +3 -3
  4. package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
  5. package/dist/ai-sdk/claim-broadcast.js +2 -2
  6. package/dist/ai-sdk/wrap.d.ts +5 -4
  7. package/dist/ai-sdk/wrap.js +3 -3
  8. package/dist/cli.cjs +152 -41
  9. package/dist/client/Ablo.d.ts +25 -3
  10. package/dist/client/Ablo.js +5 -5
  11. package/dist/client/ApiClient.js +26 -11
  12. package/dist/client/createModelProxy.d.ts +15 -7
  13. package/dist/client/createModelProxy.js +12 -12
  14. package/dist/coordination/schema.d.ts +1 -1
  15. package/dist/coordination/schema.js +3 -1
  16. package/dist/errors.d.ts +3 -1
  17. package/dist/errors.js +6 -1
  18. package/dist/react/AbloProvider.d.ts +11 -7
  19. package/dist/react/AbloProvider.js +9 -5
  20. package/dist/react/context.d.ts +9 -14
  21. package/dist/react/context.js +10 -15
  22. package/dist/react/index.d.ts +8 -4
  23. package/dist/react/index.js +8 -4
  24. package/dist/react/useMutators.js +3 -2
  25. package/dist/react/useUndoScope.js +3 -2
  26. package/dist/schema/index.d.ts +2 -2
  27. package/dist/schema/index.js +2 -2
  28. package/dist/schema/model.d.ts +38 -77
  29. package/dist/schema/model.js +12 -12
  30. package/dist/schema/roles.d.ts +49 -0
  31. package/dist/schema/roles.js +21 -0
  32. package/dist/schema/schema.d.ts +1 -1
  33. package/dist/schema/schema.js +1 -1
  34. package/dist/schema/serialize.d.ts +4 -2
  35. package/dist/schema/serialize.js +4 -2
  36. package/dist/schema/sugar.d.ts +7 -28
  37. package/dist/schema/sugar.js +2 -7
  38. package/dist/schema/sync-delta-row.d.ts +2 -0
  39. package/dist/schema/sync-delta-row.js +2 -1
  40. package/dist/schema/tenancy.d.ts +67 -28
  41. package/dist/schema/tenancy.js +93 -23
  42. package/dist/server/commit.d.ts +8 -3
  43. package/dist/sync/createClaimStream.js +5 -4
  44. package/dist/sync/participants.js +1 -1
  45. package/dist/types/streams.d.ts +17 -7
  46. package/docs/api.md +1 -1
  47. package/docs/cli.md +43 -4
  48. package/docs/client-behavior.md +2 -2
  49. package/docs/coordination.md +1 -1
  50. package/docs/examples/agent-human.md +6 -6
  51. package/docs/examples/ai-sdk-tool.md +1 -1
  52. package/docs/examples/existing-python-backend.md +0 -2
  53. package/docs/examples/nextjs.md +2 -2
  54. package/docs/examples/scoped-agent.md +3 -3
  55. package/docs/examples/server-agent.md +4 -4
  56. package/docs/identity.md +27 -20
  57. package/docs/index.md +0 -1
  58. package/docs/integration-guide.md +12 -9
  59. package/docs/interaction-model.md +1 -1
  60. package/docs/mcp.md +17 -5
  61. package/docs/migration.md +2 -1
  62. package/docs/quickstart.md +3 -3
  63. package/llms.txt +2 -3
  64. package/package.json +3 -2
  65. package/docs/mcp/claude-code.md +0 -35
  66. package/docs/mcp/cursor.md +0 -35
  67. package/docs/mcp/windsurf.md +0 -33
  68. package/docs/roadmap.md +0 -55
  69. package/docs/the-loop.md +0 -21
  70. package/llms-full.txt +0 -396
@@ -1,22 +1,36 @@
1
1
  /**
2
2
  * Tenancy — the single source of truth for how a model's rows are scoped to a
3
- * tenant. This replaces three scattered mechanisms (a hardcoded
4
- * `organization_id` literal, an `orgScoped` boolean, and a `scopedVia` ref) with
5
- * one Zod discriminated union, resolved in one place and consumed everywhere
6
- * (provision/RLS, introspection, runtime, CLI).
3
+ * tenant. There are exactly two layers here, and keeping them separate is the
4
+ * whole point:
7
5
  *
8
- * Why a union: every consumer used to re-derive "how is this table scoped?" from
9
- * a flag plus a literal a missed branch was a silent cross-tenant scoping bug.
10
- * A discriminated union makes the `switch` exhaustive, so the type system holds
11
- * the isolation boundary, and the physical column name lives in exactly one
12
- * place (the `column` variant) instead of being hardcoded across the codebase.
6
+ * 1. The CANONICAL form {@link Tenancy}, a Zod discriminated union. This is
7
+ * what every consumer (provision/RLS, introspection, runtime, CLI) reads
8
+ * and what crosses the wire in `ModelJSON`. One shape, exhaustively
9
+ * switchable, so the type system holds the isolation boundary.
10
+ * 2. The AUTHORING form {@link PolicyInput}, the `policy: { by }` option a
11
+ * schema author writes. The name follows Postgres/Supabase RLS vocabulary:
12
+ * a `policy` is the rule that decides which rows a tenant may read.
13
+ * {@link resolvePolicy} maps it to the canonical {@link Tenancy} at
14
+ * `model()`-build time (much as Supabase's `create policy` compiles to a
15
+ * `pg_policy` row), so the authoring vocabulary never reaches the wire or
16
+ * any consumer.
17
+ *
18
+ * Why one authoring option (`policy`) instead of the old
19
+ * `orgScoped`/`scopedVia`/`orgColumn` trio: those three were synonyms for one
20
+ * decision ("how is this row scoped?"), and the most dangerous of them
21
+ * (`orgScoped: false`) silently exposed a whole table cross-tenant. Collapsing
22
+ * them into a single discriminated union makes the opt-out (`{ by: 'none' }`) a
23
+ * loud, deliberate branch instead of a falsy flag — one concept, one name.
13
24
  */
14
25
  import { z } from 'zod';
15
26
  /** Default physical tenancy column. The ONLY place this literal is canonical. */
16
27
  export const DEFAULT_ORG_COLUMN = 'organization_id';
17
28
  /**
18
29
  * Scope a table's rows through a parent table (for rows that carry no tenancy
19
- * column of their own — e.g. `slide_layers` → slide → deck → org).
30
+ * column of their own — e.g. `slide_layers` → slide → deck → org). This is the
31
+ * CANONICAL `parent` payload; authors write the friendlier {@link PolicyInput}
32
+ * `{ by: 'parent', fk, parent }` shape, normalized into this by
33
+ * {@link resolvePolicy}.
20
34
  */
21
35
  export const scopedViaRefSchema = z.object({
22
36
  /** Column on THIS table pointing at the parent (e.g. `'team_id'`). */
@@ -28,7 +42,7 @@ export const scopedViaRefSchema = z.object({
28
42
  /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
29
43
  parentOrgColumn: z.string().min(1).optional(),
30
44
  });
31
- /** How a model's rows are scoped to a tenant. */
45
+ /** How a model's rows are scoped to a tenant — the CANONICAL, wire-facing form. */
32
46
  export const tenancySchema = z.discriminatedUnion('kind', [
33
47
  /** Row-local tenancy column (default name `organization_id`, overridable). */
34
48
  z.object({ kind: z.literal('column'), column: z.string().min(1) }),
@@ -38,19 +52,75 @@ export const tenancySchema = z.discriminatedUnion('kind', [
38
52
  z.object({ kind: z.literal('none') }),
39
53
  ]);
40
54
  /**
41
- * Normalize authoring sugar into the one canonical {@link Tenancy}. Called once,
42
- * at model-build, so `ModelDef`/`ModelJSON` and every consumer see only
43
- * `tenancy`. Precedence: explicit `tenancy` `scopedVia` `orgScoped:false`
44
- * column (default or `orgColumn`).
55
+ * The AUTHORING form of tenancy what a schema author writes as the model's
56
+ * `policy` option (Postgres/Supabase RLS vocabulary: a policy is the rule that
57
+ * scopes which rows a tenant may read). A Zod discriminated union on `by`, so
58
+ * the three branches are mutually exclusive and the dangerous opt-out
59
+ * (`{ by: 'none' }`) is an explicit, named choice rather than a falsy flag.
60
+ *
61
+ * - `{ by: 'column' }` — row-local tenancy column (the default).
62
+ * `column` overrides the name (default {@link DEFAULT_ORG_COLUMN}).
63
+ * - `{ by: 'parent', fk, parent }` — inherit tenancy through a foreign key when
64
+ * this table has no tenancy column of its own. `parentKey` (default `'id'`)
65
+ * and `parentTenantColumn` (default {@link DEFAULT_ORG_COLUMN}) are overrides.
66
+ * - `{ by: 'none' }` — genuinely global / reference data. ⚠ Makes
67
+ * the whole table readable cross-tenant — only correct for tenant-less tables.
68
+ */
69
+ export const policyInputSchema = z.discriminatedUnion('by', [
70
+ z.object({
71
+ by: z.literal('column'),
72
+ /** Override the physical tenancy column name. Default {@link DEFAULT_ORG_COLUMN}. */
73
+ column: z.string().min(1).optional(),
74
+ }),
75
+ z.object({
76
+ by: z.literal('parent'),
77
+ /** Column on THIS table pointing at the parent (e.g. `'slideId'`). */
78
+ fk: z.string().min(1),
79
+ /** Parent table name (e.g. `'slides'`). */
80
+ parent: z.string().min(1),
81
+ /** Column on the parent that `fk` references. Default `'id'`. */
82
+ parentKey: z.string().min(1).optional(),
83
+ /** Column on the parent holding the tenant id. Default {@link DEFAULT_ORG_COLUMN}. */
84
+ parentTenantColumn: z.string().min(1).optional(),
85
+ }),
86
+ z.object({ by: z.literal('none') }),
87
+ ]);
88
+ /**
89
+ * Normalize the authoring {@link PolicyInput} into the one canonical
90
+ * {@link Tenancy}. Called once, at `model()`-build, so `ModelDef`/`ModelJSON`
91
+ * and every consumer see only the canonical union. Omitting `policy` defaults
92
+ * to a row-local `organization_id` column.
93
+ */
94
+ export function resolvePolicy(input) {
95
+ if (!input)
96
+ return { kind: 'column', column: DEFAULT_ORG_COLUMN };
97
+ switch (input.by) {
98
+ case 'column':
99
+ return { kind: 'column', column: input.column ?? DEFAULT_ORG_COLUMN };
100
+ case 'parent':
101
+ return {
102
+ kind: 'parent',
103
+ via: {
104
+ localKey: input.fk,
105
+ parentTable: input.parent,
106
+ parentKey: input.parentKey,
107
+ parentOrgColumn: input.parentTenantColumn,
108
+ },
109
+ };
110
+ case 'none':
111
+ return { kind: 'none' };
112
+ }
113
+ }
114
+ /**
115
+ * Read the canonical {@link Tenancy} off an already-built model def (or parsed
116
+ * `ModelJSON`), defaulting to a row-local `organization_id` column when absent.
117
+ *
118
+ * This is the READ-side helper — consumers (provision/RLS, membership resolver,
119
+ * DDL, CLI) call it to get a model's tenancy without re-deriving the default in
120
+ * each place. It is NOT the authoring normalizer; that's {@link resolveIsolation}.
45
121
  */
46
- export function resolveTenancy(input) {
47
- if (input.tenancy)
48
- return input.tenancy;
49
- if (input.scopedVia)
50
- return { kind: 'parent', via: input.scopedVia };
51
- if (input.orgScoped === false)
52
- return { kind: 'none' };
53
- return { kind: 'column', column: input.orgColumn ?? DEFAULT_ORG_COLUMN };
122
+ export function resolveTenancy(def) {
123
+ return def.tenancy ?? { kind: 'column', column: DEFAULT_ORG_COLUMN };
54
124
  }
55
125
  /** The physical tenancy column for a column-scoped model, else `null`. */
56
126
  export function tenancyColumn(t) {
@@ -58,11 +58,16 @@ export interface CommitContext {
58
58
  */
59
59
  onBehalfOf?: ParticipantRef | null;
60
60
  /**
61
- * FK to AgentCapabilityRoot.capabilityId. Non-null for agent / system commits
62
- * authorized by a Biscuit; null for human-direct commits. Embedded in every
63
- * delta so the audit chain "delta → capability → human" is one FK hop.
61
+ * Scoped credential id. Non-null for agent / system commits when the
62
+ * authorizing credential is known; null for human-direct commits.
64
63
  */
65
64
  capabilityId?: string | null;
65
+ /**
66
+ * Human user id at the root of the delegated authority chain. Stored directly
67
+ * on `sync_deltas` so audit triggers never need to join mutable credential
68
+ * tables while appending the hash chain.
69
+ */
70
+ delegationChainRootUserId?: string | null;
66
71
  /**
67
72
  * ApiKey row id when the caller authenticated with an API key. Used by the
68
73
  * idempotency cache and usage attribution. Null for session / capability
@@ -192,7 +192,8 @@ export function createClaimStream(config, transport = null) {
192
192
  entityId: claim.entityId,
193
193
  path: claim.path,
194
194
  range: claim.range,
195
- action: claim.action,
195
+ // Wire field stays `action` (coordination schema); source is `reason`.
196
+ action: claim.reason,
196
197
  field: claim.field,
197
198
  meta: claim.meta,
198
199
  estimatedMs: claim.estimatedMs,
@@ -244,7 +245,7 @@ export function createClaimStream(config, transport = null) {
244
245
  range: args.range,
245
246
  field: args.field,
246
247
  meta: args.meta,
247
- action: args.action,
248
+ reason: args.reason,
248
249
  estimatedMs,
249
250
  queue: args.queue,
250
251
  };
@@ -261,7 +262,7 @@ export function createClaimStream(config, transport = null) {
261
262
  return {
262
263
  object: 'claim',
263
264
  claimId,
264
- action: args.action,
265
+ reason: args.reason,
265
266
  target: {
266
267
  model: args.entityType,
267
268
  id: args.entityId,
@@ -294,7 +295,7 @@ export function createClaimStream(config, transport = null) {
294
295
  range: resolved.range,
295
296
  field: resolved.field,
296
297
  meta: withDescription(resolved.meta, opts?.description),
297
- action: opts?.reason ?? 'editing',
298
+ reason: opts?.reason ?? 'editing',
298
299
  ttl: opts?.ttl,
299
300
  queue: opts?.queue,
300
301
  });
@@ -221,7 +221,7 @@ function createJoinedParticipant(args) {
221
221
  return {
222
222
  object: 'claim',
223
223
  claimId: handle.claimId,
224
- action: handle.action,
224
+ reason: handle.reason,
225
225
  target: handle.target,
226
226
  async release() {
227
227
  ownHandles.delete(handle);
@@ -360,7 +360,7 @@ export interface ClaimStream {
360
360
  /**
361
361
  * Reactive view of the wait queue on one target — the FIFO line of
362
362
  * `status: 'queued'` claims behind the current holder, each with its
363
- * `action`, `heldBy`, and `position`. Synced from the server's per-entity
363
+ * `reason`, `heldBy`, and `position`. Synced from the server's per-entity
364
364
  * `claim_queue` frame; empty when no one's waiting. Pair with
365
365
  * `subscribe(...)` for change notifications.
366
366
  */
@@ -457,10 +457,12 @@ export interface ClaimDeclaration {
457
457
  /** Human-readable reason — "rewriting title" / "restyling chart". */
458
458
  readonly reason: string;
459
459
  /**
460
- * Expiry auto-revoke if the participant doesn't finish in time.
461
- * 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`.
462
464
  */
463
- readonly ttlSeconds?: Duration;
465
+ readonly ttlSeconds?: number;
464
466
  }
465
467
  /**
466
468
  * Handle returned from `announce(...)` / `analyzing(...)` / etc.
@@ -507,7 +509,13 @@ export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposabl
507
509
  readonly range?: TargetRange;
508
510
  readonly meta?: Record<string, unknown>;
509
511
  };
510
- 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;
511
519
  readonly description?: string;
512
520
  /** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
513
521
  readonly data?: T;
@@ -565,8 +573,10 @@ export interface Claim {
565
573
  readonly status: ClaimStatus;
566
574
  /** What is being coordinated. */
567
575
  readonly target: EntityRef;
568
- /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. */
569
- 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;
570
580
  /** Peer-visible explanation of the work being performed. */
571
581
  readonly description?: string;
572
582
  /** Participant holding it. */
package/docs/api.md CHANGED
@@ -180,7 +180,7 @@ if (claim) {
180
180
 
181
181
  const handle = await ablo.weatherReports.claim({
182
182
  id: 'report_stockholm',
183
- action: 'editing',
183
+ reason: 'editing',
184
184
  ttl: '2m',
185
185
  });
186
186
  await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
package/docs/cli.md CHANGED
@@ -24,18 +24,23 @@ direct-connector commands are tagged **Direct Postgres**.
24
24
 
25
25
  `ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
26
26
  **log in** or **create an account** and approve, and the CLI provisions a
27
- **test + live key pair** (90-day, restricted) and stores them locally. This
28
- mirrors `stripe login`.
27
+ **test + live key pair** (90-day) and stores them locally. The test key is a
28
+ sandbox `sk_test_` key; the live key is a restricted `rk_live_` key (read-only
29
+ observation — `logs`, `status`), so a stolen config can't write to production.
30
+ This mirrors `stripe login`.
29
31
 
30
32
  | Command | What it does |
31
33
  | ------------------------ | -------------------------------------------------------------------------- |
32
34
  | `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
35
+ | `ablo login --project <slug>` | Same, but scope (and mint) the pair to a project, and make it active. |
33
36
  | `ablo logout` | Remove the stored keys. |
34
37
  | `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
35
38
  | `ablo mode [sandbox\|production]` | Switch the active environment. With no argument, prompts. |
36
39
 
37
- Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
38
- log in set `ABLO_API_KEY`, which always overrides the stored key.
40
+ Keys live in `~/.config/ablo/credentials.json` (mode `0600`), keyed by project
41
+ then environment; the non-secret `config.json` holds only the active mode and
42
+ project. In **CI**, don't log in — set `ABLO_API_KEY`, which always overrides
43
+ the stored key.
39
44
 
40
45
  ## Test vs live
41
46
 
@@ -47,6 +52,39 @@ the sandbox by design.
47
52
  The schema, however, is **shared** across the org — pushing a schema (from
48
53
  either environment) defines the same models sandbox and production see; only the rows differ.
49
54
 
55
+ ## Projects
56
+
57
+ An org can have multiple **projects**, each with its own isolated keys, schema,
58
+ and data. Keys are scoped to a project **at mint** and never re-scoped, so the
59
+ CLI keeps a separate credential profile per project — Stripe's
60
+ `login --project-name` model. The active project (set with `projects use`)
61
+ selects which profile every command authenticates with.
62
+
63
+ | Command | What it does |
64
+ | ----------------------------- | ---------------------------------------------------------------------------------- |
65
+ | `ablo projects list` | List the org's projects (marks the active one and the org-default). |
66
+ | `ablo projects create <slug>` | Create a project (`--name "Display Name"`). Its keys/schema/data are isolated. |
67
+ | `ablo projects use <slug>` | Switch the active project. `ablo projects use default` returns to the org-default. |
68
+ | `ablo login --project <slug>` | Mint and store a key pair for a project, and make it active. |
69
+
70
+ Because keys are fixed to a project, `projects use` only changes which profile
71
+ is active — it never re-scopes an existing key. Switch to a project you haven't
72
+ logged into yet and the CLI tells you to mint one:
73
+
74
+ ```bash
75
+ npx ablo projects use war-room
76
+ # ✓ now targeting project war-room (prj_…)
77
+ # No key stored for this project yet — run `ablo login --project war-room` to mint one.
78
+
79
+ npx ablo login --project war-room # mints + stores its key pair, keeps it active
80
+ ```
81
+
82
+ If you run a project-scoped command (`push`, `dev`) while the active project has
83
+ no key — but other projects do — the CLI **refuses** rather than silently
84
+ deploying with the wrong project's credential, and names the fix
85
+ (`ablo login --project <slug>`). In CI, an explicit `ABLO_API_KEY` bypasses
86
+ profiles entirely: it acts in whatever project it was minted for.
87
+
50
88
  ## Commands
51
89
 
52
90
  | Command | What it does | Flags |
@@ -54,6 +92,7 @@ either environment) defines the same models sandbox and production see; only the
54
92
  | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
55
93
  | `ablo login` / `logout` / `status` | Authentication & status (above). | — |
56
94
  | `ablo mode [sandbox\|production]` | Switch active environment. | — |
95
+ | `ablo projects list\|create\|use\|rename` | Manage projects and the active one (see [Projects](#projects)). Each project's keys/schema/data are isolated. | `--name "<display>"` (create/rename) |
57
96
  | `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>` |
58
97
  | `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 sandbox\|production` |
59
98
  | `ablo 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` |
@@ -146,7 +146,7 @@ the latest row, then hands you the fresh row — so you can't overwrite a change
146
146
  see. Options on the claim:
147
147
 
148
148
  - default `claim` waits in the fair queue and re-reads before handing you the row;
149
- - `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
149
+ - `{ queue: false }` rejects with `AbloClaimedError` instead of queuing;
150
150
  - `{ maxQueueDepth }` rejects if the wait line is already too deep.
151
151
 
152
152
  While waiting, schema clients learn when the claim clears from the live claim
@@ -166,7 +166,7 @@ All SDK errors extend `AbloError` and carry a stable `type`.
166
166
  | `AbloValidationError` | Invalid input or unsupported request shape. |
167
167
  | `AbloServerError` | Server-side 5xx. Retry with backoff if the operation is idempotent. |
168
168
  | `AbloStaleContextError` | Write was based on stale `readAt` state. Re-read and retry. |
169
- | `AbloClaimedError` | An active claim conflicted with `{ wait: false }`, the queue was too deep, or a claim wait timed out. |
169
+ | `AbloClaimedError` | An active claim conflicted with `{ queue: false }`, the queue was too deep, or a claim wait timed out. |
170
170
 
171
171
  ```ts
172
172
  import { AbloClaimedError } from '@abloatai/ablo';
@@ -280,7 +280,7 @@ Releasing **promotes the head of the queue**: the next waiter receives the claim
280
280
  **Example**
281
281
 
282
282
  ```ts
283
- const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', action: 'reviewing' });
283
+ const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', reason: 'reviewing' });
284
284
  const report = claim.data;
285
285
  try {
286
286
  const ok = await reviewExternally(report);
@@ -47,14 +47,14 @@ export async function markReady(reportId: string) {
47
47
  if (!report) return { status: 'not_found' };
48
48
 
49
49
  try {
50
- // wait: false → don't queue behind a current holder. If a human already
50
+ // queue: false → don't queue behind a current holder. If a human already
51
51
  // holds the row, claim rejects with AbloClaimedError (caught below), so the
52
- // agent yields instead of waiting. Omit it, or pass wait: true, to queue
53
- // behind them. action → the label observers see while we work.
52
+ // agent yields instead of waiting. Omit it, or pass queue: true, to queue
53
+ // behind them. reason → the label observers see while we work.
54
54
  await using claim = await ablo.weatherReports.claim({
55
55
  id: reportId,
56
- wait: false,
57
- action: 'marking_ready',
56
+ queue: false,
57
+ reason: 'marking_ready',
58
58
  });
59
59
  const claimed = claim.data;
60
60
 
@@ -120,7 +120,7 @@ export function ReportRow({ report: serverReport }: Props) {
120
120
  - The claim is visible to everyone: the UI reads it synchronously with
121
121
  `claim.state({ id })`, and it also arrives over the live stream.
122
122
  - `claim({ id })` makes writers take turns instead of racing — with
123
- `wait: false`, the agent simply yields when a human already holds the row.
123
+ `queue: false`, the agent simply yields when a human already holds the row.
124
124
  - The `update` made while the claim is held is stale-checked automatically, so a human's
125
125
  edit landing mid-run rejects the agent's write with a typed
126
126
  `AbloStaleContextError` instead of overwriting it.
@@ -43,7 +43,7 @@ const updateReport = tool({
43
43
  // The claim is released automatically when it goes out of scope.
44
44
  await using claim = await ablo.weatherReports.claim({
45
45
  id: reportId,
46
- action: 'editing',
46
+ reason: 'editing',
47
47
  ttl: '2m',
48
48
  });
49
49
  const claimed = claim.data;
@@ -37,10 +37,8 @@ import { defineSchema, model, z } from '@abloatai/ablo/schema';
37
37
 
38
38
  export const schema = defineSchema({
39
39
  weatherReports: model({
40
- id: z.string(),
41
40
  location: z.string(),
42
41
  status: z.enum(['pending', 'ready']),
43
- updatedAt: z.string(),
44
42
  }),
45
43
  });
46
44
  ```
@@ -57,8 +57,8 @@ import { ablo } from '@/lib/ablo';
57
57
  export async function markReady(id: string) {
58
58
  await using claim = await ablo.weatherReports.claim({
59
59
  id,
60
- wait: false,
61
- action: 'marking_ready',
60
+ queue: false,
61
+ reason: 'marking_ready',
62
62
  });
63
63
  const claimed = claim.data;
64
64
 
@@ -19,18 +19,18 @@ import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/s
19
19
 
20
20
  export const schema = defineSchema(
21
21
  {
22
- // A deck's rows form the group `deck:<id>` (the kind comes from `scope`).
22
+ // A deck's rows form the group `deck:<id>` (the kind comes from `groups.root`).
23
23
  decks: model(
24
24
  { title: z.string() },
25
25
  {},
26
- { orgScoped: true, scope: 'deck' },
26
+ { groups: { root: 'deck' } },
27
27
  ),
28
28
  // A slide has no group of its own. It inherits its deck's group via the
29
29
  // `parent` edge, so a slide write reaches everyone viewing the deck.
30
30
  slides: model(
31
31
  { deckId: z.string(), body: z.string() },
32
32
  { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
33
- { orgScoped: true },
33
+ {},
34
34
  ),
35
35
  },
36
36
  {
@@ -38,8 +38,8 @@ export async function completeReport(reportId: string) {
38
38
 
39
39
  await using claim = await ablo.weatherReports.claim({
40
40
  id: reportId,
41
- wait: false,
42
- action: 'completing',
41
+ queue: false,
42
+ reason: 'completing',
43
43
  });
44
44
  const claimed = claim.data;
45
45
 
@@ -60,9 +60,9 @@ the server has accepted it.
60
60
 
61
61
  The two options on the claim:
62
62
 
63
- - `wait: false` — skip this record if another claim is already in progress,
63
+ - `queue: false` — skip this record if another claim is already in progress,
64
64
  rather than queueing behind it. (The default queues.)
65
- - `action: 'completing'` — a human-readable label for what your worker is doing,
65
+ - `reason: 'completing'` — a human-readable label for what your worker is doing,
66
66
  visible to anyone reading `claim.state({ id })`.
67
67
 
68
68
  Because the worker uses the same schema and `claim()` as the UI, its writes sync
package/docs/identity.md CHANGED
@@ -47,18 +47,20 @@ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/s
47
47
 
48
48
  export const schema = defineSchema(
49
49
  {
50
- // A scope root: its rows form the group `deck:<id>` (kind from `scope`).
50
+ // A scope root: its rows form the group `deck:<id>` (kind from `groups.root`).
51
+ // Tenant isolation defaults to a row-local `organization_id` column, so no
52
+ // `policy` is needed here.
51
53
  decks: model(
52
54
  { title: z.string(), status: z.enum(['draft', 'published']) },
53
55
  {},
54
- { orgScoped: true, scope: 'deck' },
56
+ { groups: { root: 'deck' } },
55
57
  ),
56
58
  // A child: it has no group of its own; it inherits its deck's group via the
57
59
  // `parent` edge. A write to a slide reaches everyone viewing the deck.
58
60
  slides: model(
59
61
  { deckId: z.string() },
60
62
  { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
61
- { orgScoped: true },
63
+ {},
62
64
  ),
63
65
  },
64
66
  {
@@ -208,13 +210,13 @@ You never write a sync-group string for a row. You declare a model's *place* in
208
210
  the entity graph and the engine derives the groups its rows fan out on. Three
209
211
  declarations, in order of how often you reach for them:
210
212
 
211
- **`scope` — this model is a scope root.** Its rows form a group of their own.
212
- The kind comes from the model's `typename` by default, or pass a string to set
213
- it explicitly (use the string form when the wire kind differs from the typename,
214
- e.g. typename `SlideDeck` but group `deck:<id>`):
213
+ **`groups.root` — this model is a scope root.** Its rows form a group of their
214
+ own. The kind comes from the model's `typename` by default, or pass a string to
215
+ set it explicitly (use the string form when the wire kind differs from the
216
+ typename, e.g. typename `SlideDeck` but group `deck:<id>`):
215
217
 
216
218
  ```ts
217
- decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
219
+ decks: model({ title: z.string() }, {}, { groups: { root: 'deck' } });
218
220
  // a deck row → group `deck:<id>`
219
221
  ```
220
222
 
@@ -232,7 +234,7 @@ slides: model(
232
234
  deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership → inherit deck:<id>
233
235
  sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
234
236
  },
235
- { orgScoped: true },
237
+ {}, // default policy: row-local organization_id
236
238
  );
237
239
  ```
238
240
 
@@ -241,8 +243,8 @@ slides: model(
241
243
  > some required FKs are mere references. Containment is a fact only you know, so
242
244
  > it's declared, exactly as it is in OpenFGA/Zanzibar.
243
245
 
244
- **`grants` — a membership edge.** On a join model (e.g. `dataroomMember`), it
245
- says "this row grants a *subject* access to a *scope root*." Both are relation
246
+ **`groups.grants` — a membership edge.** On a join model (e.g. `dataroomMember`),
247
+ it says "this row grants a *subject* access to a *scope root*." Both are relation
246
248
  names on the model. The server resolves it at connect time — for user `U`, it
247
249
  finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
248
250
  set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
@@ -255,15 +257,19 @@ dataroomMember: model(
255
257
  member: relation.belongsTo('users', 'userId'),
256
258
  room: relation.belongsTo('datarooms', 'dataroomId'),
257
259
  },
258
- { orgScoped: true, grants: { subject: 'member', scope: 'room' } },
260
+ { groups: { grants: { subject: 'member', scope: 'room' } } },
259
261
  );
260
262
  ```
261
263
 
262
264
  For the rare group keyed on a plain field rather than a relation (per-recipient
263
- inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
265
+ inbox fan-out, say), there's a `groups: { roles: [entityRole({ kind, source })] }`
264
266
  escape hatch. For rows that inherit *tenancy* (not a sync group) through a
265
- foreign key without carrying `organization_id`, use `scopedVia` rather than
266
- `orgScoped: false` the latter exposes the whole table cross-tenant. See
267
+ foreign key without carrying `organization_id`, use `policy: { by: 'parent', fk,
268
+ parent }` rather than opting out of isolation. The old `orgScoped: false`
269
+ exposed the whole table cross-tenant, so `validate_schema` rejects the removed
270
+ options as `tenancy-option-removed` errors and steers you to `policy: { by:
271
+ 'parent' }` (FK inheritance) or, for genuinely global reference data, the
272
+ explicit `policy: { by: 'none' }`. See
267
273
  `packages/sync-engine/src/schema/model.ts` for the full option set.
268
274
 
269
275
  ## How identity reaches Ablo — the proxy model
@@ -393,8 +399,8 @@ an entity anchor on the models an agent operates on:
393
399
 
394
400
  ```ts
395
401
  // each scope-root model an agent edits forms a per-entity group
396
- documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
397
- decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
402
+ documents: model({ /* … */ }, {}, { groups: { root: 'document' } }),
403
+ decks: model({ /* … */ }, {}, { groups: { root: 'deck' } }),
398
404
  ```
399
405
 
400
406
  Then a run subscribes only to the entity groups for the rows it works on — a
@@ -484,9 +490,10 @@ an agent pointed at the entities it's working on. You **never hand-write**
484
490
  (it returns a participant handle with `.peers`). See
485
491
  [Coordination](./coordination.md).
486
492
 
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.
493
+ > **`groups.root` is the schema model option, not a client setting.**
494
+ > `groups: { root: 'deck' }` in `model(...)` declares a scope root
495
+ > ([Half 2](#half-2--per-model-scope-row--group)) it names the group
496
+ > (`deck:<id>`) that the mechanisms above then subscribe to.
490
497
  > There is no `Ablo({ scope })` constructor option. The lifecycle filter on
491
498
  > [`list()`](./api.md#model-methods) is a separate axis named **`state`**
492
499
  > (`'live' | 'archived' | 'all'`, GitHub's open/closed/all), precisely so it
package/docs/index.md CHANGED
@@ -99,4 +99,3 @@ Three things stay true no matter how you use Ablo:
99
99
  - [README](../README.md) — product overview and first example.
100
100
  - [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
101
101
  - [Changelog](../CHANGELOG.md) — what shipped recently.
102
- - [Roadmap](./roadmap.md) — what's planned next.