@abloatai/ablo 0.57.0 → 0.59.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 (90) hide show
  1. package/AGENTS.md +12 -5
  2. package/CHANGELOG.md +269 -13
  3. package/README.md +6 -5
  4. package/dist/ai-sdk.d.ts +1 -1
  5. package/dist/ai-sdk.d.ts.map +1 -1
  6. package/dist/context/evidence.d.ts +6 -8
  7. package/dist/context/evidence.d.ts.map +1 -1
  8. package/dist/context/evidence.js +6 -20
  9. package/dist/context/evidence.js.map +1 -1
  10. package/dist/context/index.d.ts +23 -0
  11. package/dist/context/index.d.ts.map +1 -0
  12. package/dist/context/index.js +26 -0
  13. package/dist/context/index.js.map +1 -0
  14. package/dist/context/onChange.d.ts +9 -0
  15. package/dist/context/onChange.d.ts.map +1 -0
  16. package/dist/context/onChange.js +37 -0
  17. package/dist/context/onChange.js.map +1 -0
  18. package/dist/source-conformance.d.ts +1 -1
  19. package/dist/source-conformance.d.ts.map +1 -1
  20. package/dist/source-conformance.js +1 -1
  21. package/dist/source-conformance.js.map +1 -1
  22. package/dist/source-drizzle.d.ts +1 -1
  23. package/dist/source-drizzle.d.ts.map +1 -1
  24. package/dist/source-drizzle.js +1 -1
  25. package/dist/source-drizzle.js.map +1 -1
  26. package/dist/source-kysely.d.ts +1 -1
  27. package/dist/source-kysely.d.ts.map +1 -1
  28. package/dist/source-kysely.js +1 -1
  29. package/dist/source-kysely.js.map +1 -1
  30. package/dist/source-next.d.ts +1 -1
  31. package/dist/source-next.d.ts.map +1 -1
  32. package/dist/source-next.js +1 -1
  33. package/dist/source-next.js.map +1 -1
  34. package/docs/agents.md +38 -15
  35. package/docs/api-keys.md +6 -6
  36. package/docs/api.md +107 -34
  37. package/docs/basic-usage.md +84 -0
  38. package/docs/branch-development.md +23 -4
  39. package/docs/cli.md +16 -9
  40. package/docs/client-behavior.md +27 -29
  41. package/docs/comparison.md +63 -0
  42. package/docs/concurrency-convention.md +92 -75
  43. package/docs/context.md +76 -31
  44. package/docs/coordinate-existing-work.md +104 -0
  45. package/docs/coordination.md +93 -38
  46. package/docs/data-sources.md +12 -6
  47. package/docs/debugging.md +1 -1
  48. package/docs/deployment.md +19 -1
  49. package/docs/examples/agent-human.md +6 -18
  50. package/docs/examples/coordination-conformance.md +69 -0
  51. package/docs/examples/evidence-backed-document-pipeline.md +488 -0
  52. package/docs/examples/existing-python-backend.md +10 -13
  53. package/docs/examples/nextjs.md +2 -2
  54. package/docs/examples/scoped-agent.md +18 -1
  55. package/docs/examples/server-agent.md +2 -2
  56. package/docs/faq.md +75 -0
  57. package/docs/groups.md +19 -139
  58. package/docs/guarantees.md +8 -8
  59. package/docs/idempotency.md +3 -0
  60. package/docs/identity.md +2 -1
  61. package/docs/implement.md +61 -0
  62. package/docs/implementation-index.md +20 -0
  63. package/docs/index.md +59 -173
  64. package/docs/installation.md +77 -0
  65. package/docs/instrumentation.md +52 -0
  66. package/docs/integration-guide.md +20 -19
  67. package/docs/integrations/sandbox-runtime.md +157 -0
  68. package/docs/integrations.md +9 -0
  69. package/docs/migration.md +12 -7
  70. package/docs/operating-on-your-database.md +7 -0
  71. package/docs/options.md +172 -0
  72. package/docs/quickstart.md +25 -14
  73. package/docs/react.md +9 -9
  74. package/docs/schema-contract.md +14 -13
  75. package/docs/security.md +64 -0
  76. package/docs/sessions.md +1 -1
  77. package/examples/README.md +8 -2
  78. package/examples/agent-turn.ts +1 -1
  79. package/examples/expensive-agent-turn.ts +1 -1
  80. package/examples/stale-context-agent-turn.ts +106 -0
  81. package/llms.txt +23 -12
  82. package/package.json +7 -6
  83. package/dist/context/sources.d.ts +0 -21
  84. package/dist/context/sources.d.ts.map +0 -1
  85. package/dist/context/sources.js +0 -36
  86. package/dist/context/sources.js.map +0 -1
  87. package/dist/context.d.ts +0 -22
  88. package/dist/context.d.ts.map +0 -1
  89. package/dist/context.js +0 -33
  90. package/dist/context.js.map +0 -1
@@ -0,0 +1,104 @@
1
+ # Coordinate Existing Work
2
+
3
+ > Start here. Preserve the application, coordinate one operation, and read another page only when the routing table sends you there.
4
+
5
+ Use this guide to coordinate expensive work while its existing PostgreSQL
6
+ transaction remains authoritative.
7
+
8
+ Many production systems already reserve slow work in Redis and protect the
9
+ final write with a PostgreSQL transaction. That is a sound architecture. The
10
+ cost appears when every workflow must independently define ownership, expiry,
11
+ heartbeat, waiting, recovery, participant identity, and operational visibility.
12
+
13
+ Ablo standardizes that coordination lifecycle. It does not replace the
14
+ application's authoritative transaction.
15
+
16
+ ## Existing backend: copy this shape
17
+
18
+ For an application that already owns its API and Postgres transaction:
19
+
20
+ 1. Choose one named operation, such as `completeTask`.
21
+ 2. Keep its API, authorization, validation, transaction, locks, and constraints.
22
+ 3. Give each concurrent worker a distinct scoped credential.
23
+ 4. Claim the operation's stable business identifier before expensive work.
24
+ 5. While the claim is held, call the existing operation to commit.
25
+
26
+ ```ts
27
+ const ablo = Ablo({
28
+ schema,
29
+ apiKey: process.env.ABLO_API_KEY,
30
+ transport: 'http',
31
+ });
32
+
33
+ await using lease = await ablo.taskRuns.claim(taskId, {
34
+ contention: { mode: 'skip' },
35
+ ttl: '30s',
36
+ heartbeat: { every: '10s' },
37
+ });
38
+ if (!lease) return { outcome: 'skipped' };
39
+
40
+ const prepared = await performExpensiveWork(taskId);
41
+ return existingTaskService.commitPrepared(taskId, prepared);
42
+ ```
43
+
44
+ The string passed to `claim` is an identifier-only lease. It does not require an
45
+ Ablo row and returns no row data. `commitPrepared` must still re-read and validate
46
+ inside the application's Postgres transaction. An Ablo lease does not join a
47
+ transaction in another process.
48
+
49
+ If the operation only needs to inspect an Ablo row before calling the existing
50
+ write path, keep it this small:
51
+
52
+ ```ts
53
+ const task = await ablo.tasks.get({ id: taskId });
54
+ if (!task) throw new Error('task not found');
55
+
56
+ await completeTask({ id: task.id, expectedTitle: task.title });
57
+ return task.id;
58
+ ```
59
+
60
+ `get({ id })` observes a row. Use `read({ id })` only when its captured evidence
61
+ will be passed to an Ablo write through `reads`.
62
+
63
+ ## Change the shape only when required
64
+
65
+ | Your operation requires this | Use | Read |
66
+ |---|---|---|
67
+ | The claimed target is an Ablo model row and the final write goes through that model. | Row-backed claim; pass the returned `claim` to the write. | [Coordination](../coordination.md) |
68
+ | The result depends on an Ablo row that may change while work runs. | `read(...)` the premise and pass it through `reads`. | [Concurrency Convention](../concurrency-convention.md) |
69
+ | Several Ablo writes must all land or none may land. | One `commits.create(...)`. | [API Reference](../api.md) |
70
+ | A person needs live state, presence, or reactive local reads. | WebSocket client for that human interface. Workers stay on HTTP. | [React](../react.md) |
71
+ | The operation sends email, charges money, writes a file, or calls another provider. | That system's idempotency key or an application outbox. | [Idempotency](../idempotency.md) |
72
+
73
+ Do not add a mechanism unless its condition is true. In particular, do not
74
+ replace an existing database operation merely because Ablo coordinates it.
75
+
76
+ ## Check these boundaries before editing
77
+
78
+ - What existing operation and public result must remain unchanged?
79
+ - What stable identifier represents the contested work?
80
+ - Which process performs expensive work, and which process commits?
81
+ - Does Postgres or Ablo own each final write?
82
+ - What does the caller receive on contention, failure, and retry?
83
+
84
+ If an answer is unknown, preserve the existing write path. Do not copy an
85
+ advanced example or expand the schema to hide the missing decision.
86
+
87
+ ## Prove the implementation
88
+
89
+ For the adopted operation, test that:
90
+
91
+ 1. The coordinated and existing paths return the same public result.
92
+ 2. Two distinct participants do not both perform the expensive work.
93
+ 3. A contender follows the chosen skip or wait behavior.
94
+ 4. Failure or expiry allows a later attempt to proceed.
95
+ 5. The existing authorization and database transaction still run.
96
+
97
+ Local tests prove application behavior. Run
98
+ [`examples/coordination-conformance`](../../../../examples/coordination-conformance/README.md)
99
+ against hosted Ablo to prove participant identity, heartbeat, exclusion, release,
100
+ and expiry. Run staging against the real database and authorization to prove the
101
+ production boundary.
102
+
103
+ For setup, read the [Integration Guide](../integration-guide.md). The complete
104
+ implementation on this page is the starter route for an existing backend.
@@ -2,6 +2,20 @@
2
2
 
3
3
  > Choose plain writes, functional updates, stale guards, or claims without losing concurrent work.
4
4
 
5
+ This page owns row coordination: acquire ownership, proceed from the granted
6
+ state, and release on every exit path.
7
+
8
+ ## Choose the owner first
9
+
10
+ | Where the result lands | Start here |
11
+ |---|---|
12
+ | Existing application path: API, GraphQL operation, Postgres transaction, filesystem write, or Git branch merge | [Coordinate existing work](./coordinate-existing-work.md) |
13
+ | Ablo model row, written through the Ablo client | Continue on this page |
14
+
15
+ If Ablo only decides who may start, use the first row and stop here. The claim
16
+ examples below own Ablo-row writes; they are not the starter for wrapping an
17
+ existing operation.
18
+
5
19
  Ablo gives you several concurrency tools because not every write has the same
6
20
  meaning. Choose the narrowest one that matches the operation.
7
21
 
@@ -9,7 +23,7 @@ meaning. Choose the narrowest one that matches the operation.
9
23
  |---|---|---|
10
24
  | Set an independent value | `update({ id, data })` | Last-write-wins when no claim applies. |
11
25
  | Compute a value from the current row | `update(id, current => next)` | Re-reads and retries if the row changes concurrently. |
12
- | Write only if earlier rows are still current | `reads: [record, policy]` | Rejects when an explicitly named dependency changed. |
26
+ | Write only if earlier rows are still current | `reads: [record, rules]` | Rejects when an explicitly named dependency changed. |
13
27
  | Read, call a model, then write | `claim({ id })` | Other participants cannot write the claimed target by default until your claim ends. |
14
28
 
15
29
  **If a model call sits between the read and the write, take a claim.** A stale
@@ -25,8 +39,8 @@ does not carry a stale premise. It is intentionally last-write-wins.
25
39
  Pass the exact rows that produced a decision on the write:
26
40
 
27
41
  ```ts
28
- const record = await ablo.records.get({ id: recordId });
29
- const policy = await ablo.policies.get({ id: policyId });
42
+ const record = await ablo.records.read({ id: recordId });
43
+ const policy = await ablo.policies.read({ id: policyId });
30
44
  if (!record || !policy) throw new Error('required input is missing');
31
45
 
32
46
  const result = await model({ record, policy });
@@ -91,7 +105,7 @@ Use explicit returned rows when application code reads first and writes later,
91
105
  but does not need to reserve the row:
92
106
 
93
107
  ```ts
94
- const report = await ablo.reports.get({ id: reportId });
108
+ const report = await ablo.reports.read({ id: reportId });
95
109
  if (!report) throw new Error('report missing');
96
110
 
97
111
  await ablo.reports.update({
@@ -101,37 +115,13 @@ await ablo.reports.update({
101
115
  });
102
116
  ```
103
117
 
104
- The dispositions are:
105
-
106
- | `onStale` | Behavior |
107
- |---|---|
108
- | `reject` | Reject the write if its premise is stale. |
109
- | `notify` | Leave the row unchanged and return the current value for reconciliation. |
110
- | `overwrite` | Apply the write without a stale check. |
118
+ There is no stale-mode option on the write. If a declared row changed, Ablo
119
+ rejects the whole mutation with `AbloStaleContextError`. Re-read and recompute,
120
+ or use the functional update form when the computation is pure and retryable.
121
+ To make an unconditional assignment, omit `reads` deliberately.
111
122
 
112
123
  See [Concurrency Convention](./concurrency-convention.md) for guarded batches
113
- and notifications.
114
-
115
- ### Decide the model's conflict policy
116
-
117
- Who yields is a design decision about the model, not something to restate on
118
- every write. Declare it once, in the schema, and it travels to the server with
119
- the rest of the model:
120
-
121
- ```ts
122
- import { coordination, model, z } from '@abloatai/ablo/schema';
123
-
124
- const cards = model(
125
- { title: z.string() },
126
- {
127
- conflict: coordination.humansOverwrite().agentsReject(),
128
- },
129
- );
130
- ```
131
-
132
- An omitted participant kind uses the engine default, `reject`. A per-write
133
- `onStale` states the disposition for that one write. Keep the policy simple, and
134
- document any rule that lets a participant overwrite a held claim.
124
+ and the `get` / `read` boundary.
135
125
 
136
126
  ## Claims
137
127
 
@@ -149,19 +139,66 @@ const forecast = await generateForecast(claim.data.location);
149
139
  await ablo.reports.update({
150
140
  id: claim.data.id,
151
141
  data: { forecast, status: 'ready' },
142
+ claim,
152
143
  });
153
144
  ```
154
145
 
155
146
  If another participant already holds the target, `claim` waits its turn and
156
- then resolves with a fresh row in `claim.data`. Ordinary reads remain open. By
157
- default, a write from a participant that does not hold the active claim is
158
- rejected; an explicit model conflict policy can choose otherwise.
147
+ then resolves with a fresh row in `claim.data`. Ordinary reads remain open. Pass
148
+ the handle as `claim` on the write so Ablo can verify that you still own the row
149
+ and that it has not changed since the claim was granted.
159
150
 
160
151
  Bind claims with `await using` whenever possible. The claim then releases when
161
152
  the scope exits, including when the external call or write throws. For runtimes
162
153
  without explicit resource management, use `try/finally` and
163
154
  `await claim.release()`.
164
155
 
156
+ ### Handle an expired claim
157
+
158
+ When heartbeat is unset, the lease ends at its TTL. A delayed write that passes
159
+ the expired handle rejects with `AbloClaimedError` and code `claim_lost`. Do not
160
+ apply the prepared result elsewhere; clean up best-effort, then restart from a
161
+ new claim and its fresh `claim.data`.
162
+
163
+ ```ts
164
+ import { AbloClaimedError } from '@abloatai/ablo';
165
+
166
+ const claim = await ablo.tasks.claim({ id: taskId, ttl: '2s' });
167
+ try {
168
+ await new Promise((resolve) => setTimeout(resolve, 2300));
169
+ await ablo.tasks.update({
170
+ id: claim.data.id,
171
+ data: { status: 'done' },
172
+ claim,
173
+ });
174
+ } catch (error) {
175
+ if (error instanceof AbloClaimedError && error.code === 'claim_lost') {
176
+ console.log(error.code);
177
+ } else {
178
+ throw error;
179
+ }
180
+ } finally {
181
+ try { await claim.release(); } catch { /* already expired */ }
182
+ }
183
+ ```
184
+
185
+ ### Keep a claim alive
186
+
187
+ Set `ttl` to how quickly another worker should recover if this one stops. If the
188
+ work can take longer, set `heartbeat: true` so Ablo renews the claim:
189
+
190
+ ```ts
191
+ const claim = await ablo.records.claim({
192
+ id: recordId,
193
+ ttl: '30s',
194
+ heartbeat: true,
195
+ });
196
+ ```
197
+
198
+ Leave `heartbeat` out when the claim should expire after the TTL; do not pass
199
+ `false`. If a write returns `claim_lost`, discard that result, claim the row
200
+ again, and restart from the new `claim.data`.
201
+
165
202
  ### One identity per participant
166
203
 
167
204
  Explicit claims coordinate authenticated participants. Two clients using the
@@ -198,7 +235,7 @@ try {
198
235
  }
199
236
  ```
200
237
 
201
- To wait with limits, keep the policy together:
238
+ To wait with limits, keep the contention settings together:
202
239
 
203
240
  ```ts
204
241
  const claim = await ablo.records.claim({
@@ -226,6 +263,19 @@ await using claim = await ablo.records.claim({
226
263
  Claims on disjoint fields can coexist. A whole-row claim conflicts with every
227
264
  field claim on that row.
228
265
 
266
+ ### Relations do not create hierarchical claims
267
+
268
+ A `parent: true` relation controls ownership, access inheritance, and sync
269
+ routing. It does not make claims conflict across related rows. For example, a
270
+ claim on one document row and a claim on one of its page rows have different
271
+ model-and-ID targets and can coexist.
272
+
273
+ Choose the row that represents the actual unit of exclusive work. Page rows
274
+ allow different pages to process concurrently. If a whole-document operation
275
+ must exclude every page operation, enumerate the authoritative page manifest,
276
+ acquire page claims in one stable order, and guard the manifest against change.
277
+ Do not infer that exclusion from the schema relation alone.
278
+
229
279
  The target options are:
230
280
 
231
281
  | Option | Purpose |
@@ -251,12 +301,17 @@ The main methods are:
251
301
 
252
302
  | Method | Purpose |
253
303
  |---|---|
254
- | `claim({ id })` | Acquire the target, waiting by default. |
304
+ | `claim({ id, ...options })` | Read and claim an existing model row; the handle includes fresh row data. |
255
305
  | `claim.state({ id })` | Read the current holder without blocking. |
256
306
  | `claim.queue({ id })` | Read the current wait order. |
257
307
  | `claim.release({ id })` | Release early when you do not hold a handle. |
258
308
  | `join({ scope })` | Observe presence for a broader scope. |
259
309
 
310
+ This page owns row-backed claims: `model.claim({ id })` reads and claims an Ablo
311
+ model row, and the handle carries fresh data. Identifier-only claims before an
312
+ existing authoritative service have a different persistence boundary; use the
313
+ [coordinate-existing-work guide](./coordinate-existing-work.md) for that form.
314
+
260
315
  ## Choosing correctly
261
316
 
262
317
  - Prefer a plain update for values that do not depend on an earlier read.
@@ -110,12 +110,12 @@ migrations — your migration tool stays in charge of the shape of your database
110
110
  Ablo only writes rows into tables you already have, through a role scoped to
111
111
  exactly that.
112
112
 
113
- > **Just trying Ablo?** You don't need a database to start. Pass an `apiKey` only,
114
- > and Ablo keeps your rows in its own log so you can build the whole app today.
115
- > `ablo dev` gives each Git branch its own isolated plane.
116
- > Keep it hosted with no database, or point that branch at a separate/local
117
- > Postgres. Connect your production root (below) when you're ready for its
118
- > database to be the system of record.
113
+ > **Just trying Ablo?** Start on a throwaway Postgres rather than your production
114
+ > one. `ablo dev` gives each Git branch its own isolated plane, so point that
115
+ > branch at a separate or local database, build against it, and connect your
116
+ > production root (below) when you're ready for its database to be the system of
117
+ > record. A branch with nothing connected refuses a schema push, which is the
118
+ > first thing you'll hit if you skip this.
119
119
 
120
120
  Connecting sets up two capabilities on your Postgres: **logical replication**, so
121
121
  Ablo can read and confirm, and a **scoped DML role**, so Ablo can write. `ablo
@@ -475,6 +475,12 @@ directly by other application code is visible only if that code writes the same
475
475
  outbox record in its transaction. Native WAL observation sees both Ablo and
476
476
  external writes.
477
477
 
478
+ Endpoint events use a versioned envelope. Version 2 freezes `syncGroups` in the
479
+ writing transaction; version 1 is retained only to decode events written by an
480
+ older adapter during a rolling upgrade. Poll requests keep `cursor` (where to
481
+ read) separate from `acknowledgedThrough` (what Ablo has durably accepted), and
482
+ the built-in adapters prune acknowledged rows in bounded batches.
483
+
478
484
  ## Next steps
479
485
 
480
486
  - [Quickstart](./quickstart.md) — connect and write through `ablo.<model>`.
package/docs/debugging.md CHANGED
@@ -86,7 +86,7 @@ Read it as the lifecycle of one claim:
86
86
  - **`queued … position N of M`:** the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance.
87
87
  - **`granted … your turn`:** you reached the head of the line; the lease is now yours and the row may have changed while you waited.
88
88
  - **`rejected … held by <who>`:** your claim was refused because someone else holds it (and the model's policy didn't let you in).
89
- - **`lost`:** you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
89
+ - **`lost`:** you held the lease and it ended (the queue fairness ceiling advanced, or it expired).
90
90
  - **`released`:** you (or `await using`'s scope exit) gave the lease back.
91
91
 
92
92
  ## Where the logs run
@@ -229,6 +229,24 @@ ABLO_API_KEY=sk_… npx ablo webhooks list # endpoints + delivery health
229
229
  place to look when a mirror falls behind. [Webhooks](./webhooks.md) covers the
230
230
  handler, the Standard Webhooks signature, and rolling a secret.
231
231
 
232
+ ## Multi-stage schema changes
233
+
234
+ `ablo plan` is the read-only front door for source, active artifact, and
235
+ PostgreSQL together. For a live rename, type transition, or required-field
236
+ change, commit a deployment manifest and pass the same file to plan and push.
237
+ Its gates are `expand`, `dual_write`, `backfill`, `verify`, `switch`, and
238
+ `contract`; each names an owner, resource, dependencies, status, and action.
239
+
240
+ ```bash
241
+ npx ablo plan --manifest ablo/deployment.json
242
+ npx ablo push --manifest ablo/deployment.json --yes
243
+ ```
244
+
245
+ A live manifest cannot combine expand and contract for the same resource.
246
+ Contract belongs in a later manifest with its own recorded `approval`. A
247
+ backfill being finished is not contract evidence; verification and client-drain
248
+ gates must also be satisfied.
249
+
232
250
  ## What to watch once it is live
233
251
 
234
252
  - **`ablo logs`:** commit activity as it happens, scoped by the key. A
@@ -263,7 +281,7 @@ and what each promises.
263
281
  `ablo connect check` all green.
264
282
  2. A secret `sk_` in the deploy environment as `ABLO_API_KEY` — never in a
265
283
  browser bundle.
266
- 3. `ablo push --yes` in the pipeline, ahead of the code that needs the new models.
284
+ 3. `ablo plan` reviewed, followed by fingerprint-gated `ablo push --yes`.
267
285
  4. `ablo status --json` gating the deploy on an empty `blockers` array.
268
286
  5. Browser clients on a root-bound `pk_` or an `authEndpoint`, not a secret key.
269
287
  6. Webhook endpoints registered at their deployed URLs, with the signing secret
@@ -25,7 +25,7 @@ a typed error if the row moved underneath you while the agent was busy.
25
25
  ## Schema-Backed Worker
26
26
 
27
27
  The worker uses the same schema client the app uses. It reads the record from the
28
- server with `get({ id })`, claims the row, and writes through
28
+ server with `read({ id })`, claims the row, and writes through
29
29
  `ablo.records.update(...)` with a stale-check so a concurrent edit can't be
30
30
  overwritten.
31
31
 
@@ -49,8 +49,8 @@ const ablo = Ablo({
49
49
  export async function markDone(recordId: string) {
50
50
  await ablo.ready();
51
51
 
52
- // get({ id }) is an async server read — await it.
53
- const record = await ablo.records.get({ id: recordId });
52
+ // read({ id }) is an async server read — await it.
53
+ const record = await ablo.records.read({ id: recordId });
54
54
  if (!record) return { status: 'not_found' };
55
55
 
56
56
  try {
@@ -67,21 +67,9 @@ export async function markDone(recordId: string) {
67
67
  await using claim = acquired;
68
68
  if (claim.data.status === 'done') return { status: 'noop' };
69
69
 
70
- // Inside an active claim, `update` is stale-checked automatically: the SDK
71
- // attaches the claim's snapshot version as `readAt` and sets
72
- // `onStale: 'reject'`. The write below is therefore equivalent to passing
73
- // those options yourself:
74
- //
75
- // ablo.records.update({
76
- // id: claim.data.id,
77
- // data: { status: 'done' },
78
- // readAt: <claim snapshot version>,
79
- // onStale: 'reject',
80
- // });
81
- //
82
- // If a newer version landed mid-run, the row no longer matches `readAt`, so
83
- // the server rejects this commit with AbloStaleContextError (caught below)
84
- // instead of clobbering that edit.
70
+ // The claim handle carries its acquisition snapshot. If a newer version
71
+ // somehow lands mid-run, the server rejects this commit with
72
+ // AbloStaleContextError instead of clobbering that edit.
85
73
  const updated = await ablo.records.update({
86
74
  id: claim.data.id,
87
75
  data: { status: 'done' },
@@ -0,0 +1,69 @@
1
+ # Verify hosted coordination separately
2
+
3
+ > Prove claim behavior once, without coupling the proof to a document, workflow, or GraphQL schema.
4
+
5
+ Use two test layers when adopting Ablo behind an existing application:
6
+
7
+ | Proof | Responsibility |
8
+ |---|---|
9
+ | Domain contract | State transitions, stale evidence, provenance, idempotency, and old/new path parity. |
10
+ | Hosted coordination conformance | Participant identity, exclusion, heartbeat, release, and lease-expiry recovery. |
11
+
12
+ The runnable hosted proof is
13
+ [`examples/coordination-conformance`](../../../../examples/coordination-conformance/README.md).
14
+ It creates a temporary test branch that inherits an existing non-production
15
+ schema. It does not push a schema or change domain rows.
16
+
17
+ ## File structure
18
+
19
+ ```text
20
+ src/conformance/index.ts
21
+ -> src/conformance/claimExclusion.ts
22
+ -> src/conformance/contract.ts
23
+ src/runtime/index.ts
24
+ -> src/runtime/client.ts
25
+ -> src/runtime/config.ts
26
+ live/index.ts
27
+ -> live/claimAndExit.ts
28
+ ```
29
+
30
+ The conformance operation depends on a narrow structural claim interface. The
31
+ runtime child supplies the real Ablo model resource. Domain examples depend on
32
+ the same narrow behavior without inheriting this runner's branch or credential
33
+ setup.
34
+
35
+ ## Run
36
+
37
+ First run the deterministic structure and configuration checks:
38
+
39
+ ```bash
40
+ cd examples/coordination-conformance
41
+ npm test
42
+ npm run typecheck
43
+ ```
44
+
45
+ Then name an existing model on the CLI login's dedicated non-production
46
+ project:
47
+
48
+ ```bash
49
+ ABLO_CONFORMANCE_MODEL=existingModel npm run test:live
50
+ ```
51
+
52
+ The model name supplies only a typed claim namespace. Every claim target is a
53
+ new random identifier; the proof creates no model row. The disposable branch
54
+ is deleted even when an assertion fails.
55
+
56
+ The runtime deliberately calls `model.claim(id, options)`. This identifier
57
+ overload is row-free. `model.claim({ id, ...options })` is the row-backed form:
58
+ it reads the model row and is not interchangeable in a coordination-only
59
+ rollout.
60
+
61
+ ## What this lets domain examples omit
62
+
63
+ A document-processing example does not need its own branch provisioning,
64
+ session delegation, heartbeat, or process-death fixture. It must still test its
65
+ own behavior when a claim is won, skipped, released after failure, and combined
66
+ with changing evidence.
67
+
68
+ This separation prevents a vertical testcase from force-replacing an inherited
69
+ schema merely to re-prove generic lease behavior.