@abloatai/ablo 0.12.0 → 0.14.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 (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +3 -3
  4. package/dist/BaseSyncedStore.js +39 -32
  5. package/dist/batching/index.d.ts +57 -0
  6. package/dist/batching/index.js +150 -0
  7. package/dist/cli.cjs +158 -40
  8. package/dist/client/Ablo.d.ts +16 -25
  9. package/dist/client/Ablo.js +1 -1
  10. package/dist/client/auth.js +11 -0
  11. package/dist/client/createModelProxy.d.ts +33 -8
  12. package/dist/client/createModelProxy.js +4 -4
  13. package/dist/errorCodes.d.ts +3 -1
  14. package/dist/errorCodes.js +10 -1
  15. package/dist/schema/index.d.ts +2 -2
  16. package/dist/schema/index.js +2 -2
  17. package/dist/schema/model.d.ts +38 -84
  18. package/dist/schema/model.js +12 -12
  19. package/dist/schema/roles.d.ts +49 -0
  20. package/dist/schema/roles.js +21 -0
  21. package/dist/schema/schema.d.ts +1 -1
  22. package/dist/schema/schema.js +1 -1
  23. package/dist/schema/serialize.d.ts +4 -2
  24. package/dist/schema/serialize.js +4 -2
  25. package/dist/schema/sugar.d.ts +7 -28
  26. package/dist/schema/sugar.js +2 -7
  27. package/dist/schema/sync-delta-row.d.ts +2 -0
  28. package/dist/schema/sync-delta-row.js +2 -1
  29. package/dist/schema/tenancy.d.ts +67 -28
  30. package/dist/schema/tenancy.js +93 -23
  31. package/dist/server/commit.d.ts +8 -3
  32. package/docs/api.md +7 -6
  33. package/docs/cli.md +43 -4
  34. package/docs/client-behavior.md +2 -2
  35. package/docs/coordination.md +12 -12
  36. package/docs/examples/agent-human.md +6 -6
  37. package/docs/examples/ai-sdk-tool.md +1 -1
  38. package/docs/examples/existing-python-backend.md +0 -2
  39. package/docs/examples/nextjs.md +2 -2
  40. package/docs/examples/scoped-agent.md +3 -3
  41. package/docs/examples/server-agent.md +4 -4
  42. package/docs/identity.md +27 -20
  43. package/docs/index.md +0 -1
  44. package/docs/integration-guide.md +12 -9
  45. package/docs/interaction-model.md +1 -1
  46. package/docs/mcp.md +17 -5
  47. package/docs/quickstart.md +3 -3
  48. package/docs/react.md +69 -0
  49. package/llms.txt +2 -3
  50. package/package.json +8 -2
  51. package/docs/mcp/claude-code.md +0 -35
  52. package/docs/mcp/cursor.md +0 -35
  53. package/docs/mcp/windsurf.md +0 -33
  54. package/docs/roadmap.md +0 -55
  55. package/docs/the-loop.md +0 -21
  56. 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
package/docs/api.md CHANGED
@@ -124,10 +124,11 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
124
124
  | `id` | string | Unique identifier for the claim. |
125
125
  | `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
126
126
  | `target` | `{ type, id, field? }` | What is being coordinated. |
127
- | `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
127
+ | `reason` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
128
128
  | `heldBy` | string | Participant id holding the claim. |
129
129
  | `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
130
- | `expiresAt` | string | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
130
+ | `createdAt` | number? | Ms-epoch the holder opened it. Optional derived shapes may omit it. |
131
+ | `expiresAt` | number | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
131
132
 
132
133
  ```json
133
134
  {
@@ -135,10 +136,10 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
135
136
  "id": "claim_3MtwBwLkdIwHu7ix",
136
137
  "status": "active",
137
138
  "target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
138
- "action": "editing",
139
+ "reason": "editing",
139
140
  "heldBy": "agent:report-writer",
140
141
  "participantKind": "agent",
141
- "expiresAt": "1716580000000"
142
+ "expiresAt": 1716580000000
142
143
  }
143
144
  ```
144
145
 
@@ -175,12 +176,12 @@ Reads never block on a claim — to wait for a row to free up, `claim({ id })` i
175
176
  const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
176
177
  if (claim) {
177
178
  claim.heldBy;
178
- claim.action;
179
+ claim.reason;
179
180
  }
180
181
 
181
182
  const handle = await ablo.weatherReports.claim({
182
183
  id: 'report_stockholm',
183
- action: 'editing',
184
+ reason: 'editing',
184
185
  ttl: '2m',
185
186
  });
186
187
  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';
@@ -72,23 +72,23 @@ a model row. It's what `claim.state()` returns and what observers render.
72
72
  | `id` | `string` | The claim id (distinct from the target row id). |
73
73
  | `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. The other three are terminal states you only see on a claim you just finished — `committed` (released after a successful write), `expired` (TTL lapsed), `canceled` (released early). |
74
74
  | `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
75
- | `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
75
+ | `reason` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. Serialized on the wire as `action`. |
76
76
  | `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
77
77
  | `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
78
78
  | `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
79
- | `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
80
- | `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. |
79
+ | `createdAt` | `number?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
80
+ | `expiresAt` | `number` | 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. |
81
81
 
82
82
  ```jsonc
83
83
  {
84
84
  "id": "claim_8fJ2",
85
85
  "status": "active",
86
86
  "target": { "model": "weatherReports", "id": "report_stockholm" },
87
- "action": "editing",
87
+ "reason": "editing",
88
88
  "heldBy": "agent:forecaster",
89
89
  "participantKind": "agent",
90
- "createdAt": "1748160000000",
91
- "expiresAt": "1748160030000"
90
+ "createdAt": 1748160000000,
91
+ "expiresAt": 1748160030000
92
92
  }
93
93
  ```
94
94
 
@@ -129,9 +129,9 @@ so two claimers can't both think they won.
129
129
  | name | type | required | description |
130
130
  |---|---|---|---|
131
131
  | `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
132
- | `options.action` | `string` | no | Phase shown to observers (default `'editing'`). |
132
+ | `options.reason` | `string` | no | Phase shown to observers (default `'editing'`). Serialized on the wire as `action`. |
133
133
  | `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
134
- | `options.wait` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
134
+ | `options.queue` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
135
135
  | `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
136
136
  | `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
137
137
 
@@ -209,7 +209,7 @@ is free.
209
209
 
210
210
  ```ts
211
211
  const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
212
- if (who) console.log(`${who.heldBy} is ${who.action}`);
212
+ if (who) console.log(`${who.heldBy} is ${who.reason}`);
213
213
  ```
214
214
 
215
215
  Returns the active claim state when the row is held, or `null` when it's free:
@@ -219,10 +219,10 @@ Returns the active claim state when the row is held, or `null` when it's free:
219
219
  "id": "claim_8fJ2",
220
220
  "status": "active",
221
221
  "target": { "model": "weatherReports", "id": "report_stockholm" },
222
- "action": "editing",
222
+ "reason": "editing",
223
223
  "heldBy": "agent:forecaster",
224
224
  "participantKind": "agent",
225
- "expiresAt": "1748160030000"
225
+ "expiresAt": 1748160030000
226
226
  }
227
227
  ```
228
228
 
@@ -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.