@abloatai/ablo 0.48.0 → 0.50.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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +144 -2
- package/README.md +12 -12
- package/dist/ai-sdk.d.ts +11 -0
- package/dist/ai-sdk.d.ts.map +1 -1
- package/dist/ai-sdk.js +15 -0
- package/dist/ai-sdk.js.map +1 -1
- package/dist/batching.d.ts +7 -0
- package/dist/batching.d.ts.map +1 -0
- package/dist/batching.js +7 -0
- package/dist/batching.js.map +1 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -0
- package/dist/client.js.map +1 -1
- package/dist/context/await.d.ts +10 -0
- package/dist/context/await.d.ts.map +1 -0
- package/dist/context/await.js +33 -0
- package/dist/context/await.js.map +1 -0
- package/dist/context/evidence.d.ts +11 -0
- package/dist/context/evidence.d.ts.map +1 -0
- package/dist/context/evidence.js +53 -0
- package/dist/context/evidence.js.map +1 -0
- package/dist/context/sources.d.ts +21 -0
- package/dist/context/sources.d.ts.map +1 -0
- package/dist/context/sources.js +36 -0
- package/dist/context/sources.js.map +1 -0
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +33 -0
- package/dist/context.js.map +1 -0
- package/dist/coordination.d.ts +8 -0
- package/dist/coordination.d.ts.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/docs/agent-messaging.md +2 -2
- package/docs/api.md +2 -2
- package/docs/concurrency-convention.md +82 -268
- package/docs/context.md +170 -0
- package/docs/coordination.md +178 -905
- package/docs/data-sources.md +32 -1
- package/docs/examples/ai-sdk-tool.md +4 -0
- package/docs/guarantees.md +29 -21
- package/docs/how-it-works.md +10 -40
- package/docs/identity.md +48 -0
- package/docs/index.md +2 -2
- package/docs/integrations.md +4 -0
- package/docs/migration.md +46 -443
- package/docs/operating-on-your-database.md +29 -36
- package/examples/README.md +24 -0
- package/examples/agent-turn.ts +37 -0
- package/examples/data-source/ablo-driver.ts +4 -4
- package/examples/data-source/customer-server.ts +9 -4
- package/examples/data-source/schema.ts +1 -1
- package/examples/expensive-agent-turn.ts +76 -0
- package/examples/quickstart.ts +9 -7
- package/package.json +19 -5
- package/docs/internal/README.md +0 -18
- package/docs/internal/agent-fleet-coordination-design.md +0 -171
- package/docs/internal/commit-identifiers.md +0 -91
- package/docs/internal/concurrency-open-decisions.md +0 -37
- package/docs/internal/data-source-reverse-channel.md +0 -147
- package/docs/internal/per-field-conflict-detection.md +0 -165
- package/docs/internal/postgres-replication.md +0 -64
- package/docs/internal/serializable-schema.md +0 -119
- package/docs/internal/structure.md +0 -36
package/dist/coordination.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
export * from '@abloatai/transaction/coordination';
|
|
2
|
+
/**
|
|
3
|
+
* Coordination vocabulary that the streams module declares.
|
|
4
|
+
*
|
|
5
|
+
* A caller that holds a claim, watches presence, or types an activity feed
|
|
6
|
+
* needs these names, and coordination is where they belong — so they are
|
|
7
|
+
* surfaced here rather than leaving callers to reach into the type module.
|
|
8
|
+
*/
|
|
9
|
+
export type { Activity, Claim, ClaimTarget } from '@abloatai/transaction/types/streams';
|
|
2
10
|
//# sourceMappingURL=coordination.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coordination.d.ts","sourceRoot":"","sources":["../src/coordination.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAC"}
|
|
1
|
+
{"version":3,"file":"coordination.d.ts","sourceRoot":"","sources":["../src/coordination.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAC;AAEnD;;;;;;GAMG;AACH,YAAY,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export * from '@abloatai/transaction';
|
|
2
2
|
export { Ablo as default } from '@abloatai/transaction';
|
|
3
|
+
/**
|
|
4
|
+
* The logger shape a caller passes in, and the no-op it can pass instead.
|
|
5
|
+
*
|
|
6
|
+
* Anything that supplies its own logging to the client has to name this type,
|
|
7
|
+
* so it belongs on the surface the client itself is imported from.
|
|
8
|
+
*/
|
|
9
|
+
export { noopLogger } from '@abloatai/transaction/logger';
|
|
10
|
+
export type { Logger } from '@abloatai/transaction/logger';
|
|
3
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,IAAI,IAAI,OAAO,EAAE,MAAM,uBAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,IAAI,IAAI,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAExD;;;;;GAKG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC1D,YAAY,EAAE,MAAM,EAAE,MAAM,8BAA8B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
export * from '@abloatai/transaction';
|
|
2
2
|
export { Ablo as default } from '@abloatai/transaction';
|
|
3
|
+
/**
|
|
4
|
+
* The logger shape a caller passes in, and the no-op it can pass instead.
|
|
5
|
+
*
|
|
6
|
+
* Anything that supplies its own logging to the client has to name this type,
|
|
7
|
+
* so it belongs on the surface the client itself is imported from.
|
|
8
|
+
*/
|
|
9
|
+
export { noopLogger } from '@abloatai/transaction/logger';
|
|
3
10
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,IAAI,IAAI,OAAO,EAAE,MAAM,uBAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,IAAI,IAAI,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAExD;;;;;GAKG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC"}
|
package/docs/agent-messaging.md
CHANGED
|
@@ -12,7 +12,7 @@ HTTP, or be replayed from the sync cursor.
|
|
|
12
12
|
| Need | Use | Why |
|
|
13
13
|
| --- | --- | --- |
|
|
14
14
|
| "I am holding this row because..." | `claim({ description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
|
|
15
|
-
| "Remember this handoff/status/request" | A `messages` model | Durable row
|
|
15
|
+
| "Remember this handoff/status/request" | A `messages` model | Durable row, replayed after reconnect and readable by HTTP agents. |
|
|
16
16
|
|
|
17
17
|
Claim context is ephemeral. If a participant was offline, reconnected later, or
|
|
18
18
|
only uses `transport: "http"`, it can miss claim/presence frames. Message rows
|
|
@@ -141,6 +141,6 @@ their cursor and see the rows they missed while offline.
|
|
|
141
141
|
## Retention
|
|
142
142
|
|
|
143
143
|
Deleting or archiving old `messages` rows is your app's policy. The sync log is
|
|
144
|
-
still durable audit/history:
|
|
144
|
+
still durable audit/history: messages have no message-specific TTL. That is
|
|
145
145
|
useful for coordination and compliance, but a chat-scale product should plan
|
|
146
146
|
retention before writing high-volume conversation traffic.
|
package/docs/api.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> The per-method reference for every model call an agent or an interface can make.
|
|
4
4
|
|
|
5
|
-
> **Upgrading?**
|
|
6
|
-
> [
|
|
5
|
+
> **Upgrading?** Follow the version-matched workflow in the
|
|
6
|
+
> [Upgrade Guide](./migration.md), then read the intervening changelog entries.
|
|
7
7
|
|
|
8
8
|
This is the per-method reference for reading and writing rows that stay in
|
|
9
9
|
sync across sessions. You declare your models once, then call the same
|
|
@@ -1,305 +1,119 @@
|
|
|
1
1
|
# Concurrency Convention
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> What Ablo checks when a guarded write depends on earlier state.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Ablo never infers whether a write depends on earlier state. You decide, in two
|
|
6
|
+
places. The model's `conflict` setting in the schema says what each kind of
|
|
7
|
+
participant does when it hits a conflict, and it is the policy for that model.
|
|
8
|
+
A per-write `onStale` states the disposition for one write. Ablo enforces what
|
|
9
|
+
you declared and nothing else.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Unguarded writes
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
**The engine surfaces the truthful current state and lets the intelligent actor —
|
|
14
|
-
agent or human — decide what to do. It does not force a resolution.**
|
|
15
|
-
|
|
16
|
-
That is the whole convention. Everything below is a consequence of it.
|
|
17
|
-
|
|
18
|
-
Classical concurrency control is *coercive*: it imposes the remedy. Two-phase
|
|
19
|
-
locking forces a block; optimistic concurrency forces an abort. Ablo's wager is
|
|
20
|
-
that the actor in the loop (an agent reasoning over the change, or a human
|
|
21
|
-
watching the row) is better placed to resolve a conflict than a fixed rule baked
|
|
22
|
-
into the storage layer. So the engine's job narrows to one thing: **report what
|
|
23
|
-
is true, on time, and get out of the way.**
|
|
24
|
-
|
|
25
|
-
There are two forms of non-coercion, and they are the same principle at two
|
|
26
|
-
moments in time:
|
|
27
|
-
|
|
28
|
-
| form | when | mechanism |
|
|
29
|
-
|---|---|---|
|
|
30
|
-
| **Claim** | *prospective*: before you act | reserve the row; others queue. Coordinate so the conflict never forms. |
|
|
31
|
-
| **Notification** | *in-flight*: after a concurrent change | surface the changed value; the actor resolves and re-issues. |
|
|
32
|
-
|
|
33
|
-
Use a claim when you will hold the row across a slow read→reason→write gap. Use a
|
|
34
|
-
notification when you didn't, and the premise moved under you.
|
|
35
|
-
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
## 2. The dispositions (`onStale`)
|
|
39
|
-
|
|
40
|
-
Every guarded write (and every premise declared in §4) says what should happen
|
|
41
|
-
when it goes stale. Three modes, split by whether they **force** an outcome:
|
|
42
|
-
|
|
43
|
-
| mode | coercive? | what the engine does | who resolves | use when |
|
|
44
|
-
|---|---|---|---|---|
|
|
45
|
-
| `notify` | **No**: surface + delegate | Holds the write (does **not** apply it); returns a `StaleNotification` with the current value. | The actor (agent or human) reconciles and re-issues. | The aligned mode: tell the actor what changed, let it solve. |
|
|
46
|
-
| `reject` | **Yes**: force-abort | Throws `AbloStaleContextError`; the batch is discarded. | The caller retries from scratch. | Hard invariants; legacy/strict callers. The current default. |
|
|
47
|
-
| `overwrite` | **Yes**: force-clobber | Overwrites blindly last-writer-wins; **no** signal. | Nobody. | You genuinely own the field and concurrent values are noise. |
|
|
48
|
-
|
|
49
|
-
> `notify` is the convention. `reject` and `overwrite` are escape hatches for the
|
|
50
|
-
> two ends — "never let this be wrong" and "never bother me." They are not the
|
|
51
|
-
> spirit; they are the boundary of it.
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## 3. What is checked: two premises
|
|
56
|
-
|
|
57
|
-
A conflict is a **premise intersection** — what your operation was based on
|
|
58
|
-
overlaps a concurrent delta. Ablo checks two premises, and they are independent.
|
|
59
|
-
They differ only in what declared them:
|
|
60
|
-
|
|
61
|
-
| premise | declared by | question | scope |
|
|
62
|
-
|---|---|---|---|
|
|
63
|
-
| **Write-target** | per-op `readAt` | "did a row I'm **writing** change since I read it?" | the rows in `operations[]` |
|
|
64
|
-
| **Batch** | batch-level `reads[]` | "did anything I **looked at** change since I read it?" | rows/groups in `reads[]`, even if not written |
|
|
65
|
-
|
|
66
|
-
The write-target check alone is the narrow case the canary anomaly defeats: an
|
|
67
|
-
agent reads `deal.stage`, writes `task.status`, and a peer moves `deal.stage` —
|
|
68
|
-
`task` never changed, so a write-target-only check waves it through. The batch
|
|
69
|
-
premise closes that gap.
|
|
70
|
-
|
|
71
|
-
---
|
|
72
|
-
|
|
73
|
-
## 4. The batch premise (`reads[]`)
|
|
74
|
-
|
|
75
|
-
A commit may declare, at the batch level, what its writes were based on.
|
|
76
|
-
Two granularities, developer's choice per entry:
|
|
13
|
+
A plain write has no stale premise:
|
|
77
14
|
|
|
78
15
|
```ts
|
|
79
|
-
|
|
80
|
-
{ model: 'Document', id: 's-1', readAt: N, fields?: ['title'] }, // ROW premise
|
|
81
|
-
{ group: 'workspace:abc', readAt: N, onStale: 'notify' }, // GROUP premise
|
|
82
|
-
]
|
|
16
|
+
await ablo.tasks.update({ id, data: { status: 'done' } });
|
|
83
17
|
```
|
|
84
18
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
and claims**. This is the more Ablo-native granularity.
|
|
19
|
+
If no active claim conflicts with it, the write is last-write-wins. That is a
|
|
20
|
+
choice rather than a fallback: use it for independent assignments where the
|
|
21
|
+
latest value should win. When a model's writes are never independent, say so
|
|
22
|
+
once in its `conflict` setting instead of at every call site.
|
|
90
23
|
|
|
91
|
-
|
|
92
|
-
*all* the writes in the commit, so its disposition governs the batch:
|
|
93
|
-
`reject` aborts it, `notify` holds **every** write and notifies, `overwrite`
|
|
94
|
-
lets them land. Per-entry `onStale` defaults to `reject`.
|
|
24
|
+
## Guarded writes
|
|
95
25
|
|
|
96
|
-
|
|
26
|
+
Pass the exact returned rows when a write is based on values previously read:
|
|
97
27
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
28
|
+
```ts
|
|
29
|
+
const task = await ablo.tasks.get({ id });
|
|
30
|
+
const policy = await ablo.policies.get({ id: policyId });
|
|
31
|
+
if (!task || !policy) throw new Error('required input is missing');
|
|
32
|
+
|
|
33
|
+
await ablo.tasks.update({
|
|
34
|
+
id: task.id,
|
|
35
|
+
data: { status: 'done' },
|
|
36
|
+
reads: [task, policy],
|
|
37
|
+
});
|
|
38
|
+
```
|
|
109
39
|
|
|
110
|
-
|
|
40
|
+
Ablo privately resolves each exact object to its model, id, and read watermark,
|
|
41
|
+
then compares those premises with current state when the write is accepted.
|
|
42
|
+
Clones, fabrications, and rows returned by another client are rejected locally.
|
|
111
43
|
|
|
112
|
-
|
|
|
44
|
+
| Disposition | If the premise is stale |
|
|
113
45
|
|---|---|
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `readAt` | the watermark the committer reasoned against |
|
|
118
|
-
| `observedSyncId` | the newest delta on the premise: re-read at/after this |
|
|
119
|
-
| `writtenBy` | `{ kind, id }` of the concurrent author, reported faithfully |
|
|
120
|
-
|
|
121
|
-
`scope: 'row'` adds:
|
|
46
|
+
| `reject` | Reject the write with `AbloStaleContextError`. |
|
|
47
|
+
| `notify` | Keep the current row, return a `StaleNotification`, and let the caller reconcile. |
|
|
48
|
+
| `overwrite` | Apply the new value without enforcing the stale premise. |
|
|
122
49
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
50
|
+
`notify` is useful when an agent or human can merge the new information.
|
|
51
|
+
`reject` is useful when the caller should restart from fresh state. Use
|
|
52
|
+
`overwrite` only when the newer assignment should unconditionally win.
|
|
126
53
|
|
|
127
|
-
|
|
54
|
+
## Functional updates
|
|
128
55
|
|
|
129
|
-
|
|
130
|
-
|---|---|
|
|
131
|
-
| `group` | the group premise that fired (`report:abc`) |
|
|
132
|
-
| `propagation?` | how `target` reached `group`: `{ via, through }` |
|
|
133
|
-
| `changed?` | how much of the group moved: `{ count, sample, truncated }` |
|
|
56
|
+
For a pure read-modify-write calculation, use the functional update form:
|
|
134
57
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
58
|
+
```ts
|
|
59
|
+
await ablo.counters.update(counterId, (current) => ({
|
|
60
|
+
value: current.value + 1,
|
|
61
|
+
}));
|
|
62
|
+
```
|
|
138
63
|
|
|
139
|
-
|
|
140
|
-
|
|
64
|
+
It performs the read, guarded write, and bounded reconciliation loop for you.
|
|
65
|
+
See [Coordination](./coordination.md#functional-updates).
|
|
141
66
|
|
|
142
|
-
|
|
67
|
+
## Claims
|
|
143
68
|
|
|
144
|
-
|
|
145
|
-
|
|
69
|
+
A claim protects a target across a longer interval. By default, other
|
|
70
|
+
participants cannot write the claimed target, while contenders that claim it
|
|
71
|
+
wait their turn. Reads remain open. A model's explicit conflict policy can
|
|
72
|
+
choose a different disposition for a participant kind.
|
|
146
73
|
|
|
147
|
-
|
|
148
|
-
// Trigger: a guarded write under the non-coercive mode.
|
|
149
|
-
const receipt = await ablo.task.update({
|
|
150
|
-
id, data: { status: 'blocked' },
|
|
151
|
-
readAt: myWatermark,
|
|
152
|
-
onStale: 'notify',
|
|
153
|
-
});
|
|
74
|
+
Claims and stale guards protect different things:
|
|
154
75
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
ws.subscribe('conflict:notified', ({ notifications }) => notifications.forEach(resolve));
|
|
160
|
-
|
|
161
|
-
function resolve(n: StaleNotification) {
|
|
162
|
-
// n.target — the row that moved, and which fields (both scopes)
|
|
163
|
-
// n.writtenBy — who moved it (e.g. { kind: 'agent', id: 'agent-b' })
|
|
164
|
-
if (n.scope === 'group') {
|
|
165
|
-
// "something in report:abc moved" — but `target` says WHICH row, so this is
|
|
166
|
-
// a one-row re-read, not a re-read of the group.
|
|
167
|
-
// n.propagation?.via — 'self' | 'parent' | 'transitive'
|
|
168
|
-
return refreshRow(n.target.model, n.target.id, n.observedSyncId);
|
|
169
|
-
}
|
|
170
|
-
// n.currentValues — what's actually there now (e.g. { status: 'done' })
|
|
171
|
-
if (!stillValid(n.currentValues)) return; // premise gone → drop the write
|
|
172
|
-
|
|
173
|
-
return ablo.task.update({
|
|
174
|
-
id: n.target.id,
|
|
175
|
-
data: { status: 'blocked' },
|
|
176
|
-
readAt: n.observedSyncId, // adopt the new high-water mark — this is what terminates the loop
|
|
177
|
-
onStale: 'notify',
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
```
|
|
76
|
+
- A claim excludes other participants while it is held.
|
|
77
|
+
- A stale guard proves that the state a write depended on has not changed.
|
|
78
|
+
- A write made under a claim is still rejected if its own claimed snapshot has
|
|
79
|
+
become stale.
|
|
181
80
|
|
|
182
|
-
|
|
183
|
-
a peer that keeps writing only ever notifies you against a *newer* baseline, never
|
|
184
|
-
the same one twice.
|
|
81
|
+
See [Coordination](./coordination.md#claims) for the API.
|
|
185
82
|
|
|
186
|
-
|
|
83
|
+
## Cross-row and batch premises
|
|
187
84
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
85
|
+
Model writes and lower-level commits can declare rows they read even when the
|
|
86
|
+
write targets somewhere else. This protects decisions such as “update the task
|
|
87
|
+
only if the deal I inspected has not changed.” A stale batch premise applies to
|
|
88
|
+
the whole batch so atomicity is preserved.
|
|
191
89
|
|
|
192
|
-
|
|
90
|
+
Use the high-level model methods unless you are building a custom runtime. When
|
|
91
|
+
you do use batch premises, declare only the rows or groups that materially
|
|
92
|
+
influenced the decision; overly broad premises create unnecessary contention.
|
|
193
93
|
|
|
194
|
-
|
|
195
|
-
one-row re-read.
|
|
196
|
-
- **`group`** — the premise that fired.
|
|
197
|
-
- **`propagation`** — how the row reached the group. `via: 'self'` means the row
|
|
198
|
-
*is* the group's scope root; `'parent'` means one containment edge below it;
|
|
199
|
-
`'transitive'` means further up, with `through` listing the intermediate
|
|
200
|
-
models (`['slides', 'decks']`).
|
|
201
|
-
- **`changed`** — how much moved, in **distinct rows** rather than deltas.
|
|
202
|
-
`count` is the total, `sample` names the most recently changed (capped), and
|
|
203
|
-
`truncated` says whether the sample tells the whole story.
|
|
94
|
+
## Notifications
|
|
204
95
|
|
|
205
|
-
`
|
|
206
|
-
|
|
96
|
+
A `StaleNotification` identifies the stale premise and provides the current
|
|
97
|
+
state needed to reconcile. The original write has not been applied.
|
|
207
98
|
|
|
208
|
-
|
|
209
|
-
if (n.changed && n.changed.count > 50) return null; // too much moved — abandon the write
|
|
210
|
-
for (const row of n.changed?.sample ?? []) await refresh(row.model, row.id);
|
|
211
|
-
```
|
|
99
|
+
A typical loop is:
|
|
212
100
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
101
|
+
1. Inspect the current value in the notification.
|
|
102
|
+
2. Recompute the intended change.
|
|
103
|
+
3. Submit a new guarded write with a fresh premise.
|
|
216
104
|
|
|
217
|
-
|
|
105
|
+
Give this loop a retry budget. Continuous contention should surface to the
|
|
106
|
+
caller rather than retry forever.
|
|
218
107
|
|
|
219
|
-
|
|
220
|
-
is a separate decision, and it belongs on the belief, not on the write.
|
|
108
|
+
## Boundaries
|
|
221
109
|
|
|
222
|
-
|
|
223
|
-
when you last looked. Its disposition says what a move does to your next commit:
|
|
110
|
+
Concurrency control does not replace:
|
|
224
111
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
112
|
+
- database constraints and transactions for application invariants;
|
|
113
|
+
- authorization for deciding who may read or write;
|
|
114
|
+
- idempotency for safely replaying the same request;
|
|
115
|
+
- claims for exclusivity across slow, side-effecting work.
|
|
229
116
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
It is enforced at the commit chokepoint, so it is a guarantee rather than a
|
|
235
|
-
convention. And the gate takes a key: it does not reopen on its own, because an
|
|
236
|
-
agent retrying blindly would land exactly the write `reject` was asked to
|
|
237
|
-
prevent. Re-read, then re-register the track to say you have. See
|
|
238
|
-
[Groups](./groups.md#reporting-or-gating-onstale).
|
|
239
|
-
|
|
240
|
-
A row that goes stale twice is telling you something: the read→reason→write gap
|
|
241
|
-
wants the *prospective* guard, not the in-flight one. Escalate to a claim
|
|
242
|
-
(§1) rather than raising `retries`.
|
|
243
|
-
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
## 6. Boundaries & invariants
|
|
247
|
-
|
|
248
|
-
What the convention **guarantees**, and where it **stops**:
|
|
249
|
-
|
|
250
|
-
1. **Engine surfaces, actor decides.** Under `notify` the engine never
|
|
251
|
-
repairs, merges, or re-plans. It reports `currentValues` and the actor (agent
|
|
252
|
-
or human) owns the resolution. The engine does not distinguish them — it is
|
|
253
|
-
actor-neutral by design.
|
|
254
|
-
|
|
255
|
-
2. **Truthfulness:** `currentValues` / `observedSyncId` reflect committed state at
|
|
256
|
-
detection time, inside the same transaction as the write. A notification is
|
|
257
|
-
never speculative.
|
|
258
|
-
|
|
259
|
-
3. **No livelock, which is not the same as termination.** The monotonic
|
|
260
|
-
`sync_id` landing order is the serialization order. The stale committer
|
|
261
|
-
always yields/recomputes — an asymmetry that rules out the symmetric
|
|
262
|
-
notify-rewrite livelock, because each round adopts a newer `observedSyncId`
|
|
263
|
-
and no baseline is ever reasoned against twice.
|
|
264
|
-
|
|
265
|
-
It does **not** rule out starvation. A peer writing faster than your
|
|
266
|
-
read→decide→write gap keeps winning, and the engine never re-issues on your
|
|
267
|
-
behalf, so the rounds are yours and they are unbounded. Progress in the
|
|
268
|
-
watermark is not progress in the work — and for an agent, each round is a
|
|
269
|
-
model call. `update(id, fn)` bounds it for you and hands the conflict to
|
|
270
|
-
your updater (§5.3); a hand-rolled loop must bound itself.
|
|
271
|
-
|
|
272
|
-
4. **Scope: reversible DB state only.** The convention governs writes to the
|
|
273
|
-
shared database, which are inherently reversible (prior value in
|
|
274
|
-
`sync_deltas`). **Irreversible external side-effects** (emails, payments,
|
|
275
|
-
third-party calls) are *out of scope* — the engine cannot hold or undo them,
|
|
276
|
-
so they must not be gated by `notify`.
|
|
277
|
-
|
|
278
|
-
5. **Defaults.** A plain write (no `readAt`) is last-writer-wins with **no**
|
|
279
|
-
check. A guarded write with `readAt` but no `onStale` defaults to `reject`.
|
|
280
|
-
|
|
281
|
-
6. **Policy seam.** Custom `ConflictPolicy` functions see **write-target**
|
|
282
|
-
conflicts (`stale_context` / `claim_held`). **Batch-premise** conflicts are
|
|
283
|
-
resolved directly via each entry's `onStale`, not through the policy seam.
|
|
284
|
-
|
|
285
|
-
7. **Claims win when held.** A non-holder writing to a claimed row is rejected
|
|
286
|
-
(`AbloClaimedError`) regardless of `readAt` — the prospective form takes
|
|
287
|
-
precedence over the in-flight form. Only `user`/`system` principals may
|
|
288
|
-
`bypass` a foreign claim; agents may not.
|
|
289
|
-
|
|
290
|
-
---
|
|
291
|
-
|
|
292
|
-
## 7. What this convention does not cover
|
|
293
|
-
|
|
294
|
-
Three limits worth knowing before you rely on it.
|
|
295
|
-
|
|
296
|
-
- **Irreversible external side-effects.** Emails, payments, and third-party
|
|
297
|
-
calls are not gated by this convention (§6.4). The engine cannot hold or undo
|
|
298
|
-
them, so never place one behind `notify`.
|
|
299
|
-
- **A caller that declares nothing gets no check.** The batch premise catches
|
|
300
|
-
only what you declared. Write-target checking needs a `readAt` to compare
|
|
301
|
-
against, so a plain write with neither is last-writer-wins (§6.5). What you
|
|
302
|
-
declare is what is protected.
|
|
303
|
-
- **`writtenBy.kind` reports what authenticated, not what you meant.** An `sk_`
|
|
304
|
-
key resolves to `system`, not `agent`. How identities map to participant kinds
|
|
305
|
-
is a separate concern from this convention.
|
|
117
|
+
The rule is simple: the model's `conflict` setting is the policy, and each write
|
|
118
|
+
declares what it read. Plain writes are last-write-wins because declaring
|
|
119
|
+
nothing is itself a decision, so make it deliberately.
|
package/docs/context.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Context
|
|
2
|
+
|
|
3
|
+
> Assemble the current information for an action and carry its authoritative
|
|
4
|
+
> Ablo reads into the write that follows.
|
|
5
|
+
|
|
6
|
+
`context()` is a standalone SDK function. It does not run a model, keep a
|
|
7
|
+
conversation, search documents, or create memory. The application chooses the
|
|
8
|
+
values; Ablo awaits them and identifies the exact returned rows that can guard
|
|
9
|
+
a later write.
|
|
10
|
+
|
|
11
|
+
## Context, model, write
|
|
12
|
+
|
|
13
|
+
This is the complete shape. `loadMemories()` and `parseTaskUpdate()` are
|
|
14
|
+
application functions; they are not Ablo APIs.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { context } from '@abloatai/ablo/context';
|
|
18
|
+
import { contextMessage } from '@abloatai/ablo/ai-sdk';
|
|
19
|
+
import { generateText } from 'ai';
|
|
20
|
+
|
|
21
|
+
const ctx = await context({
|
|
22
|
+
ablo,
|
|
23
|
+
data: {
|
|
24
|
+
task: ablo.tasks.get({ id: taskId }),
|
|
25
|
+
documents: ablo.documents.list({ where: { taskId } }),
|
|
26
|
+
memory: loadMemories(taskId),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!ctx.data.task) throw new Error('Task not found');
|
|
31
|
+
|
|
32
|
+
const result = await generateText({
|
|
33
|
+
model,
|
|
34
|
+
messages: [...history, contextMessage(ctx)],
|
|
35
|
+
tools,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await ablo.tasks.update({
|
|
39
|
+
id: ctx.data.task.id,
|
|
40
|
+
data: parseTaskUpdate(result.text),
|
|
41
|
+
reads: ctx.reads,
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If an authoritative row moves during the model call, the update rejects with
|
|
46
|
+
`AbloStaleContextError`. Rebuild the context before trying again. The model is
|
|
47
|
+
not called or retried by `context()`.
|
|
48
|
+
|
|
49
|
+
## Choose the protection separately
|
|
50
|
+
|
|
51
|
+
Context assembly and concurrency policy answer different questions. Choose the
|
|
52
|
+
protection according to the work:
|
|
53
|
+
|
|
54
|
+
| Situation | Use | Why |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Bring several current values into one model call | `context()` | Awaits the selected values and collects their evidence. |
|
|
57
|
+
| Reject if any selected Ablo row moves | `reads: ctx.reads` | Checks those premises when the write reaches the server. |
|
|
58
|
+
| Avoid paying for a model call while another participant owns the row | `claim()` | Waits first, then supplies fresh state. |
|
|
59
|
+
| Compute a patch from one current row without external work | Functional `update()` | Re-reads and retries the pure calculation. |
|
|
60
|
+
|
|
61
|
+
A stale guard detects a change after the work has happened. When the work is
|
|
62
|
+
slow or costly and must be exclusive, take a claim before assembling context.
|
|
63
|
+
See [Coordination](./coordination.md) for the full choice.
|
|
64
|
+
|
|
65
|
+
## Result
|
|
66
|
+
|
|
67
|
+
The result has four members:
|
|
68
|
+
|
|
69
|
+
| Member | Meaning |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `data` | The selected values, with nested promises resolved. |
|
|
72
|
+
| `reads` | Exact Ablo rows accepted by a write's `reads` option. |
|
|
73
|
+
| `cursor` | The greatest watermark among those authoritative reads, or `null`. |
|
|
74
|
+
| `sources` | One provenance summary for each top-level value. |
|
|
75
|
+
|
|
76
|
+
If a row in `ctx.reads` moves before the write, the server rejects the write as
|
|
77
|
+
stale. A plain value can inform the action, but it does not gain that guarantee.
|
|
78
|
+
This distinction is visible in `sources`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
ctx.sources;
|
|
82
|
+
// [
|
|
83
|
+
// { key: 'task', kind: 'ablo', guarantee: 'guardable', cursor: 42 },
|
|
84
|
+
// { key: 'memory', kind: 'value', guarantee: 'informational', cursor: null },
|
|
85
|
+
// ]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A top-level value may contain both kinds. It is then marked `mixed` and only
|
|
89
|
+
its exact Ablo rows appear in `ctx.reads`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// data: { briefing: { task, memory } }
|
|
93
|
+
// sources: [
|
|
94
|
+
// { key: 'briefing', kind: 'mixed', guarantee: 'partial', cursor: 42 },
|
|
95
|
+
// ]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`partial` does not weaken the included Ablo rows. It says the surrounding value
|
|
99
|
+
also contains information Ablo cannot guard.
|
|
100
|
+
|
|
101
|
+
## External context
|
|
102
|
+
|
|
103
|
+
Provider results pass through without an adapter or provider dependency. The
|
|
104
|
+
functions below belong to the application; they may call Mem0, Turbopuffer,
|
|
105
|
+
Reducto, or another system behind their own interfaces.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const ctx = await context({
|
|
109
|
+
ablo,
|
|
110
|
+
data: {
|
|
111
|
+
task: ablo.tasks.get({ id: taskId }),
|
|
112
|
+
memory: loadMemories({ query, userId }),
|
|
113
|
+
related: findRelatedChunks({ projectId, query }),
|
|
114
|
+
evidence: extractEvidence({ documentId }),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
These values are informational. Search ranking, citation versions, and memory
|
|
120
|
+
quality remain guarantees of their own systems. They do not become canonical
|
|
121
|
+
Ablo state unless the application writes them to an Ablo model and reads that
|
|
122
|
+
row back.
|
|
123
|
+
|
|
124
|
+
One rejected promise rejects the whole `context()` call. Requested information
|
|
125
|
+
is never omitted silently.
|
|
126
|
+
|
|
127
|
+
An absent row remains absent. It contributes no read evidence, so check required
|
|
128
|
+
rows before calling a model. `context()` does not turn a missing read into a
|
|
129
|
+
create-if-absent guard.
|
|
130
|
+
|
|
131
|
+
## AI SDK
|
|
132
|
+
|
|
133
|
+
The optional formatter produces a user message. It does not turn retrieved
|
|
134
|
+
content into a system instruction and does not take ownership of the run.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { contextMessage } from '@abloatai/ablo/ai-sdk';
|
|
138
|
+
import { generateText } from 'ai';
|
|
139
|
+
|
|
140
|
+
await generateText({
|
|
141
|
+
model,
|
|
142
|
+
messages: [
|
|
143
|
+
...history,
|
|
144
|
+
contextMessage(ctx, { include: ['task', 'documents', 'memory'] }),
|
|
145
|
+
],
|
|
146
|
+
tools,
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Selection, trimming, token budgets, conversation history, and model execution
|
|
151
|
+
remain application or framework policy. Applications may format `ctx.data`
|
|
152
|
+
themselves.
|
|
153
|
+
|
|
154
|
+
## Current limits
|
|
155
|
+
|
|
156
|
+
The first version deliberately has no:
|
|
157
|
+
|
|
158
|
+
- search or memory API;
|
|
159
|
+
- provider registry or provider-specific adapter;
|
|
160
|
+
- `since` cursor or incremental `changes` result;
|
|
161
|
+
- context session, persistence, or sharing lifecycle;
|
|
162
|
+
- token counting, trimming, summarisation, or model call;
|
|
163
|
+
- guarantee that a person or model understood the included information.
|
|
164
|
+
|
|
165
|
+
Store `ctx.cursor` in application-owned state if it is useful. Incremental
|
|
166
|
+
context is not yet derived from it.
|
|
167
|
+
|
|
168
|
+
`context` remains available as a schema model name. The helper lives at
|
|
169
|
+
`@abloatai/ablo/context`; it does not add `ablo.context()` or reserve a member
|
|
170
|
+
of the schema-backed client.
|