@abloatai/ablo 0.31.0 → 0.33.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 +50 -0
  3. package/README.md +25 -13
  4. package/dist/cli.cjs +1686 -1219
  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 +55 -1
  12. package/dist/schema/schema.js +47 -0
  13. package/dist/schema/select.js +1 -0
  14. package/dist/schema/serialize.d.ts +3 -1
  15. package/dist/schema/serialize.js +9 -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/coordination.md +13 -1
  29. package/docs/data-sources.md +34 -32
  30. package/docs/examples/agent-human.md +2 -2
  31. package/docs/examples/ai-sdk-tool.md +1 -1
  32. package/docs/examples/nextjs.md +1 -1
  33. package/docs/examples/server-agent.md +2 -2
  34. package/docs/groups.md +160 -0
  35. package/docs/guarantees.md +9 -7
  36. package/docs/how-it-works.md +110 -0
  37. package/docs/integration-guide.md +2 -2
  38. package/docs/interaction-model.md +3 -3
  39. package/docs/quickstart.md +6 -6
  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
@@ -473,6 +473,18 @@ Each beat's answer carries two more things:
473
473
  the lease. Durable progress belongs in the data itself — write a row, and
474
474
  every subscriber already sees it.
475
475
 
476
+ Cooperative yield has a server-side backstop your deployment can turn on: a
477
+ **cumulative-hold ceiling**. Left unset — the default — a holder that keeps
478
+ beating holds the row as long as it likes, and the line behind it depends on
479
+ that holder reading `queueDepth` and releasing of its own accord. With a ceiling
480
+ configured for a model, a holder that runs past its fair share *while contenders
481
+ are queued* is preempted at the server: its next beat comes back `claim_lost`
482
+ (reason `preempted`) — the same loss you already handle, so abandon or re-claim,
483
+ and any write attempted under the old lease is fenced regardless. A holder with
484
+ no one waiting is never preempted, however long it runs. It is the same idea as
485
+ an SQS message that cannot stay invisible past a hard cap however often its lock
486
+ is refreshed, narrowed here to bite only under real contention.
487
+
476
488
  Works identically on both transports: the realtime client sends a
477
489
  `claim_heartbeat` frame; the HTTP client posts
478
490
  `POST /api/v1/models/{model}/{id}/claim/heartbeat` (`{ ttl?, claimId?, details? }`).
@@ -603,7 +615,7 @@ inspect the `code`.
603
615
 
604
616
  | error | `code` | thrown when | carries |
605
617
  |---|---|---|---|
606
- | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, or was revoked — while you were holding or waiting. | `claims?` |
618
+ | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited) — while you were holding or waiting. | `claims?` |
607
619
  | `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable — re-attempt the claim. | `claims?` |
608
620
  | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
609
621
  | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
@@ -20,12 +20,12 @@ exactly that.
20
20
 
21
21
  Connecting sets up two capabilities on your Postgres: **logical replication**, so
22
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.
23
+ connect` prints the exact SQL. `ablo connect apply` runs it for you.
24
24
 
25
25
  ## Connect in one command
26
26
 
27
27
  ```bash
28
- npx ablo connect --apply --url postgres://admin:...@host:5432/db
28
+ npx ablo connect apply --url postgres://admin:...@host:5432/db
29
29
  ```
30
30
 
31
31
  Pass an admin connection string with `--url` and it creates the publication, the
@@ -34,7 +34,7 @@ both scoped roles with Ablo, and proves the setup by reconnecting and reading ba
34
34
  The admin credential is used on this machine only and never persisted — nothing is
35
35
  written to your `.env`, which keeps holding only `ABLO_API_KEY`. Pass `--show-sql`
36
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`.
37
+ yourself. Rotate the scoped passwords any time with `ablo connect rotate`.
38
38
 
39
39
  The rest of this page is what that command sets up, step by step, for when you want
40
40
  to run it by hand or review exactly what changes.
@@ -52,7 +52,7 @@ ALTER SYSTEM SET wal_level = 'logical';
52
52
  `wal_level` is **not reloadable** — you must **restart Postgres** for it to take
53
53
  effect. On Amazon RDS / Aurora you can't `ALTER SYSTEM`; set
54
54
  `rds.logical_replication = 1` in the instance's parameter group instead, then
55
- reboot. (`ablo connect --apply` attempts this for you and, where a managed
55
+ reboot. (`ablo connect apply` attempts this for you and, where a managed
56
56
  provider refuses, hands you the one remaining step.)
57
57
 
58
58
  ### 2. Run `ablo connect` for the publication and roles
@@ -101,44 +101,46 @@ Run it against your database as a superuser or the DB owner. It creates:
101
101
  The **replication slot** is created and owned by Ablo's runtime when it first
102
102
  subscribes — you don't pre-create it.
103
103
 
104
- ### 3. Validate with `ablo connect --check`
104
+ ### 3. Register the database with Ablo
105
105
 
106
- Put the replication role's connection string in `DATABASE_URL`, then verify the
107
- 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:
108
110
 
109
111
  ```bash
110
- 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
111
115
  ```
112
116
 
113
- It checks the four invariants and prints a green checklist or the precise per-item
114
- 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:
115
132
 
116
133
  - `wal_level` is `logical`
117
134
  - the `ablo_publication` publication exists
118
135
  - the replication role has the `REPLICATION` attribute
119
136
  - every published table has a usable `REPLICA IDENTITY` (a primary key, or
120
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
121
140
 
122
- `--check` reads the two scoped roles from `ABLO_REPLICATION_DATABASE_URL` and
123
- `ABLO_WRITE_DATABASE_URL` (the `ABLO_`-namespaced names keep a replication-only
124
- credential from ever landing in the generic `DATABASE_URL` your ORM reads).
125
- Re-run it until every item is green.
126
-
127
- ### 4. Register the two roles with Ablo
128
-
129
- `ablo connect --apply` already did this. If you ran the SQL by hand instead, hand
130
- Ablo both connection strings — the replication role it reads and confirms through,
131
- and the writer role it lands your rows through:
132
-
133
- ```bash
134
- npx ablo connect --register
135
- ```
136
-
137
- This is out of band and happens once. `--register` reads the two scoped connection
138
- strings from `ABLO_REPLICATION_DATABASE_URL` and `ABLO_WRITE_DATABASE_URL` and
139
- posts them to Ablo, which holds them encrypted and uses them to read and write your
140
- database. The role passwords are generated for you and never printed — rotate them
141
- any time with `ablo connect --rotate`. After this, Ablo does all the connecting.
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.
142
144
 
143
145
  Your **app** holds only the API key — never a connection string:
144
146
 
@@ -172,10 +174,10 @@ landed:
172
174
 
173
175
  ```ts
174
176
  // Enters the chokepoint (claims, ordering, idempotency), lands in your Postgres.
175
- await ablo.weatherReports.update('report_stockholm', { high: 21 });
177
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 } });
176
178
 
177
179
  // Block until your database has it and the WAL echo confirms.
178
- await ablo.weatherReports.update('report_stockholm', { high: 21 }, { wait: 'confirmed' });
180
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { high: 21 }, wait: 'confirmed' });
179
181
 
180
182
  // Reads are live off the same stream.
181
183
  const report = ablo.weatherReports.get('report_stockholm');
@@ -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
package/docs/groups.md ADDED
@@ -0,0 +1,160 @@
1
+ # Change Propagation
2
+
3
+ > How a change to one row reaches the rows and actors that depend on it, and how
4
+ > to keep a chain of dependent work fresh. This is the propagation half of sync
5
+ > groups; [`identity.md`](./identity.md) is the access half (who may read a
6
+ > group), and [`concurrency-convention.md`](./concurrency-convention.md) is the
7
+ > convention this rests on.
8
+
9
+ ---
10
+
11
+ ## Start from the problem
12
+
13
+ An agent reads deck `A` to write slide `B`. A moment later it reads `B` to write
14
+ layer `C`. Between those steps someone else edits `A`. The agent is now building
15
+ `C` on a premise that has moved — and nothing about writing `C` looks wrong in
16
+ isolation. That is stale context, and it is the thing sync groups let you catch.
17
+
18
+ The recipe is one field on the commit: declare the group you read as a premise,
19
+ and say what should happen if it moved.
20
+
21
+ ```ts
22
+ // The agent read everything under deck:abc to compose this write.
23
+ await ablo.layers.update({
24
+ id: 'layer-C',
25
+ data: { text: revised },
26
+ reads: [{ group: 'deck:abc', readAt: watermark, onStale: 'notify' }],
27
+ });
28
+ ```
29
+
30
+ At commit, inside the write transaction, the engine asks a single question: *did
31
+ any delta routed to `deck:abc` land after `watermark`?* If nothing moved, the
32
+ write applies. If something moved, `onStale` decides — `notify` holds the write
33
+ and hands the agent a `StaleNotification` naming the group, so it re-reads
34
+ `deck:abc` and regenerates; `reject` aborts the batch with a `409`. The agent
35
+ never persists work built on a premise it can no longer see.
36
+
37
+ ---
38
+
39
+ ## Three ways a change reaches other rows
40
+
41
+ "A affects B and C" means three different things. The engine does the first two
42
+ for you and leaves the third to you — on purpose.
43
+
44
+ **Routing — who hears about a change.** Every row belongs to one or more sync
45
+ groups, and a write fans out to all of them. A row also inherits its ancestors'
46
+ groups: editing a layer stamps the delta with `layer:…`, `slide:…`, *and*
47
+ `deck:…`, so everyone watching the deck sees the layer move. This is delivery,
48
+ resolved by walking the ownership tree at commit time. It routes the change; it
49
+ never recomputes a value.
50
+
51
+ **Structural cascade — what disappears with a change.** Deleting a deck removes
52
+ its slides and layers. The database does that through `ON DELETE CASCADE`, but a
53
+ database-level cascade emits no delta, so open clients would quietly hold rows
54
+ that no longer exist. The engine closes that gap: before the delete it snapshots
55
+ the subtree and emits a tombstone for each descendant, routed to the right
56
+ group. Watchers see the whole subtree vanish.
57
+
58
+ **Value recomputation — what a change implies for derived state.** If `B` holds a
59
+ number rolled up from `A`, the engine does not recompute `B` when `A` changes. It
60
+ surfaces that `A` moved and lets the actor decide what `B` should become. This is
61
+ the non-coercion principle: coordinate and report, resolve nothing by fiat.
62
+ Merging derived state is a judgment call, and for an agent in the loop that
63
+ judgment is the whole point.
64
+
65
+ ---
66
+
67
+ ## The chain: A → B → C
68
+
69
+ Model a dependency as shared group membership. Put `A` and `B` in one group, `B`
70
+ and `C` in another, and you have wired the edges of a chain. What travels along
71
+ those edges is a *signal*, one hop at a time — not a recomputation.
72
+
73
+ ```
74
+ A writes ──▶ group {A,B} ──▶ B hears it
75
+ │ B decides, B writes
76
+
77
+ group {B,C} ──▶ C hears it
78
+ ```
79
+
80
+ A's delta lands in `{A,B}` and stops there. `C` is not in that group, so `C`
81
+ learns nothing from A directly. `C` advances only when **B itself writes** and
82
+ that new delta lands in `{B,C}`. `B` is the translator: it takes "A moved,"
83
+ decides what that means for its own state, commits, and *its* commit is what
84
+ reaches `C`.
85
+
86
+ The direction matters. The signal flows forward, A to B to C, and each hop is a
87
+ real write an actor chose to make. The engine supplies the edges (group
88
+ membership) and a stale signal on each edge (the read-dependency check); the
89
+ actors are the runtime that walks them. It is closer to a spreadsheet an analyst
90
+ recalculates cell by cell than to a reactive engine that recomputes the whole
91
+ column for you.
92
+
93
+ Two consequences worth designing around:
94
+
95
+ - **The chain runs as fast as actors react.** If `B` never acts on its signal,
96
+ the chain stops at `B` and `C` stays as it was. Freshness is an actor
97
+ responsibility; the engine guarantees the signal, not the follow-through.
98
+ - **Cycles don't settle themselves.** If `C` writes back to `A`, each hop is a
99
+ separate commit with its own stale check, and nothing damps the oscillation.
100
+ Keep the dependency graph acyclic, or give one actor the job of reaching a
101
+ fixpoint. Convergence lives above the engine.
102
+
103
+ ---
104
+
105
+ ## Declaring a read premise
106
+
107
+ A read-dependency is a premise for the *whole* batch: its disposition governs
108
+ every write in the commit, not just one operation. You choose the granularity per
109
+ entry.
110
+
111
+ ```ts
112
+ reads: [
113
+ { group: 'deck:abc', readAt: N, onStale: 'notify' }, // did anything in the deck move?
114
+ { model: 'Slide', id: 's-1', readAt: N, fields: ['title'] }, // did this row (this field) move?
115
+ ]
116
+ ```
117
+
118
+ A **group** premise asks "did anything I was watching change?" — the native
119
+ Ablo granularity, and the right tool for the chain above. A **row** premise is
120
+ literal: this object, optionally these fields. A row premise with `fields`
121
+ conflicts only on real field overlap, so two actors editing disjoint fields of
122
+ the same row don't collide.
123
+
124
+ `onStale` has three settings, defaulting to `reject`:
125
+
126
+ - **`notify`** holds every write in the batch and returns a `StaleNotification`.
127
+ For a group premise it carries the group name and the new watermark
128
+ (`observedSyncId`); re-read the group at that point and regenerate. This is the
129
+ setting a chain wants — the actor gets the truth and resolves it.
130
+ - **`reject`** aborts the batch with a `stale_context` error (`409`). The right
131
+ default when there is nothing to reconcile and the write should simply not
132
+ land.
133
+ - **`overwrite`** skips the check and lets the write land — last-write-wins, the
134
+ explicit escape hatch.
135
+
136
+ ---
137
+
138
+ ## Sizing groups
139
+
140
+ A group is the unit of both delivery and staleness, so its size is a real
141
+ tradeoff. A change fans out to every subscriber of every group it touches, and a
142
+ group premise fires when *anything* in the group moves — so a group that is too
143
+ broad wakes actors for changes they don't care about, and one that is too narrow
144
+ misses the dependency you meant to track.
145
+
146
+ The rule of thumb: **make a group the smallest set of rows that must stay
147
+ mutually consistent.** A deck and its slides belong together because editing one
148
+ changes what the others mean; two unrelated decks do not. Reach for finer,
149
+ overlapping groups when you genuinely have a dependency chain to track, and keep
150
+ them coarse everywhere else.
151
+
152
+ ---
153
+
154
+ ## Where this is defined
155
+
156
+ - **Access** — who may read or write a group — is [`identity.md`](./identity.md).
157
+ - **The convention** — non-coercion, the read-set, and the notification — is
158
+ [`concurrency-convention.md`](./concurrency-convention.md) (§4 and §5).
159
+ - **The mechanics** — the three coordination layers underneath — are
160
+ [`coordination.md`](./coordination.md).
@@ -66,10 +66,8 @@ snapshot, the server rejects the write instead of applying stale reasoning.
66
66
  Advanced policies exist for controlled product flows:
67
67
 
68
68
  - `reject` fails the write when state moved.
69
- - `force` applies the write without stale protection.
70
- - `flag` accepts the write and marks it for product review.
71
-
72
- `merge` is not yet available.
69
+ - `overwrite` applies the write without stale protection.
70
+ - `notify` accepts the write and marks it for product review.
73
71
 
74
72
  ## Claim Coordination
75
73
 
@@ -148,9 +146,13 @@ separates workflow or deployment lanes sharing the same store.
148
146
 
149
147
  ## Storage Boundary
150
148
 
151
- Ablo does not need a customer database URL. When your own database is canonical,
152
- Ablo calls a signed Data Source endpoint and records the coordination result for
153
- receipts, realtime fanout, and audit. See [Connect Your Database](./data-sources.md).
149
+ Your rows live in your database; Ablo holds only the transaction log and the
150
+ coordination state. Writes enter Ablo's commit chokepoint and land in your
151
+ Postgres through a scoped writer role, and the WAL echo confirms them
152
+ (`queued` → `confirmed`). For a database that can't grant replication, Ablo
153
+ forwards the write to a signed Data Source endpoint instead — the marked
154
+ fallback. Either way Ablo never holds a database connection string. See
155
+ [Connect Your Database](./data-sources.md).
154
156
 
155
157
  ## Writes
156
158
 
@@ -0,0 +1,110 @@
1
+ # How Ablo Works
2
+
3
+ You write through Ablo, and Ablo writes to your Postgres. That one sentence is the
4
+ whole model — everything below explains what it means and how to use it.
5
+
6
+ ```ts
7
+ // You call Ablo. Ablo lands the change in your database and confirms it.
8
+ await ablo.tasks.update({ id: 'task_42', data: { status: 'done' }, wait: 'confirmed' });
9
+
10
+ // Reads come back live, kept current from your database.
11
+ const task = ablo.tasks.get('task_42');
12
+ ```
13
+
14
+ ## The mental model — read this once
15
+
16
+ Ablo is a **coordination layer in front of your Postgres**. Humans, agents, and
17
+ background jobs all change the same application data through one API, and Ablo
18
+ makes sure their writes don't clobber each other.
19
+
20
+ - **Writes go through Ablo.** `ablo.<model>.create / update / delete` enter Ablo's
21
+ commit chokepoint — where claims, ordering, and idempotency are enforced — and
22
+ Ablo applies the change to your Postgres through a scoped writer role. The commit
23
+ is accepted (`queued`) the moment Ablo takes it.
24
+ - **Your database confirms the write.** Ablo tails your write-ahead log (WAL). When
25
+ the row it wrote shows up there, the receipt is promoted to `confirmed`. So your
26
+ database, not Ablo, is the source of truth for row state — the WAL echo is how
27
+ Ablo *confirms*, not how it writes.
28
+ - **Reads are live.** Ablo serves current state and keeps every connected client up
29
+ to date off that same stream.
30
+ - **Ablo stores only the log.** Ablo holds the ordered transaction log
31
+ (`sync_deltas`) and the coordination state — the claims, the ordering, the
32
+ watermarks. Your rows live in your database. Ablo runs no DDL and owns no schema;
33
+ your migration tool stays in charge of the shape of your database.
34
+
35
+ That's the shape: **you write through Ablo → it lands in your Postgres → the WAL
36
+ echo confirms it → everyone connected sees it live.**
37
+
38
+ ## Where your data lives
39
+
40
+ You point Ablo at a Postgres database, and that's where its rows live. Only *which*
41
+ database differs by environment — the code is identical.
42
+
43
+ - **Production** — your Postgres. `ablo connect` sets up a scoped writer role and
44
+ logical replication; your rows live in your database, and Ablo writes to them
45
+ through that role.
46
+ - **Sandbox and local dev** — a separate or local Postgres you can throw away. Same
47
+ models, same code, a different database behind them.
48
+ - **Before you connect one** — Ablo keeps state in its own log, so you can build the
49
+ whole app today and point it at a real database when you're ready.
50
+
51
+ Registering the database is the whole switch. There is no tier or flag to choose.
52
+
53
+ ## Use it end to end
54
+
55
+ ```bash
56
+ # 1. Install and scaffold.
57
+ npm install @abloatai/ablo
58
+ npx ablo init
59
+
60
+ # 2. Push your schema (the models humans and agents edit together).
61
+ npx ablo push
62
+
63
+ # 3. Connect your database — one command, admin credential used once and discarded.
64
+ npx ablo connect apply --url postgres://admin:...@host:5432/db
65
+ ```
66
+
67
+ ```bash
68
+ # Your app's environment holds only the API key — never a connection string.
69
+ ABLO_API_KEY=sk_live_...
70
+ ```
71
+
72
+ ```ts
73
+ // 4. Build the client once.
74
+ import Ablo from '@abloatai/ablo';
75
+ import { schema } from './ablo/schema';
76
+ export const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
77
+
78
+ // 5. Write through Ablo. It lands in your Postgres; wait: 'confirmed' blocks
79
+ // until the WAL echo proves the row is there.
80
+ await ablo.tasks.update({ id: 'task_42', data: { status: 'done' }, wait: 'confirmed' });
81
+
82
+ // 6. Read — live, no fetch loop.
83
+ const task = ablo.tasks.get('task_42');
84
+
85
+ // 7. Coordinate when more than one actor can touch a row. Hold a claim and Ablo
86
+ // serializes writes on that key against everyone else; read after claiming,
87
+ // then write. The lease releases automatically at the end of the scope.
88
+ await using _hold = await ablo.tasks.claim('task_42');
89
+ const latest = ablo.tasks.get('task_42'); // read after claiming, not from memory
90
+ await ablo.tasks.update({ id: 'task_42', data: { status: 'done' } });
91
+ ```
92
+
93
+ ## Reading the architecture correctly
94
+
95
+ The model changed once (ADR 0010), and older material described the earlier one.
96
+ If you've read that a customer's app keeps writing while Ablo only tails the WAL,
97
+ here is the current, correct reading:
98
+
99
+ - **"Ablo tails the WAL."** True — to *confirm* your writes and serve live reads.
100
+ It is not the write path. You write **through** Ablo.
101
+ - **"Ablo is out of the write path."** Not anymore. Ablo is the write chokepoint;
102
+ every write flows through its coordination and then into your database.
103
+ - **"Ablo holds my rows."** Only in the no-database-yet sandbox. Once you connect a
104
+ database, your rows live in *your* Postgres — Ablo keeps just the log.
105
+ - **"Ablo migrates my schema."** No. Ablo writes rows through a scoped role and runs
106
+ no DDL; your migrations own the shape of your database.
107
+
108
+ For the setup details, see [Connect Your Database](./data-sources.md). For the
109
+ coordination loop, see [Concurrency Convention](./concurrency-convention.md). For
110
+ what `queued` and `confirmed` guarantee, see [Guarantees](./guarantees.md).