@abloatai/ablo 0.30.2 → 0.32.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 (42) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +53 -12
  3. package/README.md +12 -14
  4. package/dist/cli.cjs +1787 -1329
  5. package/dist/coordination/schema.d.ts +4 -2
  6. package/dist/coordination/schema.js +23 -9
  7. package/dist/errorCodes.d.ts +1 -0
  8. package/dist/errorCodes.js +2 -1
  9. package/dist/schema/index.d.ts +1 -1
  10. package/dist/schema/index.js +1 -1
  11. package/dist/schema/schema.d.ts +57 -0
  12. package/dist/schema/schema.js +53 -0
  13. package/dist/schema/select.js +1 -0
  14. package/dist/schema/serialize.d.ts +3 -1
  15. package/dist/schema/serialize.js +7 -1
  16. package/dist/source/adapters/drizzle.js +4 -3
  17. package/dist/source/adapters/kysely.js +4 -4
  18. package/dist/source/adapters/prisma.js +3 -3
  19. package/dist/source/idempotency.d.ts +19 -4
  20. package/dist/source/idempotency.js +19 -4
  21. package/dist/source/migrations.d.ts +4 -1
  22. package/dist/source/migrations.js +36 -5
  23. package/docs/agent-messaging.md +1 -2
  24. package/docs/api.md +4 -4
  25. package/docs/audit.md +35 -23
  26. package/docs/client-behavior.md +7 -4
  27. package/docs/concurrency-convention.md +3 -3
  28. package/docs/data-sources.md +141 -102
  29. package/docs/examples/agent-human.md +2 -2
  30. package/docs/examples/ai-sdk-tool.md +1 -1
  31. package/docs/examples/nextjs.md +1 -1
  32. package/docs/examples/server-agent.md +2 -2
  33. package/docs/groups.md +160 -0
  34. package/docs/guarantees.md +9 -7
  35. package/docs/how-it-works.md +110 -0
  36. package/docs/integration-guide.md +8 -7
  37. package/docs/interaction-model.md +3 -3
  38. package/docs/quickstart.md +38 -31
  39. package/docs/schema-contract.md +7 -5
  40. package/docs/webhooks.md +23 -22
  41. package/llms.txt +10 -4
  42. package/package.json +1 -1
package/docs/audit.md CHANGED
@@ -2,33 +2,40 @@
2
2
 
3
3
  The audit log records who changed what in your org, and when — including
4
4
  changes an AI agent made on a person's behalf. Every change is one row, and the
5
- rows are signed in a chain so you can later prove the history wasn't altered.
6
- You can filter it, page through it, and export it.
7
-
8
- Every commit becomes one row.
5
+ rows are chained with a keyed hash (HMAC-SHA256) so you can later prove the
6
+ history wasn't altered. You can filter it, page through it, and export it.
9
7
 
10
8
  ## Row shape
11
9
 
10
+ Each stored row carries both the attribution fields — who acted, on whose behalf,
11
+ with which key — and the chain columns that make the log tamper-evident:
12
+
12
13
  ```ts
13
14
  {
14
- occurredAt: '2026-05-14T14:22:01.034Z',
15
- actorKind: 'user' | 'agent' | 'system',
16
- actorId: string,
17
- onBehalfOfKind: 'user' | 'agent' | 'system' | null,
18
- onBehalfOfId: string | null,
19
- capabilityId: string | null, // the API key/capability used for the write
20
- capabilityLabel: string | null, // its human-readable name, for scanning the log
21
- delegationChainRoot: string | null, // always points at a human
22
- actionType: string, // e.g. 'weatherReport.update'
23
- modelName: string | null, // e.g. 'claude-opus-4-7'
24
- diffSummary: unknown,
25
- // tamper-evident
26
- chainSeq: number,
27
- prevHash: string,
28
- rowHash: string,
15
+ id: string,
16
+ occurredAt: '2026-05-14T14:22:01.034Z',
17
+ actorKind: 'user' | 'agent' | 'system',
18
+ actorId: string,
19
+ onBehalfOfKind: 'user' | 'agent' | 'system' | null,
20
+ onBehalfOfId: string | null,
21
+ capabilityId: string | null, // the API key/capability used for the write
22
+ capabilityLabel: string | null, // its human-readable name, for scanning the log
23
+ delegationChainRootUserId: string | null, // always points at a human
24
+ actionType: string, // e.g. 'weatherReport.update'
25
+ modelName: string, // e.g. 'claude-opus-4-8'
26
+ confirmationState: 'auto' | 'previewed' | 'approved' | 'required_human_approval' | 'auto_historical',
27
+ diffSummary: unknown,
28
+ // chain columns — carried on every stored row, checked by verify (below)
29
+ chainSeq: number,
30
+ prevHash: string,
31
+ rowHash: string,
29
32
  }
30
33
  ```
31
34
 
35
+ `confirmationState` records whether an agent's write ran on its own (`auto`), was
36
+ shown first (`previewed`), was signed off (`approved`), or is still waiting on a
37
+ person (`required_human_approval`) — it's also a filter on the list endpoint.
38
+
32
39
  ## Delegation chain
33
40
 
34
41
  Every action traces back to a human. Even when an agent makes the change,
@@ -53,9 +60,14 @@ or, on tamper:
53
60
 
54
61
  ```json
55
62
  { "ok": false, "brokenAtSeq": 8419, "reason": "hash_mismatch",
56
- "expectedHash": "sha256:…", "foundHash": "sha256:…" }
63
+ "expectedHash": "a3f1c9…", "foundHash": "b7e04d…" }
57
64
  ```
58
65
 
66
+ Hashes come back as plain hex. `expectedHash`/`foundHash` accompany a
67
+ `hash_mismatch` or `prev_hash_mismatch`; the other reasons (`sequence_gap`,
68
+ `missing_root`, `no_rows`) stand on their own. Recomputing a row's hash needs the
69
+ org's HMAC key, so verification runs where that secret is available.
70
+
59
71
  ## Filter and paginate
60
72
 
61
73
  The dashboard at `/[orgSlug]/audit` is the UI for this. The same filters
@@ -82,6 +94,6 @@ JSON `GET` endpoint above using `nextCursor`.
82
94
  ## Compliance posture
83
95
 
84
96
  The [audit log landing page](/audit-log) is the marketing-side description.
85
- The verifier's hash algorithm and chain semantics live in the
86
- `@ablo/audit-chain` package — embeddable if you need to verify chains in a
87
- detached service.
97
+ The HMAC-SHA256 chain algorithm and its semantics live in the
98
+ `@ablo/audit-chain` package — the reference implementation, embeddable if you
99
+ need to verify chains in a detached service (given the org's HMAC key).
@@ -39,8 +39,9 @@ Common options:
39
39
  | `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. |
40
40
 
41
41
  Your database connects out of band — through logical replication (`npx ablo
42
- connect`) or a signed [Data Source](./data-sources.md) endpoint so the client
43
- holds only `apiKey`, never a connection string. See
42
+ connect`), or the signed [Data Source](./data-sources.md) endpoint as the
43
+ fallback for databases that can't grant replication — so the client holds only
44
+ `apiKey`, never a connection string. See
44
45
  [Connect Your Database](./data-sources.md) for the full setup.
45
46
 
46
47
  ## Model Methods
@@ -95,8 +96,10 @@ such as `useAblo((ablo) => ablo.weatherReports.claim.state({ id }))`
95
96
  receive active claim state. There is
96
97
  no extra multiplayer setup beyond routing shared state through Ablo.
97
98
 
98
- If an app writes directly to its database, Ablo cannot coordinate that write
99
- until the app reports it through Data Source events.
99
+ Writes flow through Ablo's commit chokepoint and land in your database, so every
100
+ actor routing through Ablo is coordinated. The one write it can't coordinate is
101
+ one made directly against your database, around Ablo — the WAL echo still catches
102
+ it for reads, but it bypasses claims and ordering.
100
103
 
101
104
  ## Per-Write Options
102
105
 
@@ -161,7 +161,7 @@ and `currentValues` is empty (re-read the group).
161
161
 
162
162
  What the convention **guarantees**, and where it **stops**:
163
163
 
164
- 1. **Engine surfaces, actor decides.** For `flag`/`merge` the engine never
164
+ 1. **Engine surfaces, actor decides.** Under `notify` the engine never
165
165
  repairs, merges, or re-plans. It reports `currentValues` and the actor (agent
166
166
  or human) owns the resolution. The engine does not distinguish them — it is
167
167
  actor-neutral by design.
@@ -179,7 +179,7 @@ What the convention **guarantees**, and where it **stops**:
179
179
  shared database, which are inherently reversible (prior value in
180
180
  `sync_deltas`). **Irreversible external side-effects** (emails, payments,
181
181
  third-party calls) are *out of scope* — the engine cannot hold or undo them,
182
- so they must not be gated by `flag`/`merge`.
182
+ so they must not be gated by `notify`.
183
183
 
184
184
  5. **Defaults.** A plain write (no `readAt`) is last-writer-wins with **no**
185
185
  check. A guarded write with `readAt` but no `onStale` defaults to `reject`
@@ -202,7 +202,7 @@ What the convention **guarantees**, and where it **stops**:
202
202
  These are deliberately left open; they change behavior and are the user's call.
203
203
 
204
204
  - **Default disposition for agents.** Should an agent-participant guarded write
205
- default to `flag` (philosophy-aligned: surface, don't force) instead of
205
+ default to `notify` (philosophy-aligned: surface, don't overwrite) instead of
206
206
  `reject` (back-compat)? Trade-off: alignment vs. a behavior change for existing
207
207
  agent callers.
208
208
  - **Read-deps through the policy seam.** Should read-set conflicts also pass
@@ -1,31 +1,49 @@
1
1
  # Connect Your Database
2
2
 
3
- **Connect your database = Postgres logical replication.** That is the one way.
4
- Ablo reads your write-ahead log (WAL) and **never runs DDL, never owns your
5
- schema, and never migrates it**. Your application keeps writing to its own
6
- Postgres through its own backend, exactly as it does today; Ablo only tails the
7
- changes and fans the confirmed rows out to every connected human and agent. This
8
- is the same model ElectricSQL, PowerSync, and Zero use — a publication plus a
9
- replication slot. Ablo consumes the logical-replication stream; your application
10
- continues to own the write path.
3
+ You write through Ablo, and Ablo writes to your Postgres. A call to
4
+ `ablo.<model>.create / update / delete` enters Ablo's commit chokepoint where
5
+ claims, ordering, and idempotency are enforced and Ablo applies the change to
6
+ your database through a scoped role. Your rows live in your database, which stays
7
+ the system of record. Ablo reads your write-ahead log (WAL) to confirm each write
8
+ landed and to keep every connected human and agent current.
9
+
10
+ Ablo writes your **rows**; it never touches your **schema**. It runs no DDL and no
11
+ migrations — your migration tool stays in charge of the shape of your database.
12
+ Ablo only writes rows into tables you already have, through a role scoped to
13
+ exactly that.
14
+
15
+ > **Just trying Ablo?** You don't need a database to start. Pass an `apiKey` only,
16
+ > and Ablo keeps your rows in its own log so you can build the whole app today —
17
+ > like Stripe test mode. For a sandbox you can throw away, point Ablo at a separate
18
+ > or local Postgres. Connect your production database (below) when you're ready for
19
+ > it to be the system of record.
20
+
21
+ Connecting sets up two capabilities on your Postgres: **logical replication**, so
22
+ Ablo can read and confirm, and a **scoped DML role**, so Ablo can write. `ablo
23
+ connect` prints the exact SQL. `ablo connect apply` runs it for you.
24
+
25
+ ## Connect in one command
11
26
 
12
- > **Just trying Ablo?** You don't need a database at all to start. The hosted
13
- > **sandbox** can host rows in Ablo's test plane — pass an `apiKey` only and omit
14
- > any database setup, like Stripe test mode. Connect your Postgres with logical
15
- > replication (below) when you're ready for it to be the system of record.
27
+ ```bash
28
+ npx ablo connect apply --url postgres://admin:...@host:5432/db
29
+ ```
16
30
 
17
- Your database stays the system of record. Ablo never becomes a second source of
18
- truth and never takes over operating your Postgres.
31
+ Pass an admin connection string with `--url` and it creates the publication, the
32
+ two scoped roles, and the grants, turns on logical decoding where it can, registers
33
+ both scoped roles with Ablo, and proves the setup by reconnecting and reading back.
34
+ The admin credential is used on this machine only and never persisted — nothing is
35
+ written to your `.env`, which keeps holding only `ABLO_API_KEY`. Pass `--show-sql`
36
+ to see every statement first, or drop `--apply` to print the SQL and run it
37
+ yourself. Rotate the scoped passwords any time with `ablo connect rotate`.
19
38
 
20
- ## The five steps (mirrors Zero's install flow)
39
+ The rest of this page is what that command sets up, step by step, for when you want
40
+ to run it by hand or review exactly what changes.
21
41
 
22
- You run the setup once against your own database, then point Ablo at it. The CLI
23
- prints the exact SQL and validates it for you — you never hand-craft replication
24
- internals.
42
+ ## The setup, step by step
25
43
 
26
44
  ### 1. Enable logical decoding
27
45
 
28
- Turn on logical WAL so Ablo can decode row changes:
46
+ Turn on logical WAL so Ablo can decode row changes and confirm writes:
29
47
 
30
48
  ```sql
31
49
  ALTER SYSTEM SET wal_level = 'logical';
@@ -34,21 +52,20 @@ ALTER SYSTEM SET wal_level = 'logical';
34
52
  `wal_level` is **not reloadable** — you must **restart Postgres** for it to take
35
53
  effect. On Amazon RDS / Aurora you can't `ALTER SYSTEM`; set
36
54
  `rds.logical_replication = 1` in the instance's parameter group instead, then
37
- reboot.
55
+ reboot. (`ablo connect apply` attempts this for you and, where a managed
56
+ provider refuses, hands you the one remaining step.)
38
57
 
39
- ### 2. Run `ablo connect` to get the publication / slot / role SQL
58
+ ### 2. Run `ablo connect` for the publication and roles
40
59
 
41
60
  ```bash
42
61
  npx ablo connect
43
62
  ```
44
63
 
45
- `ablo connect` prints the exact, copy-pasteable setup SQL for **your** Postgres
46
- and nothing else it does not ask for a connection-string flavor, an adapter, or
47
- a driver, because logical replication is how you connect. Run the printed SQL
48
- against your database (as a superuser / the DB owner). It does three things:
64
+ `ablo connect` prints the exact, copy-pasteable setup SQL for **your** Postgres.
65
+ Run it against your database as a superuser or the DB owner. It creates:
49
66
 
50
- - **A publication** naming the tables Ablo should read (`ablo_publication`, the
51
- single canonical name the runtime subscribes to):
67
+ - **A publication** naming the tables Ablo reads and confirms against
68
+ (`ablo_publication`, the single canonical name the runtime subscribes to):
52
69
 
53
70
  ```sql
54
71
  CREATE PUBLICATION "ablo_publication" FOR ALL TABLES;
@@ -56,53 +73,79 @@ against your database (as a superuser / the DB owner). It does three things:
56
73
 
57
74
  Scope it to a subset with `npx ablo connect --tables a,b,c`.
58
75
 
59
- - **A least-privilege replication role** — it can stream replication and `SELECT`,
60
- nothing more. You choose the password; it never passes through Ablo's CLI or
61
- servers:
76
+ - **A replication role** — it streams the WAL and `SELECT`s, nothing more. This is
77
+ the role Ablo reads and confirms through. You choose the password; it never
78
+ passes through Ablo's CLI or servers:
62
79
 
63
80
  ```sql
64
81
  CREATE ROLE "ablo_replicator" WITH REPLICATION LOGIN PASSWORD '<password>';
65
82
  GRANT SELECT ON ALL TABLES IN SCHEMA public TO "ablo_replicator";
66
- ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO "ablo_replicator";
67
83
  ```
68
84
 
69
- Rename it with `--role <name>`. On Amazon RDS the `REPLICATION` attribute is
70
- granted, not set directly: `GRANT rds_replication TO "ablo_replicator";`.
85
+ On Amazon RDS the `REPLICATION` attribute is granted, not set directly:
86
+ `GRANT rds_replication TO "ablo_replicator";`.
87
+
88
+ - **A scoped writer role** — the role Ablo writes your rows through. It gets row
89
+ DML (`SELECT, INSERT, UPDATE, DELETE`) and the sync ledger, and nothing else: no
90
+ `REPLICATION`, no schema `CREATE`, `NOSUPERUSER NOBYPASSRLS`, row security on. It
91
+ can change rows in your tables; it cannot change your database:
92
+
93
+ ```sql
94
+ CREATE ROLE "ablo_writer" WITH LOGIN PASSWORD '<write-password>'
95
+ NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOREPLICATION NOINHERIT;
96
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "ablo_writer";
97
+ ```
98
+
99
+ Rename either role with `--role <name>` / `--write-role <name>`.
71
100
 
72
101
  The **replication slot** is created and owned by Ablo's runtime when it first
73
- subscribes with this role — you don't pre-create it. The publication and the role
74
- are the only objects the recipe asks you to create.
102
+ subscribes — you don't pre-create it.
75
103
 
76
- ### 3. Validate with `ablo connect --check`
104
+ ### 3. Register the database with Ablo
77
105
 
78
- Put the replication role's connection string in `DATABASE_URL`, then verify the
79
- database is replication-ready:
106
+ `ablo connect apply` already did this. If you ran the SQL by hand instead, hand
107
+ Ablo both scoped connection strings once — the replication role it reads and
108
+ confirms through, and the writer role it lands your rows through. Set them just
109
+ long enough to register:
80
110
 
81
111
  ```bash
82
- npx ablo connect --check
112
+ export ABLO_REPLICATION_DATABASE_URL=... # the replication role
113
+ export ABLO_WRITE_DATABASE_URL=... # the writer role
114
+ npx ablo connect register
83
115
  ```
84
116
 
85
- This connects and checks the four invariants, printing a green checklist or the
86
- precise per-item fix:
117
+ `--register` posts them to Ablo, which holds them encrypted and uses them to read
118
+ and write your database. Ablo holds them from here, so you can drop both from your
119
+ environment — your app keeps only `ABLO_API_KEY`. The role passwords are generated
120
+ for you and never printed; rotate them any time with `ablo connect rotate`. After
121
+ this, Ablo does all the connecting.
122
+
123
+ ### 4. Verify readiness with `ablo connect check`
124
+
125
+ ```bash
126
+ npx ablo connect check
127
+ ```
128
+
129
+ `--check` needs only `ABLO_API_KEY`. It asks Ablo to check the database it now
130
+ holds, from the same infrastructure replication runs on, and prints a green
131
+ checklist or the precise per-item fix:
87
132
 
88
133
  - `wal_level` is `logical`
89
134
  - the `ablo_publication` publication exists
90
- - the `DATABASE_URL` role has the `REPLICATION` attribute
135
+ - the replication role has the `REPLICATION` attribute
91
136
  - every published table has a usable `REPLICA IDENTITY` (a primary key, or
92
137
  `REPLICA IDENTITY FULL`) so `UPDATE`/`DELETE` can replicate
138
+ - the writer role is DML-ready — scoped, non-superuser, with the idempotency
139
+ ledger in place
93
140
 
94
- Re-run it until every item is green.
95
-
96
- ### 4. Point Ablo at the database with the replication role
141
+ Because Ablo checks from its own network, a database your own machine can't reach —
142
+ IPv6-only, IP-allowlisted, behind a VPN — still verifies. Re-run it until every
143
+ item is green.
97
144
 
98
- Give Ablo the connection string for the **replication role** you created — a
99
- `REPLICATION`-attributed role that streams the WAL and `SELECT`s, nothing more
100
- (not a read-only account; see the privilege note below). The same value `--check`
101
- validated:
145
+ Your **app** holds only the API key never a connection string:
102
146
 
103
147
  ```bash
104
148
  # .env — server runtime only, never the browser
105
- DATABASE_URL=postgres://ablo_replicator:<password>@host:5432/db?sslmode=require
106
149
  ABLO_API_KEY=sk_live_...
107
150
  ```
108
151
 
@@ -116,28 +159,33 @@ export const ablo = Ablo({
116
159
  });
117
160
  ```
118
161
 
119
- You define an Ablo schema with `defineSchema`, `model`, and Zod. The Ablo schema
120
- describes **only your synced, collaborative models** the rows Ablo coordinates
121
- and fans out in realtime. It is *not* your whole-database schema and does *not*
122
- replace your `schema.prisma` (or your Drizzle schema). Your auth, billing, and
123
- any other tables stay in your own ORM schema, owned by your own migrations.
124
- `ablo check` reflects this — it reports tables you didn't declare as "ignored /
125
- owned by you," which is exactly right.
162
+ The Ablo schema describes **only your synced, collaborative models** the rows
163
+ Ablo coordinates and fans out in realtime. It is *not* your whole-database schema
164
+ and does *not* replace your `schema.prisma` (or Drizzle schema). Your auth,
165
+ billing, and other tables stay in your own ORM schema, owned by your own
166
+ migrations. `ablo check` reflects this it reports tables you didn't declare as
167
+ "ignored / owned by you," which is exactly right.
126
168
 
127
- ### 5. Writes go through your own backend
169
+ ### 5. Write through `ablo.<model>`
128
170
 
129
- Your application writes to its Postgres the way it always has — its own ORM, its
130
- own backend, its own transactions. Ablo does not intercept or proxy those writes.
131
- It observes them on the WAL and fans the confirmed rows out to connected clients.
132
- The read, claim, and coordination surface (`ablo.<model>`) layers on top:
171
+ Every change goes through Ablo. The write enters the commit chokepoint, Ablo
172
+ applies it to your Postgres through the writer role, and the WAL echo confirms it
173
+ landed:
133
174
 
134
175
  ```ts
176
+ // Enters the chokepoint (claims, ordering, idempotency), lands in your Postgres.
177
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 } });
178
+
179
+ // Block until your database has it and the WAL echo confirms.
180
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 }, wait: 'confirmed' });
181
+
182
+ // Reads are live off the same stream.
135
183
  const report = ablo.weatherReports.get('report_stockholm');
136
- const active = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
137
184
  ```
138
185
 
139
- For the typed read/claim/write surface itself, see
140
- [Quickstart](./quickstart.md) and [Schema Contract](./schema-contract.md).
186
+ A commit is accepted the moment Ablo takes it (`queued`); it becomes `confirmed`
187
+ once the row appears on your WAL. See [Guarantees](./guarantees.md) for what each
188
+ state means and when to wait.
141
189
 
142
190
  ## What Ablo touches in your database — the honest footprint
143
191
 
@@ -145,9 +193,10 @@ This is the complete list. Nothing else.
145
193
 
146
194
  | Object | What it is | Owned by |
147
195
  |---|---|---|
148
- | `ablo_publication` | A Postgres publication naming the tables Ablo reads. | You create it (step 2). |
196
+ | `ablo_publication` | A publication naming the tables Ablo reads and confirms against. | You create it (step 2). |
197
+ | `ablo_replicator` role | A `REPLICATION` + `SELECT` role Ablo reads and confirms through. | You create it (step 2). |
198
+ | `ablo_writer` role | A scoped DML role Ablo writes your rows through — row DML + ledger, nothing more. | You create it (step 2). |
149
199
  | Replication slot | A logical slot Ablo subscribes through to track its WAL position. | Ablo's runtime creates it on first connect. |
150
- | `ablo_replicator` role | A least-privilege `REPLICATION` + `SELECT` role. | You create it (step 2). |
151
200
  | `wal_level = logical` | A server setting that **requires a restart**. | You set it (step 1). |
152
201
 
153
202
  Operational reality you should know up front:
@@ -155,30 +204,26 @@ Operational reality you should know up front:
155
204
  - **`wal_level = logical` needs a restart.** It is a one-time, server-wide change
156
205
  and is not reloadable.
157
206
  - **A replication slot retains WAL.** While Ablo is connected, the slot holds the
158
- WAL it hasn't yet acknowledged. If Ablo is disconnected for a long time, that
159
- WAL accumulates and consumes disk. **Ablo monitors slot lag and WAL retention**
160
- and surfaces it so you're never surprised by disk pressure; an abandoned slot is
161
- dropped rather than left to grow unbounded.
162
- - **The role's privilege footprint is narrow and precise not a "read-only"
163
- account.** It carries the `REPLICATION` attribute, which lets it stream the WAL
164
- and `SELECT`; it cannot `INSERT`/`UPDATE`/`DELETE`, run DDL, or own objects, and
165
- the recipe never grants it more. (For a security review, state it that way — a
166
- logical-replication role is a real privilege, just a tightly-scoped one — rather
167
- than calling it "read-only", which a reviewer will correctly push back on.)
168
-
169
- What Ablo explicitly does **not** do:
170
-
171
- - It **never runs DDL** against your database.
172
- - It **never owns or migrates your schema** — your migration tool stays in charge.
173
- - It **never writes your rows** — writes are yours, through your backend.
207
+ WAL it hasn't yet acknowledged. If Ablo is disconnected for a long time, that WAL
208
+ accumulates and consumes disk. **Ablo monitors slot lag and WAL retention** and
209
+ surfaces it, so disk pressure never surprises you; an abandoned slot is dropped
210
+ rather than left to grow unbounded.
211
+ - **The writer role changes rows, not your database.** It carries row DML plus the
212
+ sync ledger and nothing more — no `REPLICATION`, no DDL, no object ownership, and
213
+ it runs with row security on and `NOBYPASSRLS`. It is a real, tightly-scoped
214
+ privilege describe it that way in a security review.
215
+
216
+ Ablo runs **no DDL** and **owns no schema**: your migration tool stays in charge of
217
+ the shape of your database, and Ablo writes only rows, only into tables you already
218
+ have.
174
219
 
175
220
  ## What Ablo stores on its side
176
221
 
177
222
  Your schema *definition* (model names, fields, types — pushed with `ablo push`),
178
223
  your hashed API keys, a safe projection of the connection registration (host,
179
224
  database, schema — the connection string itself is sealed and never echoed back),
180
- the replication slot position, and the commit log that drives sync. Never your
181
- rows.
225
+ the replication slot position, and the ordered transaction log that drives sync and
226
+ coordination. Your rows live in your database.
182
227
 
183
228
  > **Postgres replication status: Preview.** Registration, readiness checks, and
184
229
  > the server replication fleet are implemented and boot-wired. Preview describes
@@ -186,6 +231,17 @@ rows.
186
231
  > [internal/postgres-replication.md](./internal/postgres-replication.md) for the
187
232
  > architecture and operational invariants.
188
233
 
234
+ ## When your database can't grant replication
235
+
236
+ Some managed databases won't grant a `REPLICATION` role. For those, Ablo connects
237
+ through a **signed Data Source endpoint** instead: you expose one signed HTTP route
238
+ built from your ORM (`prismaDataSource` / `drizzleDataSource`, with `ablo_outbox` /
239
+ `ablo_idempotency` bookkeeping), and Ablo writes and confirms through it — same
240
+ `ablo.<model>` surface, same commit chokepoint, same `queued` → `confirmed`
241
+ lifecycle. It needs no replication setup, which is exactly why it's the fallback:
242
+ reach for it only when logical replication isn't available, and prefer `ablo
243
+ connect` everywhere else.
244
+
189
245
  ## Next steps
190
246
 
191
247
  - [Quickstart](./quickstart.md) — connect and write through `ablo.<model>`.
@@ -194,20 +250,3 @@ rows.
194
250
  - [Guarantees](./guarantees.md) — what confirmed writes and stale checks mean.
195
251
  - [Integration Guide](./integration-guide.md) — the full app, React, multiplayer,
196
252
  and agent path.
197
-
198
- ---
199
-
200
- ## Legacy / not recommended
201
-
202
- > **Use logical replication instead** (top of this page). The shapes below
203
- > predate the single connect path. They are documented only so existing
204
- > integrations can be read and understood — do **not** reach for them when
205
- > connecting a new database. They are the seams that caused painful onboarding,
206
- > and `ablo connect` exists precisely to replace them.
207
-
208
- This older shape connected Ablo to a database by exposing a **signed Data Source
209
- endpoint** built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`,
210
- with `ablo_outbox` / `ablo_idempotency` bookkeeping and a reverse-channel
211
- connector for VPCs). It required Ablo to proxy every write, and has been superseded
212
- by logical replication, where Ablo only reads your WAL. If you are maintaining one
213
- of these integrations, migrate it to `ablo connect` at the next opportunity.
@@ -50,11 +50,11 @@ export async function markReady(reportId: string) {
50
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
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.
53
+ // behind them. description → the label observers see while we work.
54
54
  await using claim = await ablo.weatherReports.claim({
55
55
  id: reportId,
56
56
  queue: false,
57
- reason: 'marking_ready',
57
+ description: 'marking_ready',
58
58
  });
59
59
  const claimed = claim.data;
60
60
 
@@ -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
- reason: 'editing',
46
+ description: 'editing',
47
47
  ttl: '2m',
48
48
  });
49
49
  const claimed = claim.data;
@@ -58,7 +58,7 @@ export async function markReady(id: string) {
58
58
  await using claim = await ablo.weatherReports.claim({
59
59
  id,
60
60
  queue: false,
61
- reason: 'marking_ready',
61
+ description: 'marking_ready',
62
62
  });
63
63
  const claimed = claim.data;
64
64
 
@@ -39,7 +39,7 @@ export async function completeReport(reportId: string) {
39
39
  await using claim = await ablo.weatherReports.claim({
40
40
  id: reportId,
41
41
  queue: false,
42
- reason: 'completing',
42
+ description: 'completing',
43
43
  });
44
44
  const claimed = claim.data;
45
45
 
@@ -62,7 +62,7 @@ The two options on the claim:
62
62
 
63
63
  - `queue: false` — skip this record if another claim is already in progress,
64
64
  rather than queueing behind it. (The default queues.)
65
- - `reason: 'completing'` — a human-readable label for what your worker is doing,
65
+ - `description: '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