@abloatai/ablo 0.17.0 → 0.19.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 (64) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/BaseSyncedStore.d.ts +5 -4
  3. package/dist/BaseSyncedStore.js +38 -14
  4. package/dist/NetworkMonitor.js +3 -1
  5. package/dist/ObjectPool.d.ts +14 -1
  6. package/dist/ObjectPool.js +8 -1
  7. package/dist/SyncClient.js +7 -1
  8. package/dist/SyncEngineContext.js +2 -0
  9. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  10. package/dist/ai-sdk/coordination-context.js +3 -3
  11. package/dist/ai-sdk/index.d.ts +3 -4
  12. package/dist/ai-sdk/index.js +2 -3
  13. package/dist/ai-sdk/wrap.d.ts +13 -18
  14. package/dist/ai-sdk/wrap.js +2 -6
  15. package/dist/cli.cjs +459 -205
  16. package/dist/client/Ablo.d.ts +116 -23
  17. package/dist/client/Ablo.js +128 -38
  18. package/dist/client/ApiClient.d.ts +2 -2
  19. package/dist/client/ApiClient.js +27 -32
  20. package/dist/client/auth.d.ts +26 -0
  21. package/dist/client/auth.js +209 -1
  22. package/dist/client/createModelProxy.d.ts +16 -11
  23. package/dist/client/createModelProxy.js +15 -15
  24. package/dist/client/sessionMint.d.ts +10 -0
  25. package/dist/client/sessionMint.js +8 -1
  26. package/dist/coordination/schema.d.ts +10 -10
  27. package/dist/coordination/schema.js +7 -8
  28. package/dist/coordination/trace.d.ts +91 -0
  29. package/dist/coordination/trace.js +147 -0
  30. package/dist/core/StoreManager.js +3 -1
  31. package/dist/errorCodes.d.ts +2 -0
  32. package/dist/errorCodes.js +2 -0
  33. package/dist/errors.d.ts +10 -2
  34. package/dist/errors.js +20 -9
  35. package/dist/index.d.ts +7 -1
  36. package/dist/index.js +12 -0
  37. package/dist/interfaces/index.d.ts +45 -2
  38. package/dist/policy/types.d.ts +33 -9
  39. package/dist/policy/types.js +42 -11
  40. package/dist/react/AbloProvider.d.ts +2 -2
  41. package/dist/schema/ddl.d.ts +36 -0
  42. package/dist/schema/ddl.js +66 -0
  43. package/dist/schema/index.d.ts +1 -1
  44. package/dist/schema/index.js +1 -1
  45. package/dist/surface.d.ts +1 -1
  46. package/dist/surface.js +2 -0
  47. package/dist/sync/HydrationCoordinator.js +7 -3
  48. package/dist/sync/SyncWebSocket.d.ts +11 -2
  49. package/dist/sync/SyncWebSocket.js +68 -4
  50. package/dist/sync/awaitClaimGrant.js +9 -2
  51. package/dist/sync/createClaimStream.d.ts +9 -2
  52. package/dist/sync/createClaimStream.js +41 -7
  53. package/dist/sync/participants.d.ts +12 -11
  54. package/dist/sync/participants.js +5 -5
  55. package/dist/transactions/TransactionQueue.d.ts +1 -0
  56. package/dist/transactions/TransactionQueue.js +40 -3
  57. package/dist/types/streams.d.ts +57 -135
  58. package/dist/wire/errorEnvelope.d.ts +13 -0
  59. package/docs/coordination.md +16 -0
  60. package/docs/debugging.md +194 -0
  61. package/docs/index.md +1 -0
  62. package/package.json +1 -1
  63. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  64. package/dist/ai-sdk/claim-broadcast.js +0 -79
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Wire contract (apps/sync-server/src/hub/types.ts):
12
12
  * • Outbound: `{ type: 'claim_begin', payload: { claimId,
13
- * entityType, entityId, action, field?, estimatedMs? } }`
13
+ * entityType, entityId, reason, field?, estimatedMs? } }`
14
14
  * • Outbound: `{ type: 'claim_abandon', payload: { claimId,
15
15
  * entityType?, entityId? } }`
16
16
  * • Inbound (via presence): `event.activeClaims: Claim[]`
@@ -22,12 +22,19 @@
22
22
  * deletes.
23
23
  */
24
24
  import type { SyncWebSocket } from './SyncWebSocket.js';
25
- import type { ClaimStream } from '../types/streams.js';
25
+ import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '../types/streams.js';
26
26
  export interface ClaimStreamConfig {
27
27
  /** Identity used to filter our own active claims out of `others`. */
28
28
  participantId: string;
29
29
  }
30
30
  export interface AttachableClaimStream extends ClaimStream {
31
+ /**
32
+ * INTERNAL lease mint — sends the `claim_begin` frame and returns a held
33
+ * {@link Claim} (no row `data`; the model door reads the row and stamps it).
34
+ * Not part of the public `ClaimStream` surface: the only public way to take a
35
+ * claim is `ablo.<model>.claim({ id })`, which builds on this.
36
+ */
37
+ claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
31
38
  attach(transport: SyncWebSocket): void;
32
39
  dispose(): void;
33
40
  }
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Wire contract (apps/sync-server/src/hub/types.ts):
12
12
  * • Outbound: `{ type: 'claim_begin', payload: { claimId,
13
- * entityType, entityId, action, field?, estimatedMs? } }`
13
+ * entityType, entityId, reason, field?, estimatedMs? } }`
14
14
  * • Outbound: `{ type: 'claim_abandon', payload: { claimId,
15
15
  * entityType?, entityId? } }`
16
16
  * • Inbound (via presence): `event.activeClaims: Claim[]`
@@ -24,6 +24,11 @@
24
24
  import { asyncIteratorFrom } from '../utils/asyncIterator.js';
25
25
  import { toMs } from '../utils/duration.js';
26
26
  import { descriptionFromMeta, participantKindFromWire, } from '../coordination/schema.js';
27
+ import { getContext } from '../context.js';
28
+ /** Readable target for the coordination trace: `documents:abc` / `documents:abc.title`. */
29
+ function claimLabel(type, id, field) {
30
+ return field ? `${type}:${id}.${field}` : `${type}:${id}`;
31
+ }
27
32
  export function createClaimStream(config, transport = null) {
28
33
  const { participantId } = config;
29
34
  // ── State: others' open claims, keyed by claimId ───────────────
@@ -37,6 +42,9 @@ export function createClaimStream(config, transport = null) {
37
42
  const queueByEntity = new Map();
38
43
  const entityKey = (type, id) => `${type}:${id}`;
39
44
  const EMPTY_QUEUE = Object.freeze([]);
45
+ // Last queue position we logged per own-claim, so advancing in line is traced
46
+ // once per change (not re-logged on every server re-fan of the same line).
47
+ const lastLoggedQueuePos = new Map();
40
48
  // ── Subscribers ──────────────────────────────────────────────────
41
49
  const listeners = new Set();
42
50
  const rejectionListeners = new Set();
@@ -96,7 +104,9 @@ export function createClaimStream(config, transport = null) {
96
104
  continue;
97
105
  const description = descriptionFromMeta(claim.meta);
98
106
  activeByClaimId.set(claim.claimId, {
107
+ object: 'claim',
99
108
  id: claim.claimId,
109
+ status: 'active',
100
110
  heldBy: event.userId,
101
111
  participantKind: participantKindFromWire(event.participantKind, event.isAgent),
102
112
  target: {
@@ -107,10 +117,10 @@ export function createClaimStream(config, transport = null) {
107
117
  field: claim.field,
108
118
  meta: claim.meta,
109
119
  },
110
- reason: claim.action,
120
+ reason: claim.reason,
111
121
  ...(description ? { description } : {}),
112
122
  ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
113
- announcedAt: claim.declaredAt,
123
+ createdAt: claim.declaredAt,
114
124
  expiresAt: claim.expiresAt,
115
125
  });
116
126
  mutated = true;
@@ -122,6 +132,12 @@ export function createClaimStream(config, transport = null) {
122
132
  unsubs.push(t.subscribe('claim_rejected', (rejection) => {
123
133
  if (!rejection.claimId)
124
134
  return;
135
+ if (ownClaims.has(rejection.claimId)) {
136
+ const tgt = rejection.target
137
+ ? claimLabel(rejection.target.entityType, rejection.target.entityId, rejection.target.field)
138
+ : rejection.claimId;
139
+ getContext().logger.info(`claim: rejected ${tgt}${rejection.heldBy ? ` — held by ${rejection.heldBy}` : ''}`, { claimId: rejection.claimId, reason: rejection.reason });
140
+ }
125
141
  // Drop the rejected own-claim so reconnect doesn't re-announce
126
142
  // a claim the server already rejected (would just spam both
127
143
  // sides with conflicts).
@@ -141,6 +157,10 @@ export function createClaimStream(config, transport = null) {
141
157
  const lost = payload;
142
158
  if (!lost.claimId)
143
159
  return;
160
+ if (ownClaims.has(lost.claimId)) {
161
+ const c = ownClaims.get(lost.claimId);
162
+ getContext().logger.info(`claim: lost ${c ? claimLabel(c.entityType, c.entityId, c.field) : lost.claimId} (preempted or expired)`, { claimId: lost.claimId });
163
+ }
144
164
  // Drop the lost own-claim so reconnect doesn't re-announce a lease we
145
165
  // no longer hold.
146
166
  ownClaims.delete(lost.claimId);
@@ -166,6 +186,16 @@ export function createClaimStream(config, transport = null) {
166
186
  queueByEntity.delete(key);
167
187
  else
168
188
  queueByEntity.set(key, Object.freeze([...line]));
189
+ // If WE are in this line, trace our position (the "agent queued behind a
190
+ // claim" moment) — once per position change, so advancing is visible.
191
+ const ourIndex = line.findIndex((c) => ownClaims.has(c.id));
192
+ if (ourIndex >= 0) {
193
+ const ourId = line[ourIndex].id;
194
+ if (lastLoggedQueuePos.get(ourId) !== ourIndex) {
195
+ lastLoggedQueuePos.set(ourId, ourIndex);
196
+ getContext().logger.info(`claim: queued for ${claimLabel(p.target.type, p.target.id)} — position ${ourIndex + 1} of ${line.length}, waiting`, { claimId: ourId });
197
+ }
198
+ }
169
199
  notifyListeners();
170
200
  }));
171
201
  // (3) On reconnect, re-announce every open self-claim — the
@@ -192,8 +222,7 @@ export function createClaimStream(config, transport = null) {
192
222
  entityId: claim.entityId,
193
223
  path: claim.path,
194
224
  range: claim.range,
195
- // Wire field stays `action` (coordination schema); source is `reason`.
196
- action: claim.reason,
225
+ reason: claim.reason,
197
226
  field: claim.field,
198
227
  meta: claim.meta,
199
228
  estimatedMs: claim.estimatedMs,
@@ -251,6 +280,9 @@ export function createClaimStream(config, transport = null) {
251
280
  };
252
281
  ownClaims.set(claimId, claim);
253
282
  sendBegin(claimId, claim);
283
+ // Coordination trace (info): the creator can SEE their human/agent claims.
284
+ getContext().logger.info(`claim: requesting ${claimLabel(claim.entityType, claim.entityId, claim.field)} for "${claim.reason}"` +
285
+ (claim.queue ? ' (will queue if contended)' : ''), { claimId });
254
286
  let revoked = false;
255
287
  const revoke = () => {
256
288
  if (revoked)
@@ -258,13 +290,15 @@ export function createClaimStream(config, transport = null) {
258
290
  revoked = true;
259
291
  ownClaims.delete(claimId);
260
292
  sendAbandon(claimId, claim);
293
+ getContext().logger.info(`claim: released ${claimLabel(claim.entityType, claim.entityId, claim.field)}`, { claimId });
261
294
  };
262
295
  return {
263
296
  object: 'claim',
264
- claimId,
297
+ id: claimId,
298
+ status: 'active',
265
299
  reason: args.reason,
266
300
  target: {
267
- model: args.entityType,
301
+ type: args.entityType,
268
302
  id: args.entityId,
269
303
  path: args.path,
270
304
  range: args.range,
@@ -1,12 +1,13 @@
1
1
  import type { SyncWebSocket } from './SyncWebSocket.js';
2
2
  import type { Schema, SchemaRecord } from '../schema/schema.js';
3
- import type { ActiveClaim, Activity, EntityRef, ClaimHandle, ClaimStream, Peer, PresenceStream, PresenceTarget } from '../types/streams.js';
3
+ import type { Claim, Activity, ClaimTarget, ClaimStream, Peer, PresenceStream, PresenceTarget } from '../types/streams.js';
4
+ import type { AttachableClaimStream } from './createClaimStream.js';
4
5
  /**
5
6
  * Scope accepted by participant APIs. The normal SDK shape is an
6
7
  * entity target (`{ type, id }`). Raw sync-group strings remain an
7
8
  * advanced transport escape hatch.
8
9
  */
9
- export type ParticipantScope = EntityRef | readonly EntityRef[] | string | readonly string[] | {
10
+ export type ParticipantScope = ClaimTarget | readonly ClaimTarget[] | string | readonly string[] | {
10
11
  readonly syncGroup: string;
11
12
  } | {
12
13
  readonly syncGroups: readonly string[];
@@ -44,7 +45,7 @@ export interface ParticipantJoinOptions {
44
45
  }
45
46
  export interface ScopedPresence {
46
47
  readonly self: Peer;
47
- readonly focus: EntityRef | null;
48
+ readonly focus: ClaimTarget | null;
48
49
  readonly others: ReadonlyArray<Peer>;
49
50
  update(activity: Activity): void;
50
51
  reading(detail?: string): void;
@@ -66,15 +67,15 @@ export interface ScopedClaimOptions {
66
67
  readonly ttl?: import('../types/streams.js').Duration;
67
68
  }
68
69
  export interface ScopedClaims {
69
- readonly focus: EntityRef | null;
70
- readonly others: ReadonlyArray<ActiveClaim>;
70
+ readonly focus: ClaimTarget | null;
71
+ readonly others: ReadonlyArray<Claim>;
71
72
  /**
72
73
  * Claim an exclusive claim on the participant's focus target (or
73
74
  * an explicit override via `opts.target`). Single verb — the old
74
75
  * `editing / writing / announce / claim(reason, opts)` overloads
75
76
  * collapsed into this one method.
76
77
  */
77
- claim(opts?: ScopedClaimOptions): ClaimHandle;
78
+ claim(opts?: ScopedClaimOptions): Claim;
78
79
  onRejected(listener: Parameters<ClaimStream['onRejected']>[0]): () => void;
79
80
  onChange(listener: () => void): () => void;
80
81
  }
@@ -84,14 +85,14 @@ export interface ParticipantFocusOptions {
84
85
  }
85
86
  export interface JoinedParticipant {
86
87
  /** Current exact thing this participant is reading/editing. */
87
- readonly target: EntityRef | null;
88
- readonly focusTarget: EntityRef | null;
88
+ readonly target: ClaimTarget | null;
89
+ readonly focusTarget: ClaimTarget | null;
89
90
  /** Transport scopes this participant is joined to for visibility/fan-out. */
90
91
  readonly syncGroups: readonly string[];
91
92
  readonly presence: ScopedPresence;
92
93
  readonly claims: ScopedClaims;
93
94
  readonly peers: ReadonlyArray<Peer>;
94
- readonly activeClaims: ReadonlyArray<ActiveClaim>;
95
+ readonly activeClaims: ReadonlyArray<Claim>;
95
96
  focus(target: PresenceTarget, options?: ParticipantFocusOptions): JoinedParticipant;
96
97
  leave(): void;
97
98
  [Symbol.asyncDispose](): Promise<void>;
@@ -104,11 +105,11 @@ export interface ParticipantManagerConfig {
104
105
  readonly ready: () => Promise<void>;
105
106
  readonly getTransport: () => SyncWebSocket | null;
106
107
  readonly presence: PresenceStream;
107
- readonly claims: ClaimStream;
108
+ readonly claims: AttachableClaimStream;
108
109
  readonly schema?: Schema<SchemaRecord>;
109
110
  }
110
111
  export declare function createParticipantManager(config: ParticipantManagerConfig): ParticipantManager;
111
112
  export declare function resolveParticipantSyncGroups(scope: ParticipantScope | undefined, schema?: Schema<SchemaRecord>): string[];
112
- export declare function syncGroupFromEntityRef(ref: EntityRef, schema?: Schema<SchemaRecord>): string;
113
+ export declare function syncGroupFromEntityRef(ref: ClaimTarget, schema?: Schema<SchemaRecord>): string;
113
114
  export declare function parseParticipantTtlSeconds(value: number | string | null | undefined): number | undefined;
114
115
  export declare function createParticipantClaimId(): string;
@@ -220,20 +220,20 @@ function createJoinedParticipant(args) {
220
220
  ownHandles.add(handle);
221
221
  return {
222
222
  object: 'claim',
223
- claimId: handle.claimId,
223
+ id: handle.id,
224
224
  reason: handle.reason,
225
225
  target: handle.target,
226
226
  async release() {
227
227
  ownHandles.delete(handle);
228
- await handle.release();
228
+ await handle.release?.();
229
229
  },
230
230
  revoke() {
231
231
  ownHandles.delete(handle);
232
- handle.revoke();
232
+ handle.revoke?.();
233
233
  },
234
234
  [Symbol.asyncDispose]: async () => {
235
235
  ownHandles.delete(handle);
236
- await handle[Symbol.asyncDispose]();
236
+ await handle[Symbol.asyncDispose]?.();
237
237
  },
238
238
  };
239
239
  };
@@ -262,7 +262,7 @@ function createJoinedParticipant(args) {
262
262
  return;
263
263
  left = true;
264
264
  for (const handle of Array.from(ownHandles)) {
265
- handle.revoke();
265
+ handle.revoke?.();
266
266
  ownHandles.delete(handle);
267
267
  }
268
268
  args.presence.idle();
@@ -125,6 +125,7 @@ interface TransactionQueueConfig {
125
125
  }
126
126
  export declare class TransactionQueue extends EventEmitter {
127
127
  private store;
128
+ private lastPermanentErrorSig?;
128
129
  private _mutationExecutor;
129
130
  private get mutationExecutor();
130
131
  private executionQueue;
@@ -199,6 +199,12 @@ class TransactionStore {
199
199
  }
200
200
  export class TransactionQueue extends EventEmitter {
201
201
  store = new TransactionStore();
202
+ // Signature of the last permanent-error we logged at `warn`. A `create`
203
+ // whose id already exists (`unique_violation`) is a permanent rejection
204
+ // that the offline queue re-drives on every reconnect/bootstrap — without
205
+ // this, the identical cause prints on a loop. We log the first occurrence
206
+ // and demote exact repeats to `debug`.
207
+ lastPermanentErrorSig;
202
208
  // Per-instance executor binding. Set by `setMutationExecutor(...)` from the
203
209
  // owning Ablo right after construction. Falls back to `getContext()` only
204
210
  // when unset (preserves legacy tests / SDK consumers that haven't migrated).
@@ -470,6 +476,9 @@ export class TransactionQueue extends EventEmitter {
470
476
  // promise always settles, regardless of which path produced the
471
477
  // terminal state.
472
478
  this.on('transaction:completed', (tx) => {
479
+ // Any successful write clears the permanent-error dedup, so a genuine
480
+ // recurrence after recovery warns again instead of staying demoted.
481
+ this.lastPermanentErrorSig = undefined;
473
482
  const r = this.confirmationResolvers.get(tx.id);
474
483
  if (r) {
475
484
  this.confirmationResolvers.delete(tx.id);
@@ -1254,7 +1263,12 @@ export class TransactionQueue extends EventEmitter {
1254
1263
  return undefined;
1255
1264
  };
1256
1265
  const diagnostics = readDiagnostics(error);
1257
- getContext().logger.warn('[TransactionQueue] Batch commit rejected', {
1266
+ // Mechanic-level breadcrumb. Every batch rejection — transient
1267
+ // (reconnect retries it) or permanent (`handleFailure` logs the
1268
+ // authoritative `warn` with the same typed cause) — passes
1269
+ // through here. Logging it at `warn` made one rejected write
1270
+ // surface three identical dumps; keep it at `debug`.
1271
+ getContext().logger.debug('[TransactionQueue] Batch commit rejected', {
1258
1272
  batchSize: batchOps.length,
1259
1273
  models: batchOps.map(({ op }) => `${op.type}:${op.model}`),
1260
1274
  errorType: abloErr?.type ?? error?.name,
@@ -1768,7 +1782,7 @@ export class TransactionQueue extends EventEmitter {
1768
1782
  // expiry (AbloAuthenticationError).
1769
1783
  try {
1770
1784
  const abloErr = error instanceof AbloError ? error : undefined;
1771
- getContext().logger.warn('[TransactionQueue] Permanent error - rolling back', {
1785
+ const details = {
1772
1786
  txId: transaction.id.slice(0, 8),
1773
1787
  type: transaction.type,
1774
1788
  model: transaction.modelName,
@@ -1779,7 +1793,30 @@ export class TransactionQueue extends EventEmitter {
1779
1793
  requestId: abloErr?.requestId,
1780
1794
  message: error?.message,
1781
1795
  inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
1782
- });
1796
+ };
1797
+ // A `create` whose id already exists is the benign idempotency case:
1798
+ // "this row is already there." It's the least alarming permanent
1799
+ // error, so it doesn't warrant a `warn` — `info` keeps it visible
1800
+ // without crying wolf. Everything else (FK violation, auth expiry,
1801
+ // server 500) stays at `warn`.
1802
+ const isBenignIdempotent = transaction.type === 'create' &&
1803
+ (abloErr?.code === 'unique_violation' ||
1804
+ abloErr?.type === 'AbloIdempotencyError');
1805
+ // Demote exact repeats (same write rejected for the same reason on
1806
+ // each reconnect replay) to `debug` so the loop logs once.
1807
+ const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
1808
+ const isRepeat = sig === this.lastPermanentErrorSig;
1809
+ this.lastPermanentErrorSig = sig;
1810
+ const logger = getContext().logger;
1811
+ if (isRepeat) {
1812
+ logger.debug('[TransactionQueue] Permanent error - rolling back (repeat)', details);
1813
+ }
1814
+ else if (isBenignIdempotent) {
1815
+ logger.info('[TransactionQueue] Write skipped — row already exists', details);
1816
+ }
1817
+ else {
1818
+ logger.warn('[TransactionQueue] Permanent error - rolling back', details);
1819
+ }
1783
1820
  }
1784
1821
  catch { }
1785
1822
  // Mark as failed immediately and rollback
@@ -100,7 +100,7 @@ export interface ContextChange {
100
100
  * `range`, `field`, and `meta` are generic coordination hints for
101
101
  * products like code editors, document editors, and design tools.
102
102
  */
103
- export interface EntityRef {
103
+ export interface ClaimTarget {
104
104
  readonly type: string;
105
105
  readonly id: string;
106
106
  readonly path?: string;
@@ -110,11 +110,11 @@ export interface EntityRef {
110
110
  }
111
111
  /**
112
112
  * A pointer to one entity the participant is acting on. Either a
113
- * typed `EntityRef` (`{ type, id, ... }`), or a tuple
113
+ * typed `ClaimTarget` (`{ type, id, ... }`), or a tuple
114
114
  * `['Clause', 'cl_3']` for ergonomic inline use. The verb methods
115
115
  * below accept both.
116
116
  */
117
- export type PresenceTarget = EntityRef | readonly [type: string, id: string];
117
+ export type PresenceTarget = ClaimTarget | readonly [type: string, id: string];
118
118
  /**
119
119
  * Reactive livestream of what every multiplayer participant is doing.
120
120
  * Every participant gets one; it's always on, always current.
@@ -336,27 +336,12 @@ export interface ClaimOptions extends ClaimLeaseOptions {
336
336
  readonly queue?: boolean;
337
337
  }
338
338
  export interface ClaimStream {
339
- /**
340
- * Claim an exclusive claim on a target. Returns a handle — call
341
- * `.revoke()` to cancel, let it expire via TTL, or use `await using`
342
- * (TC39 explicit resource management) to auto-revoke on scope exit.
343
- *
344
- * Server rejects via `claim_rejected` when another participant
345
- * already holds a claim on the same target. Default `reason` is
346
- * `'editing'`; pass `{reason: 'writing'}` (or any string) to override.
347
- *
348
- * The frame ships on the open WS immediately. One method, one shape —
349
- * the verb shortcuts (`editing`, `writing`, `announce`) and the
350
- * scoped `claim(reason, opts)` overload were collapsed into this
351
- * single primitive.
352
- */
353
- claim(target: PresenceTarget, opts?: ClaimOptions): ClaimHandle;
354
339
  /**
355
340
  * Reactive view of every other participant's active claims.
356
341
  * Reads return the current snapshot; pair with `subscribe(...)`
357
342
  * below to get notified on change.
358
343
  */
359
- readonly others: ReadonlyArray<ActiveClaim>;
344
+ readonly others: ReadonlyArray<Claim>;
360
345
  /**
361
346
  * Reactive view of the wait queue on one target — the FIFO line of
362
347
  * `status: 'queued'` claims behind the current holder, each with its
@@ -425,7 +410,7 @@ export interface ClaimStream {
425
410
  * }
426
411
  * ```
427
412
  */
428
- [Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<ActiveClaim>>;
413
+ [Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<Claim>>;
429
414
  }
430
415
  /**
431
416
  * You LOST an claim you were HOLDING — distinct from `ClaimRejection` (a
@@ -452,95 +437,6 @@ export interface ClaimLost {
452
437
  readonly meta?: Record<string, unknown>;
453
438
  };
454
439
  }
455
- export interface ClaimDeclaration {
456
- readonly target: EntityRef;
457
- /** Human-readable reason — "rewriting title" / "restyling chart". */
458
- readonly reason: string;
459
- /**
460
- * Seconds remaining until the server auto-expires this claim. An OUTPUT
461
- * field carrying a concrete countdown, so it's a plain `number` — distinct
462
- * from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
463
- * from `expiresAt - now`.
464
- */
465
- readonly ttlSeconds?: number;
466
- }
467
- /**
468
- * Handle returned from `announce(...)` / `analyzing(...)` / etc.
469
- *
470
- * Implements `Symbol.asyncDispose` so callers can write:
471
- *
472
- * ```ts
473
- * {
474
- * await using work = participant.claims.analyzing(clause, { ttl: '3m' });
475
- * // ... do the work; claim auto-revokes when the block exits
476
- * }
477
- * ```
478
- */
479
- /**
480
- * THE one claim handle. Returned by every claim door — the typed
481
- * `ablo.<model>.claim({ id })` (rich: `data`/`readAt`/`target` populated) and
482
- * the low-level `participant.claims.claim()` lease (minimal: `claimId` +
483
- * `revoke`/`release`). Row-level fields are optional precisely because the
484
- * low-level lease has no row snapshot; the model door fills them in.
485
- *
486
- * Implements `Symbol.asyncDispose` so callers can `await using claim = ...`
487
- * and have it auto-release on scope exit.
488
- */
489
- export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposable {
490
- readonly object: 'claim';
491
- readonly claimId: string;
492
- /**
493
- * True when the grant came AFTER waiting in the server's FIFO line
494
- * (`claim_granted`) — the authoritative "the row may have changed under us"
495
- * signal. Absent for an immediate grant or a non-queued lease.
496
- */
497
- readonly waited?: boolean;
498
- /**
499
- * Sync watermark of the held snapshot (`data` was read at this stamp). Writes
500
- * carrying the handle use it as the `readAt` stale guard. Present for
501
- * model-scoped claims; absent for low-level leases.
502
- */
503
- readonly readAt?: number;
504
- readonly target: {
505
- readonly model: string;
506
- readonly id: string;
507
- readonly field?: string;
508
- readonly path?: string;
509
- readonly range?: TargetRange;
510
- readonly meta?: Record<string, unknown>;
511
- };
512
- /**
513
- * The human-readable phase this claim represents — `'editing'`, `'writing'`,
514
- * `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
515
- * distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
516
- * to `'editing'`. Serialized on the wire as `action`.
517
- */
518
- readonly reason: string;
519
- readonly description?: string;
520
- /** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
521
- readonly data?: T;
522
- release(): Promise<void>;
523
- revoke(): void;
524
- }
525
- export interface ActiveClaim extends ClaimDeclaration {
526
- readonly id: string;
527
- readonly heldBy: string;
528
- /**
529
- * Whether the holding participant is a user (session), an agent, or a
530
- * system actor. First-class field so UIs can style "agent editing X"
531
- * differently from "user editing X" without string-parsing `heldBy`.
532
- * Canonical `'user' | 'agent' | 'system'` — the presence/claim stream
533
- * derives the value from the boolean `isAgent` wire flag (so it produces
534
- * only `'user'`/`'agent'`), but the type stays the full union it shares
535
- * with the HTTP claim surface and lease store.
536
- */
537
- readonly participantKind: ParticipantKind;
538
- readonly description?: string;
539
- /** Epoch-ms the claim was announced. */
540
- readonly announcedAt: number;
541
- /** Epoch-ms the server auto-expires it. */
542
- readonly expiresAt: number;
543
- }
544
440
  /**
545
441
  * Every lifecycle state of a coordination claim, in one enum.
546
442
  * `active` = the current holder (the lock). `queued` = waiting in the FIFO
@@ -554,44 +450,70 @@ export interface ClaimWaitOptions {
554
450
  readonly pollInterval?: number;
555
451
  readonly signal?: AbortSignal;
556
452
  }
557
- /**
558
- * The coordination state of one entity. Self-describing on the wire via
559
- * `object: 'claim'`. Existence with `status: 'active'` *is* the lock;
560
- * the fields *are* the awareness ("agent X is editing this until Y").
561
- *
562
- * Deliberately omits a Stripe-style `next_action`: a contender's only
563
- * response is "wait until free, then re-read", and the runtime performs
564
- * that uniformly — `claim` serializes behind the holder via the server
565
- * FIFO queue (or low-level `claims.waitFor` to wait without claiming), and the
566
- * stale-context guard forces the re-read. Encoding a constant instruction
567
- * the engine always takes would be the kind of ceremony this object exists
568
- * to remove.
569
- */
570
- export interface Claim {
453
+ export interface Claim<T = Record<string, unknown>> {
571
454
  readonly object: 'claim';
455
+ /** This claim's id. */
572
456
  readonly id: string;
573
- readonly status: ClaimStatus;
457
+ /**
458
+ * Lifecycle state. `active` = the holder (the lock); `queued` = waiting in
459
+ * the FIFO line (carries `position`). Optional on shapes derived from a
460
+ * presence frame that don't carry it — a present entry is `active` by
461
+ * construction.
462
+ */
463
+ readonly status?: ClaimStatus;
574
464
  /** What is being coordinated. */
575
- readonly target: EntityRef;
576
- /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
577
- * field on every claim surface; distinct from the CRUD operation. Serialized
578
- * on the wire as `action`. */
465
+ readonly target: ClaimTarget;
466
+ /**
467
+ * Human-readable phase `'editing'`, `'writing'`, `'reviewing'`. The same
468
+ * field on every claim surface; distinct from the CRUD operation. Defaults to
469
+ * `'editing'`.
470
+ */
579
471
  readonly reason: string;
580
472
  /** Peer-visible explanation of the work being performed. */
581
473
  readonly description?: string;
582
- /** Participant holding it. */
583
- readonly heldBy: string;
584
- readonly participantKind: ParticipantKind;
474
+ /** Participant holding it. Absent on a handle you hold for yourself. */
475
+ readonly heldBy?: string;
585
476
  /**
586
- * Epoch-ms the holder opened it. Optional until the lease wire carries
587
- * it derived shapes (e.g. mapped from a presence frame) may omit it.
477
+ * Whether the holder is a user (session), agent, or system actor — so UIs
478
+ * style "agent editing X" vs "user editing X" without string-parsing
479
+ * `heldBy`. Present on observed claims; absent on your own fresh handle.
588
480
  */
481
+ readonly participantKind?: ParticipantKind;
482
+ /** Epoch-ms the holder opened it. */
589
483
  readonly createdAt?: number;
590
484
  /** Epoch-ms the server auto-expires it if the holder doesn't finish. */
591
- readonly expiresAt: number;
485
+ readonly expiresAt?: number;
486
+ /**
487
+ * Seconds remaining until auto-expiry — an OUTPUT countdown (plain `number`),
488
+ * distinct from the input `ttl: Duration` (`'3m'`) you pass when claiming.
489
+ */
490
+ readonly ttlSeconds?: number;
592
491
  /**
593
492
  * 0-based place in the FIFO line — present only when `status: 'queued'`
594
- * (`0` = next in line behind the holder). Absent for the active holder.
493
+ * (`0` = next behind the holder). Absent for the active holder.
595
494
  */
596
495
  readonly position?: number;
496
+ /**
497
+ * True when the grant came AFTER waiting in the server's FIFO line
498
+ * (`claim_granted`) — the authoritative "the row may have changed under us"
499
+ * signal. Absent for an immediate (uncontended) grant.
500
+ */
501
+ readonly waited?: boolean;
502
+ /**
503
+ * Sync watermark of the held snapshot (`data` was read at this stamp). Writes
504
+ * carrying the claim use it as the `readAt` stale guard. Present on a claim
505
+ * you hold.
506
+ */
507
+ readonly readAt?: number;
508
+ /** Row snapshot under the lease — present on a claim you hold. */
509
+ readonly data?: T;
510
+ /**
511
+ * Release the lease. Present only on a claim you HOLD (returned by
512
+ * `ablo.<model>.claim`); absent on observed peer claims.
513
+ */
514
+ release?(): Promise<void>;
515
+ /** Synchronously abandon the lease. Present only on a claim you hold. */
516
+ revoke?(): void;
517
+ /** Auto-release on `await using` scope exit. Present only on a held claim. */
518
+ [Symbol.asyncDispose]?(): PromiseLike<void>;
597
519
  }
@@ -8,6 +8,19 @@ export interface ErrorEnvelope {
8
8
  readonly message: string;
9
9
  readonly doc_url?: string;
10
10
  readonly request_id?: string;
11
+ /** Aggregate field-level failures — so one 4xx can report EVERY invalid input
12
+ * at once (schema push, batch commit, CLI-arg validation) instead of failing
13
+ * on the first. `param` stays the single-field convenience case. (RFC 9457
14
+ * `errors[]` / JSON:API `errors[]` / Google `BadRequest.fieldViolations[]`.) */
15
+ readonly errors?: ReadonlyArray<{
16
+ readonly code?: string;
17
+ readonly message: string;
18
+ readonly param?: string;
19
+ }>;
20
+ /** Typed-details slot: `AbloError.toJSON()` spreads its `details` (e.g.
21
+ * `missingIds`, `conflicts`, `retryAfterSeconds`) as top-level members.
22
+ * Consumers MUST ignore members they don't recognize (forward-compat). */
23
+ readonly [key: string]: unknown;
11
24
  }
12
25
  /** {@link AbloError} subclass → default HTTP status. The subclass is chosen to
13
26
  * match status semantics (a validation error is a 400, a permission error a
@@ -25,6 +25,22 @@ one answer to "how do two agents not clobber each other" — then covers the
25
25
  (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
26
26
  claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
27
27
 
28
+ > **Before anything else: one identity per agent.** Coordination excludes
29
+ > *participants*, and a participant **is the key**, not the client object. The
30
+ > server derives identity from the credential's scope — so **N clients sharing
31
+ > one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
32
+ > queue behind each other, and a "second" claimer silently re-takes the lease it
33
+ > already holds (no mutual exclusion). To get real per-agent exclusion, mint a
34
+ > **distinct scoped `rk_` per agent** and bind a client to it:
35
+ >
36
+ > ```ts
37
+ > const { token } = await ablo.sessions.create({ agent: { id: `agent-${i}` } }); // rk_
38
+ > const agent = Ablo({ schema, apiKey: token }); // this agent's own participant
39
+ > ```
40
+ >
41
+ > Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
42
+ > turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
43
+
28
44
  ---
29
45
 
30
46
  ## The model — three layers, one decision