@abloatai/ablo 0.29.3 → 0.30.1

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 (118) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -2
  3. package/dist/BaseSyncedStore.js +3 -1
  4. package/dist/Database.js +29 -2
  5. package/dist/SyncClient.d.ts +5 -3
  6. package/dist/SyncClient.js +8 -4
  7. package/dist/ai-sdk/coordinatedTool.d.ts +0 -1
  8. package/dist/ai-sdk/coordinatedTool.js +0 -1
  9. package/dist/ai-sdk/coordinationContext.js +1 -5
  10. package/dist/cli.cjs +377 -84
  11. package/dist/client/Ablo.d.ts +1 -1
  12. package/dist/client/Ablo.js +24 -36
  13. package/dist/client/auth.d.ts +8 -18
  14. package/dist/client/auth.js +16 -96
  15. package/dist/client/createModelProxy.d.ts +16 -11
  16. package/dist/client/createModelProxy.js +162 -20
  17. package/dist/client/httpClient.d.ts +1 -1
  18. package/dist/client/httpTransport.js +263 -89
  19. package/dist/client/options.d.ts +0 -15
  20. package/dist/client/resourceTypes.d.ts +13 -26
  21. package/dist/client/schemaConfig.js +5 -0
  22. package/dist/client/writeOptionsSchema.d.ts +1 -0
  23. package/dist/client/writeOptionsSchema.js +11 -2
  24. package/dist/client/wsMutationExecutor.d.ts +3 -5
  25. package/dist/coordination/schema.d.ts +17 -13
  26. package/dist/coordination/schema.js +36 -10
  27. package/dist/errorCodes.d.ts +9 -4
  28. package/dist/errorCodes.js +10 -5
  29. package/dist/errors.js +4 -9
  30. package/dist/index.d.ts +4 -0
  31. package/dist/interfaces/index.d.ts +31 -19
  32. package/dist/schema/index.d.ts +1 -1
  33. package/dist/schema/index.js +4 -1
  34. package/dist/schema/schema.d.ts +13 -0
  35. package/dist/schema/select.js +7 -0
  36. package/dist/schema/syncDeltaRow.d.ts +31 -13
  37. package/dist/schema/syncDeltaRow.js +33 -15
  38. package/dist/schema/tenancy.d.ts +52 -16
  39. package/dist/schema/tenancy.js +86 -25
  40. package/dist/server/commit.d.ts +8 -24
  41. package/dist/server/index.d.ts +1 -0
  42. package/dist/server/index.js +1 -0
  43. package/dist/server/readConfig.d.ts +10 -0
  44. package/dist/source/adapter.d.ts +26 -15
  45. package/dist/source/adapter.js +5 -3
  46. package/dist/source/adapters/drizzle.js +35 -9
  47. package/dist/source/adapters/kysely.d.ts +33 -61
  48. package/dist/source/adapters/kysely.js +170 -177
  49. package/dist/source/adapters/kyselyMutationCore.d.ts +76 -0
  50. package/dist/source/adapters/kyselyMutationCore.js +125 -0
  51. package/dist/source/adapters/memory.d.ts +1 -1
  52. package/dist/source/adapters/memory.js +24 -10
  53. package/dist/source/adapters/prisma.js +35 -8
  54. package/dist/source/conformance.d.ts +26 -28
  55. package/dist/source/conformance.js +142 -69
  56. package/dist/source/contract.d.ts +46 -4
  57. package/dist/source/contract.js +66 -5
  58. package/dist/source/factory.d.ts +3 -3
  59. package/dist/source/factory.js +24 -3
  60. package/dist/source/idempotency.d.ts +46 -0
  61. package/dist/source/idempotency.js +129 -0
  62. package/dist/source/index.d.ts +6 -4
  63. package/dist/source/index.js +5 -3
  64. package/dist/source/migrations.d.ts +9 -5
  65. package/dist/source/migrations.js +39 -6
  66. package/dist/source/types.d.ts +88 -14
  67. package/dist/source/types.js +16 -0
  68. package/dist/surface.d.ts +1 -1
  69. package/dist/surface.js +0 -1
  70. package/dist/sync/BootstrapFetcher.js +7 -0
  71. package/dist/sync/SyncWebSocket.d.ts +5 -27
  72. package/dist/sync/awaitClaimGrant.d.ts +6 -0
  73. package/dist/sync/awaitClaimGrant.js +12 -2
  74. package/dist/sync/commitFrames.d.ts +2 -4
  75. package/dist/sync/commitFrames.js +1 -0
  76. package/dist/sync/createClaimStream.d.ts +1 -1
  77. package/dist/sync/createClaimStream.js +13 -15
  78. package/dist/sync/deltaPipeline.d.ts +1 -1
  79. package/dist/sync/deltaPipeline.js +1 -1
  80. package/dist/sync/participants.d.ts +2 -3
  81. package/dist/sync/participants.js +2 -2
  82. package/dist/sync/wsFrameHandlers.js +35 -15
  83. package/dist/testing/mocks/MockMutationExecutor.d.ts +21 -0
  84. package/dist/testing/mocks/MockMutationExecutor.js +53 -1
  85. package/dist/transactions/TransactionQueue.d.ts +61 -9
  86. package/dist/transactions/TransactionQueue.js +502 -114
  87. package/dist/transactions/commitEnvelope.d.ts +4 -0
  88. package/dist/transactions/commitEnvelope.js +25 -0
  89. package/dist/transactions/commitPayload.d.ts +23 -5
  90. package/dist/transactions/commitPayload.js +3 -0
  91. package/dist/transactions/deltaConfirmation.d.ts +2 -1
  92. package/dist/transactions/deltaConfirmation.js +22 -4
  93. package/dist/transactions/durableWriteStore.d.ts +8 -1
  94. package/dist/transactions/httpCommitEnvelope.d.ts +3 -1
  95. package/dist/transactions/httpCommitEnvelope.js +30 -0
  96. package/dist/transactions/replayValidation.d.ts +4 -0
  97. package/dist/transactions/replayValidation.js +6 -0
  98. package/dist/types/streams.d.ts +29 -17
  99. package/dist/wire/commit.d.ts +671 -0
  100. package/dist/wire/commit.js +300 -0
  101. package/dist/wire/delta.d.ts +4 -0
  102. package/dist/wire/delta.js +9 -0
  103. package/dist/wire/frames.d.ts +17 -45
  104. package/dist/wire/index.d.ts +2 -0
  105. package/dist/wire/index.js +4 -0
  106. package/docs/agent-messaging.md +4 -5
  107. package/docs/agents.md +1 -2
  108. package/docs/api-keys.md +1 -2
  109. package/docs/client-behavior.md +4 -6
  110. package/docs/coordination.md +64 -9
  111. package/docs/data-sources.md +4 -7
  112. package/docs/integration-guide.md +6 -8
  113. package/docs/migration.md +0 -3
  114. package/docs/quickstart.md +7 -8
  115. package/docs/schema-contract.md +7 -8
  116. package/package.json +4 -4
  117. package/dist/client/registerDataSource.d.ts +0 -19
  118. package/dist/client/registerDataSource.js +0 -66
@@ -4,7 +4,8 @@
4
4
  * {@link HttpClaimApi} derivation. This module holds only types and has no runtime
5
5
  * imports.
6
6
  */
7
- import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
7
+ import type { ReadDependency } from '../coordination/schema.js';
8
+ import type { ClientCommitReceipt, CommitStatus } from '../wire/commit.js';
8
9
  import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
9
10
  export type { ModelTarget, ModelClaim };
10
11
  import type { SchemaRecord } from '../schema/schema.js';
@@ -19,9 +20,9 @@ import type { ClaimOptions, ClaimParams, ClaimReadApi, AwaitedClaimMethod } from
19
20
  * `create({ data })` / `update({ id, data })` / `delete({ id })` — writes
20
21
  * `claim({ id })` — a durable claim handle for coordinated writes
21
22
  */
22
- export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, ModelOperations, } from './createModelProxy.js';
23
+ export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ModelOperations, } from './createModelProxy.js';
23
24
  export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
24
- export type CommitWait = 'queued' | 'confirmed';
25
+ export type CommitWait = CommitStatus;
25
26
  export type IfClaimedPolicy = 'return' | 'fail';
26
27
  export interface ClaimedOptions {
27
28
  /**
@@ -37,9 +38,9 @@ export interface ModelReadOptions extends ClaimedOptions {
37
38
  }
38
39
  export interface ClaimCreateOptions {
39
40
  readonly target: ModelTarget;
40
- /** Human-readable phase shown to peers`'editing'`, `'writing'`. The same
41
- * word on every claim surface. */
42
- readonly reason: string;
41
+ /** Peer-visible description of the workthe same field on every claim
42
+ * surface. Defaults to `'editing'` when omitted. */
43
+ readonly description?: string;
43
44
  readonly ttl?: Duration;
44
45
  /**
45
46
  * Join the server's fair FIFO queue when the target is already claimed,
@@ -67,6 +68,8 @@ export interface CommitOperationInput {
67
68
  readonly transactionId?: string | null;
68
69
  readonly readAt?: number | null;
69
70
  readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
71
+ /** Fencing token (Option B) from the batch's claim handle; server-validated. */
72
+ readonly fenceToken?: number | null;
70
73
  }
71
74
  export interface CommitCreateOptions {
72
75
  readonly idempotencyKey?: string | null;
@@ -94,26 +97,8 @@ export interface CommitCreateOptions {
94
97
  */
95
98
  readonly reads?: readonly ReadDependency[] | null;
96
99
  }
97
- export interface CommitReceipt {
98
- readonly id: string;
99
- readonly status: CommitWait;
100
- readonly lastSyncId?: number;
101
- /**
102
- * Stale-context notifications: present only when this commit guarded a write with
103
- * `onStale: 'notify'` and the premise moved concurrently. Each carries the
104
- * conflicting field's current value, handed back as data rather than raising an
105
- * `AbloStaleContextError`, so the caller — an agent or a human — decides how to
106
- * resolve it.
107
- */
108
- readonly notifications?: readonly StaleNotification[];
109
- /**
110
- * Ids of update or delete targets in this commit that matched no rows, because
111
- * the row does not exist or is outside the caller's organization. Present and
112
- * non-empty only when a write missed. The typed resource wrappers turn this into
113
- * an `AbloNotFoundError`; a raw `commits.create` caller can inspect it directly.
114
- */
115
- readonly missingIds?: readonly string[];
116
- }
100
+ /** Public projection inferred from the canonical runtime schema. */
101
+ export type CommitReceipt = ClientCommitReceipt;
117
102
  export interface CommitResource {
118
103
  create(options: CommitCreateOptions): Promise<CommitReceipt>;
119
104
  }
@@ -131,6 +116,8 @@ export interface ModelMutationOptions extends ClaimedOptions {
131
116
  readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
132
117
  readonly wait?: CommitWait;
133
118
  readonly claim?: Claim | ClaimOptions | null;
119
+ /** Fencing token (Option B) from the claim; server-validated at commit. */
120
+ readonly fenceToken?: number | null;
134
121
  }
135
122
  /**
136
123
  * The stateless HTTP claim surface. Most code puts a `claim` directly on the write
@@ -172,5 +172,10 @@ export function deriveConfigFromSchema(schema) {
172
172
  // schema the server currently has active. The server and the `ablo push`
173
173
  // command compute this same hash.
174
174
  expectedSchemaHash: schemaHash(schema),
175
+ // For a projection (`selectModels`/`omitModels`), also carry the full source
176
+ // schema's hash. The drift check accepts a server match on either hash, so a
177
+ // subset client stays quiet against a server running its full source schema.
178
+ // Undefined for a directly-authored schema — plain equality applies there.
179
+ expectedSourceSchemaHash: schema.sourceSchemaHash,
175
180
  };
176
181
  }
@@ -34,6 +34,7 @@ export declare const writeOptionsSchema: z.ZodObject<{
34
34
  overwrite: "overwrite";
35
35
  notify: "notify";
36
36
  }>>>;
37
+ fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
37
38
  claim: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
38
39
  id: z.ZodString;
39
40
  }, z.core.$loose>]>>>;
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { z } from 'zod';
19
19
  import { AbloValidationError } from '../errors.js';
20
+ import { commitStatusSchema } from '../wire/commit.js';
20
21
  export const onStaleModeSchema = z.enum(['reject', 'overwrite', 'notify']);
21
22
  export const writeOptionsSchema = z.object({
22
23
  /** Idempotency key the server records in `mutation_log` to make retries
@@ -25,11 +26,16 @@ export const writeOptionsSchema = z.object({
25
26
  /** Human-readable audit tag, persisted to `mutation_log.label`. */
26
27
  label: z.string().max(255).optional(),
27
28
  /** Resolve when queued locally (default) or once the server confirms. */
28
- wait: z.enum(['queued', 'confirmed']).optional(),
29
+ wait: commitStatusSchema.optional(),
29
30
  /** Stale guard: the sync watermark the caller's reasoning was based on. */
30
31
  readAt: z.number().int().nonnegative().nullish(),
31
32
  /** What the server does when the target moved past `readAt`. */
32
33
  onStale: onStaleModeSchema.nullish(),
34
+ /** The held claim's fencing token (Option B), sourced from the claim handle
35
+ * rather than set by hand; the server validates it against the entity's
36
+ * high-water. Kept in the schema so it stays aligned with `MutationOptions`
37
+ * (the drift guard below) and a malformed token is rejected at the boundary. */
38
+ fenceToken: z.number().nullish(),
33
39
  /** The claim this write belongs to — either a claim id, or a live claim
34
40
  * handle whose `release`/`revoke` functions are preserved untouched. */
35
41
  claim: z.union([z.string(), z.looseObject({ id: z.string() })]).nullish(),
@@ -56,5 +62,8 @@ export function assertWriteOptions(value, context) {
56
62
  ...(path ? { param: path } : {}),
57
63
  });
58
64
  }
59
- const _writeOptionsContractInSync = [true, true];
65
+ const _writeOptionsContractInSync = [
66
+ true,
67
+ true,
68
+ ];
60
69
  void _writeOptionsContractInSync;
@@ -4,8 +4,9 @@
4
4
  * commit time because it does not exist yet when the executor is created. A
5
5
  * client wires this up automatically unless you supply your own executor.
6
6
  */
7
- import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
7
+ import type { ReadDependency } from '../coordination/schema.js';
8
8
  import type { MutationExecutor, MutationOperation } from '../interfaces/index.js';
9
+ import type { CommitAck } from '../sync/commitFrames.js';
9
10
  /**
10
11
  * Creates the default mutation executor, which sends each change as a `commit`
11
12
  * frame over the sync engine's own WebSocket. The engine owns its socket, so
@@ -22,8 +23,5 @@ import type { MutationExecutor, MutationOperation } from '../interfaces/index.js
22
23
  * generation below exists only for direct, one-shot executor consumers.
23
24
  */
24
25
  export declare function createDefaultMutationExecutor(getWs: () => {
25
- sendCommit?: (operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null) => Promise<{
26
- lastSyncId: number;
27
- notifications?: StaleNotification[];
28
- }>;
26
+ sendCommit?: (operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null) => Promise<CommitAck>;
29
27
  } | null): MutationExecutor;
@@ -196,11 +196,11 @@ export declare const claimStatusSchema: z.ZodEnum<{
196
196
  export type ClaimStatus = z.infer<typeof claimStatusSchema>;
197
197
  export declare const wireClaimSummarySchema: z.ZodObject<{
198
198
  field: z.ZodOptional<z.ZodString>;
199
+ description: z.ZodOptional<z.ZodString>;
199
200
  entityType: z.ZodString;
200
201
  entityId: z.ZodString;
201
202
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
202
203
  claimId: z.ZodString;
203
- reason: z.ZodString;
204
204
  declaredAt: z.ZodNumber;
205
205
  expiresAt: z.ZodNumber;
206
206
  }, z.core.$strip>;
@@ -214,11 +214,11 @@ export declare const claimErrorSchema: z.ZodObject<{
214
214
  heldByExpiresAt: z.ZodOptional<z.ZodNumber>;
215
215
  heldByClaim: z.ZodOptional<z.ZodObject<{
216
216
  field: z.ZodOptional<z.ZodString>;
217
+ description: z.ZodOptional<z.ZodString>;
217
218
  entityType: z.ZodString;
218
219
  entityId: z.ZodString;
219
220
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
220
221
  claimId: z.ZodString;
221
- reason: z.ZodString;
222
222
  declaredAt: z.ZodNumber;
223
223
  expiresAt: z.ZodNumber;
224
224
  }, z.core.$strip>>;
@@ -228,7 +228,7 @@ export type ClaimError = z.infer<typeof claimErrorSchema>;
228
228
  /**
229
229
  * A declared, pending-mutation claim — the unit broadcast inside a presence
230
230
  * frame's `activeClaims`. The client supplies the descriptive `targetRef`
231
- * fields, an explanatory `reason`, and a chosen `claimId`; the server stamps
231
+ * fields, a `description` of the work, and a chosen `claimId`; the server stamps
232
232
  * `declaredAt` and `expiresAt` and may set `status` and `error`. Those last
233
233
  * two are optional, so one shape serves both the server, which sets them, and
234
234
  * the leaner SDK view, which reads a claim without them.
@@ -246,7 +246,7 @@ export declare const wireClaimSchema: z.ZodObject<{
246
246
  field: z.ZodOptional<z.ZodString>;
247
247
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
248
248
  claimId: z.ZodString;
249
- reason: z.ZodString;
249
+ description: z.ZodOptional<z.ZodString>;
250
250
  declaredAt: z.ZodNumber;
251
251
  expiresAt: z.ZodNumber;
252
252
  status: z.ZodOptional<z.ZodEnum<{
@@ -255,6 +255,7 @@ export declare const wireClaimSchema: z.ZodObject<{
255
255
  expired: "expired";
256
256
  canceled: "canceled";
257
257
  }>>;
258
+ fenceToken: z.ZodOptional<z.ZodNumber>;
258
259
  error: z.ZodOptional<z.ZodObject<{
259
260
  code: z.ZodString;
260
261
  message: z.ZodOptional<z.ZodString>;
@@ -263,11 +264,11 @@ export declare const wireClaimSchema: z.ZodObject<{
263
264
  heldByExpiresAt: z.ZodOptional<z.ZodNumber>;
264
265
  heldByClaim: z.ZodOptional<z.ZodObject<{
265
266
  field: z.ZodOptional<z.ZodString>;
267
+ description: z.ZodOptional<z.ZodString>;
266
268
  entityType: z.ZodString;
267
269
  entityId: z.ZodString;
268
270
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
269
271
  claimId: z.ZodString;
270
- reason: z.ZodString;
271
272
  declaredAt: z.ZodNumber;
272
273
  expiresAt: z.ZodNumber;
273
274
  }, z.core.$strip>>;
@@ -296,11 +297,11 @@ export declare const claimRejectionSchema: z.ZodObject<{
296
297
  heldByExpiresAt: z.ZodOptional<z.ZodNumber>;
297
298
  heldByClaim: z.ZodOptional<z.ZodObject<{
298
299
  field: z.ZodOptional<z.ZodString>;
300
+ description: z.ZodOptional<z.ZodString>;
299
301
  entityType: z.ZodString;
300
302
  entityId: z.ZodString;
301
303
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
302
304
  claimId: z.ZodString;
303
- reason: z.ZodString;
304
305
  declaredAt: z.ZodNumber;
305
306
  expiresAt: z.ZodNumber;
306
307
  }, z.core.$strip>>;
@@ -373,7 +374,6 @@ export declare const modelClaimSchema: z.ZodReadonly<z.ZodObject<{
373
374
  agent: "agent";
374
375
  system: "system";
375
376
  }>>;
376
- reason: z.ZodString;
377
377
  description: z.ZodOptional<z.ZodString>;
378
378
  field: z.ZodOptional<z.ZodString>;
379
379
  status: z.ZodOptional<z.ZodEnum<{
@@ -382,6 +382,7 @@ export declare const modelClaimSchema: z.ZodReadonly<z.ZodObject<{
382
382
  }>>;
383
383
  position: z.ZodOptional<z.ZodNumber>;
384
384
  expiresAt: z.ZodNumber;
385
+ fenceToken: z.ZodOptional<z.ZodNumber>;
385
386
  target: z.ZodReadonly<z.ZodObject<{
386
387
  model: z.ZodString;
387
388
  id: z.ZodString;
@@ -399,9 +400,10 @@ export declare const modelClaimSchema: z.ZodReadonly<z.ZodObject<{
399
400
  export type ModelClaim = z.infer<typeof modelClaimSchema>;
400
401
  /**
401
402
  * The `claim_begin` payload a client sends. It carries the descriptive target
402
- * and reason, an optional duration hint, and the opt-in fair-queue flag. The
403
- * server stamps the lifecycle and timestamp fields, so they are not part of
404
- * this inbound shape — this is exactly what the server validates on ingest.
403
+ * and a `description` of the work, an optional duration hint, and the opt-in
404
+ * fair-queue flag. The server stamps the lifecycle and timestamp fields, so they
405
+ * are not part of this inbound shape — this is exactly what the server validates
406
+ * on ingest.
405
407
  */
406
408
  export declare const claimBeginPayloadSchema: z.ZodObject<{
407
409
  entityType: z.ZodString;
@@ -416,7 +418,7 @@ export declare const claimBeginPayloadSchema: z.ZodObject<{
416
418
  field: z.ZodOptional<z.ZodString>;
417
419
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
418
420
  claimId: z.ZodString;
419
- reason: z.ZodString;
421
+ description: z.ZodOptional<z.ZodString>;
420
422
  estimatedMs: z.ZodOptional<z.ZodNumber>;
421
423
  queue: z.ZodOptional<z.ZodBoolean>;
422
424
  }, z.core.$strip>;
@@ -579,6 +581,7 @@ export declare const commitOperationSchema: z.ZodObject<{
579
581
  id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
580
582
  input: z.ZodOptional<z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
581
583
  transactionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
584
+ fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
582
585
  }, z.core.$strip>;
583
586
  export type CommitOperation = z.infer<typeof commitOperationSchema>;
584
587
  /**
@@ -663,7 +666,7 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
663
666
  field: z.ZodOptional<z.ZodString>;
664
667
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
665
668
  claimId: z.ZodString;
666
- reason: z.ZodString;
669
+ description: z.ZodOptional<z.ZodString>;
667
670
  declaredAt: z.ZodNumber;
668
671
  expiresAt: z.ZodNumber;
669
672
  status: z.ZodOptional<z.ZodEnum<{
@@ -672,6 +675,7 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
672
675
  expired: "expired";
673
676
  canceled: "canceled";
674
677
  }>>;
678
+ fenceToken: z.ZodOptional<z.ZodNumber>;
675
679
  error: z.ZodOptional<z.ZodObject<{
676
680
  code: z.ZodString;
677
681
  message: z.ZodOptional<z.ZodString>;
@@ -680,11 +684,11 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
680
684
  heldByExpiresAt: z.ZodOptional<z.ZodNumber>;
681
685
  heldByClaim: z.ZodOptional<z.ZodObject<{
682
686
  field: z.ZodOptional<z.ZodString>;
687
+ description: z.ZodOptional<z.ZodString>;
683
688
  entityType: z.ZodString;
684
689
  entityId: z.ZodString;
685
690
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
686
691
  claimId: z.ZodString;
687
- reason: z.ZodString;
688
692
  declaredAt: z.ZodNumber;
689
693
  expiresAt: z.ZodNumber;
690
694
  }, z.core.$strip>>;
@@ -200,17 +200,30 @@ export const claimStatusSchema = z.enum([
200
200
  ]);
201
201
  const wireClaimBaseSchema = targetRefSchema.extend({
202
202
  claimId: z.string(),
203
- /** Human-readable phase: 'editing' | 'reviewing' | 'forecasting' … */
204
- reason: z.string(),
203
+ /**
204
+ * Peer-visible description of the work being done (`'rewriting the risk
205
+ * section to match Q3'`). The server stamps a default when a frame carries
206
+ * none.
207
+ */
208
+ description: z.string().optional(),
205
209
  /** Server-stamped declaration time (epoch ms). */
206
210
  declaredAt: z.number(),
207
211
  /** Server-computed TTL deadline (epoch ms). Readers treat as advisory. */
208
212
  expiresAt: z.number(),
209
213
  status: claimStatusSchema.optional(),
214
+ /**
215
+ * The monotonic fencing token the server minted for this grant (Option B).
216
+ * Strictly increasing per entity across successive grants, so a write that
217
+ * carries it can be rejected at commit if a later holder already advanced the
218
+ * entity's high-water. Server-stamped on grant and never client-supplied;
219
+ * optional so every existing frame stays valid and a token-less write is
220
+ * simply not fence-checked.
221
+ */
222
+ fenceToken: z.number().optional(),
210
223
  });
211
224
  export const wireClaimSummarySchema = wireClaimBaseSchema.pick({
212
225
  claimId: true,
213
- reason: true,
226
+ description: true,
214
227
  declaredAt: true,
215
228
  expiresAt: true,
216
229
  entityType: true,
@@ -234,7 +247,7 @@ export const claimErrorSchema = z.object({
234
247
  /**
235
248
  * A declared, pending-mutation claim — the unit broadcast inside a presence
236
249
  * frame's `activeClaims`. The client supplies the descriptive `targetRef`
237
- * fields, an explanatory `reason`, and a chosen `claimId`; the server stamps
250
+ * fields, a `description` of the work, and a chosen `claimId`; the server stamps
238
251
  * `declaredAt` and `expiresAt` and may set `status` and `error`. Those last
239
252
  * two are optional, so one shape serves both the server, which sets them, and
240
253
  * the leaner SDK view, which reads a claim without them.
@@ -295,25 +308,30 @@ export const modelClaimSchema = z
295
308
  id: z.string(),
296
309
  actor: z.string(),
297
310
  participantKind: wireParticipantKindSchema,
298
- /** Human-readable phase (`'editing'`). */
299
- reason: z.string(),
311
+ /** Peer-visible description of the work (`'rewriting the risk section'`).
312
+ * Optional: a claim may be declared without one. */
300
313
  description: z.string().optional(),
301
314
  field: z.string().optional(),
302
315
  status: z.enum(['active', 'queued']).optional(),
303
316
  position: z.number().optional(),
304
317
  expiresAt: z.number(),
318
+ /** The grant's fencing token (Option B), present on a claim you hold. */
319
+ fenceToken: z.number().optional(),
305
320
  target: modelTargetSchema,
306
321
  })
307
322
  .readonly();
308
323
  /**
309
324
  * The `claim_begin` payload a client sends. It carries the descriptive target
310
- * and reason, an optional duration hint, and the opt-in fair-queue flag. The
311
- * server stamps the lifecycle and timestamp fields, so they are not part of
312
- * this inbound shape — this is exactly what the server validates on ingest.
325
+ * and a `description` of the work, an optional duration hint, and the opt-in
326
+ * fair-queue flag. The server stamps the lifecycle and timestamp fields, so they
327
+ * are not part of this inbound shape — this is exactly what the server validates
328
+ * on ingest.
313
329
  */
314
330
  export const claimBeginPayloadSchema = targetRefSchema.extend({
315
331
  claimId: z.string(),
316
- reason: z.string(),
332
+ /** Peer-visible description of the work. The server stamps `'editing'` when a
333
+ * frame carries none. */
334
+ description: z.string().optional(),
317
335
  /** Hint for `expiresAt`; the server caps it. */
318
336
  estimatedMs: z.number().optional(),
319
337
  /**
@@ -481,6 +499,14 @@ export const commitOperationSchema = writeGuardSchema.extend({
481
499
  input: z.record(z.string(), z.unknown()).nullish(),
482
500
  /** Per-op client tx id, echoed on the broadcast delta. */
483
501
  transactionId: z.string().nullish(),
502
+ /**
503
+ * The fencing token from the held claim this write belongs to (Option B).
504
+ * Present only on a write issued under a claim that was granted one; the
505
+ * server checks it against the entity's persisted high-water and rejects a
506
+ * stale token. Absent (nullish) on every unclaimed write — those are governed
507
+ * by version-CAS and the Option A blind-write guard, unchanged.
508
+ */
509
+ fenceToken: z.number().nullish(),
484
510
  });
485
511
  // ─────────────────────────────────────────────────────────────────────────
486
512
  // Layer 1 — presence (observation only; it never enforces)
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * error documentation and returned on the `Ablo-Version` response header, so a
38
38
  * consumer can detect when its expected contract has drifted from the server's.
39
39
  */
40
- export declare const ERROR_CONTRACT_VERSION = "2026-07-03";
40
+ export declare const ERROR_CONTRACT_VERSION = "2026-07-15";
41
41
  /** A coarse grouping of error codes, used to organize metrics and documentation. */
42
42
  export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
43
43
  /**
@@ -138,10 +138,7 @@ export declare const ERROR_CODES: {
138
138
  readonly jwt_org_membership_denied: ErrorCodeSpec;
139
139
  readonly file_upload_auth_required: ErrorCodeSpec;
140
140
  readonly browser_apikey_blocked: ErrorCodeSpec;
141
- readonly browser_database_url_blocked: ErrorCodeSpec;
142
- readonly datasource_registration_failed: ErrorCodeSpec;
143
141
  readonly datasource_connection_unsupported: ErrorCodeSpec;
144
- readonly datasource_direct_deprecated: ErrorCodeSpec;
145
142
  readonly capability_scope_denied: ErrorCodeSpec;
146
143
  readonly issuer_register_forbidden: ErrorCodeSpec;
147
144
  readonly capability_invalid: ErrorCodeSpec;
@@ -157,6 +154,7 @@ export declare const ERROR_CODES: {
157
154
  readonly byo_host_not_allowed: ErrorCodeSpec;
158
155
  readonly claim_conflict: ErrorCodeSpec;
159
156
  readonly claim_lost: ErrorCodeSpec;
157
+ readonly fence_token_stale: ErrorCodeSpec;
160
158
  readonly entity_claimed: ErrorCodeSpec;
161
159
  readonly malformed_claim: ErrorCodeSpec;
162
160
  readonly malformed_subscription: ErrorCodeSpec;
@@ -168,6 +166,8 @@ export declare const ERROR_CODES: {
168
166
  readonly contention_exhausted: ErrorCodeSpec;
169
167
  readonly update_aborted: ErrorCodeSpec;
170
168
  readonly idempotency_conflict: ErrorCodeSpec;
169
+ readonly idempotency_key_expired: ErrorCodeSpec;
170
+ readonly source_transport_pinned: ErrorCodeSpec;
171
171
  readonly idempotency_key_too_long: ErrorCodeSpec;
172
172
  readonly write_options_invalid: ErrorCodeSpec;
173
173
  readonly source_operation_id_required: ErrorCodeSpec;
@@ -236,9 +236,12 @@ export declare const ERROR_CODES: {
236
236
  readonly exchange_malformed_response: ErrorCodeSpec;
237
237
  readonly exchange_network_error: ErrorCodeSpec;
238
238
  readonly source_network_error: ErrorCodeSpec;
239
+ readonly source_unreachable: ErrorCodeSpec;
240
+ readonly data_source_blocked: ErrorCodeSpec;
239
241
  readonly identity_network_error: ErrorCodeSpec;
240
242
  readonly commit_no_result: ErrorCodeSpec;
241
243
  readonly commit_failed: ErrorCodeSpec;
244
+ readonly replication_lag_timeout: ErrorCodeSpec;
242
245
  readonly commit_offline_grace_expired: ErrorCodeSpec;
243
246
  readonly queue_too_deep: ErrorCodeSpec;
244
247
  readonly flush_timeout: ErrorCodeSpec;
@@ -308,6 +311,8 @@ export declare const ERROR_CODES: {
308
311
  readonly invalid_id: ErrorCodeSpec;
309
312
  readonly unknown_model: ErrorCodeSpec;
310
313
  readonly model_not_tenant_scoped: ErrorCodeSpec;
314
+ readonly source_tenancy_not_enforced: ErrorCodeSpec;
315
+ readonly model_not_provisioned: ErrorCodeSpec;
311
316
  readonly schema_table_invalid: ErrorCodeSpec;
312
317
  readonly schema_scope_invalid: ErrorCodeSpec;
313
318
  readonly entity_fetch_failed: ErrorCodeSpec;
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * error documentation and returned on the `Ablo-Version` response header, so a
38
38
  * consumer can detect when its expected contract has drifted from the server's.
39
39
  */
40
- export const ERROR_CONTRACT_VERSION = '2026-07-03';
40
+ export const ERROR_CONTRACT_VERSION = '2026-07-15';
41
41
  /**
42
42
  * A closed classification of how a failure can be recovered from — a level above
43
43
  * the raw {@link ErrorCode}. Where a code says what went wrong, a recovery class
@@ -125,15 +125,12 @@ export const ERROR_CODES = {
125
125
  jwt_org_membership_denied: wire('auth', 403, false, "The bearer JWT's subject is not an active member of the organization in its `org_id` claim (removed, suspended, or the claim does not match a membership)."),
126
126
  file_upload_auth_required: wire('auth', 401, false, 'File uploads require an authenticated session. Sign in and retry.'),
127
127
  browser_apikey_blocked: client('auth', 'A raw API key was used from a browser, where anyone can read it. Keep secret keys server-side and hand the browser a short-lived ephemeral key instead.'),
128
- browser_database_url_blocked: client('auth', 'A database connection string was used from a browser context. It carries database credentials, so it must stay server-side.'),
129
- datasource_registration_failed: client('auth', 'The provided `databaseUrl` could not be registered as a data source. Check that the connection string is valid and the database is reachable.'),
130
128
  datasource_connection_unsupported: wire('validation', 400, false, 'This deployment does not accept direct connection-string data sources. Register a signed Data Source endpoint instead.'),
131
- datasource_direct_deprecated: wire('validation', 410, false, 'The direct (connection string) datasource is deprecated. Register a signed Data Source endpoint instead — your app owns the write and your credentials never leave it.'),
132
129
  // ── permission / capability (403) ──────────────────────────────────
133
130
  capability_scope_denied: wire('capability', 403, false, 'This action falls outside the scope granted to the connection, so it was denied.'),
134
131
  issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (`sk_`) API key. The key presented is not a secret key.'),
135
132
  capability_invalid: wire('capability', 403, false, 'This capability cannot be used — it is unknown, revoked, or expired. Request a fresh grant.'),
136
- test_database_not_registered: wire('permission', 403, false, 'Test mode requires a registered dev database for this org — run `npx ablo init`, or construct the client with `databaseUrl` using your test key.'),
133
+ test_database_not_registered: wire('permission', 403, false, 'Test mode requires a registered dev database for this org — run `npx ablo init` to register one for your test key.'),
137
134
  tenant_routing_failed: wire('server', 500, true, "The org's registered database could not be resolved or dialed. Ablo never falls back to shared storage for a dedicated tenant — retry, and check the datasource status if it persists."),
138
135
  database_role_cannot_enforce_rls: wire('permission', 403, false, 'The database role Ablo connects with is a superuser or has `BYPASSRLS`, so Postgres will not enforce row-level security for it. Connect with a role that is subject to RLS.'),
139
136
  database_role_unreadable: wire('permission', 403, false, 'Ablo could not introspect the database role it connects with, so it cannot verify that row-level security is enforced.'),
@@ -153,6 +150,7 @@ export const ERROR_CODES = {
153
150
  // behind the holder (`ablo.<model>.claim`), or re-read and rebase.
154
151
  claim_conflict: wire('claim', 409, false, 'Another participant holds a claim on this row, so the write was rejected. Take a claim with `ablo.<model>.claim` to queue fairly behind the holder, or re-read and rebase.'),
155
152
  claim_lost: wire('claim', 409, false, 'The claim held on this row was lost before the write could apply. Re-acquire the claim and retry.'),
153
+ fence_token_stale: wire('claim', 409, false, 'This write carried a fencing token below the row’s current high-water: a later holder claimed the row, wrote, and moved on while this claim was lapsed, so applying the write would silently overwrite their work. The claim is gone — re-claim the row and retry from the current state.'),
156
154
  entity_claimed: wire('claim', 409, false, 'This row is currently claimed by another participant, so the write was blocked. Queue behind the holder with `ablo.<model>.claim`, or wait for the claim to clear.'),
157
155
  malformed_claim: wire('claim', 400, false, 'The claim payload could not be parsed. A claim must name the model and the entity it targets; check the payload shape and resend.'),
158
156
  malformed_subscription: wire('validation', 400, false, 'The `update_subscription` payload was malformed; expected `{ syncGroups: string[] }`.'),
@@ -173,6 +171,8 @@ export const ERROR_CODES = {
173
171
  contention_exhausted: client('conflict', 'A functional update kept losing to concurrent writes and exhausted its reconcile budget. Back off and retry, raise `retries`, or move the row to the WebSocket transport.'),
174
172
  update_aborted: client('conflict', 'The functional update was aborted via its `AbortSignal` before the write landed; nothing was written.'),
175
173
  idempotency_conflict: wire('conflict', 409, false, 'This `Idempotency-Key` was already used with a different request body. Reuse a key only to retry an identical request; otherwise generate a new one.'),
174
+ idempotency_key_expired: wire('conflict', 409, false, 'This idempotency key belongs to an expired retained source intent and cannot be executed again safely. Use a new key only for a genuinely new write.'),
175
+ source_transport_pinned: wire('conflict', 409, false, 'An earlier attempt under this idempotency key was pinned to a different source transport. Restore that route and retry the same key; Ablo will not switch a possibly committed write.'),
176
176
  idempotency_key_too_long: wire('validation', 400, false, 'The supplied `Idempotency-Key` exceeds the maximum length. Use a shorter key — a UUID works well.'),
177
177
  // ── validation (400 / 422) ─────────────────────────────────────────
178
178
  write_options_invalid: client('validation', 'The write options (`idempotencyKey` / `label` / `wait` / `readAt` / `onStale` / `claim`) failed validation against the write-options schema.'),
@@ -262,9 +262,12 @@ export const ERROR_CODES = {
262
262
  exchange_malformed_response: wire('transport', 502, true, 'The credential exchange returned a response that could not be parsed. Retrying may succeed.'),
263
263
  exchange_network_error: wire('transport', 503, true, 'A network error interrupted the credential exchange. Check connectivity and retry.'),
264
264
  source_network_error: wire('transport', 503, true, 'A network error occurred while talking to the data source. Check connectivity and retry.'),
265
+ source_unreachable: wire('transport', 503, true, 'Ablo could not safely reach the registered direct data source before completing the write. The write remains pinned to direct; retry the same idempotency key after connectivity recovers.'),
266
+ data_source_blocked: wire('transport', 503, false, 'The plane\'s data source is not in a verified, resolvable state. Fix its registration, secret, or configuration; Ablo will not fall through to hosted storage.'),
265
267
  identity_network_error: wire('transport', 503, true, 'A network error occurred while resolving your identity. Check connectivity and retry.'),
266
268
  commit_no_result: wire('transport', 504, true, 'The commit was sent, but no result frame arrived, so its outcome is unknown. It is safe to retry.'),
267
269
  commit_failed: wire('transport', 500, true, 'The commit reached the server but failed to apply. Retrying may succeed.'),
270
+ replication_lag_timeout: wire('transport', 504, true, "The data source accepted the write, but its correlated authoritative source delta did not arrive before the confirmation deadline. The write may still materialize; retry with the same idempotency key or wait for source ingestion to recover."),
268
271
  commit_offline_grace_expired: wire('transport', 503, false, 'The offline grace window expired before this commit could be sent, so it was not applied. Re-apply the change once the connection returns.'),
269
272
  queue_too_deep: wire('transport', 503, true, 'The transaction queue is over its depth limit, so new writes are being rejected until it drains. Retry shortly.'),
270
273
  flush_timeout: wire('transport', 504, true, 'Flushing the transaction queue timed out before every pending write was sent. Retry once connectivity stabilizes.'),
@@ -346,6 +349,8 @@ export const ERROR_CODES = {
346
349
  invalid_id: wire('validation', 400, false, 'The id in the request is not a valid identifier.'),
347
350
  unknown_model: wire('tenant', 400, false, 'Named a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` — the server keeps its own copy of the schema.'),
348
351
  model_not_tenant_scoped: wire('tenant', 400, false, 'This model is not tenant-scoped, so it cannot be queried through the tenant-scoped read path.'),
352
+ source_tenancy_not_enforced: wire('tenant', 400, false, "This model is scoped by its connected data source (`policy: { by: 'source' }`), so its tenant is resolved from the source registration rather than a row column. Enforcing that resolution requires the write-through connect path, which is not enabled on this plane yet — so the model cannot be served through the tenant-scoped read or bootstrap path without risking a cross-tenant read. If this model lives on a log plane, scope it with `by: 'column'` or `by: 'parent'` instead."),
353
+ model_not_provisioned: wire('tenant', 409, false, "This model is in the plane's registered schema, but its table has not been provisioned yet. Registering a schema with `ablo push` records the model; a plane's physical tables are created separately, out of band, so a model can appear in the schema before its table exists. Provision the plane's tables, then retry the read."),
349
354
  schema_table_invalid: wire('schema', 500, false, "The model's table identifier is invalid."),
350
355
  schema_scope_invalid: wire('schema', 500, false, "The model's scope predicate could not be built."),
351
356
  entity_fetch_failed: wire('server', 500, true, 'The server failed to fetch the requested entity. It is safe to retry.'),
package/dist/errors.js CHANGED
@@ -226,11 +226,6 @@ export class AbloContentionError extends AbloError {
226
226
  this.attempts = attempts;
227
227
  }
228
228
  }
229
- function claimReason(claim) {
230
- if (!claim)
231
- return undefined;
232
- return claim.reason;
233
- }
234
229
  function claimDescription(claim) {
235
230
  if (!claim)
236
231
  return undefined;
@@ -238,6 +233,8 @@ function claimDescription(claim) {
238
233
  return claim.description;
239
234
  }
240
235
  const meta = 'target' in claim ? claim.target?.meta ?? claim.meta : claim.meta;
236
+ // Fall back through the meta carrier so a frame that stashed its description
237
+ // there still renders its holder's work.
241
238
  return descriptionFromMeta(meta);
242
239
  }
243
240
  function claimExpiresAt(claim) {
@@ -256,20 +253,18 @@ function secondsUntil(ms, now = Date.now()) {
256
253
  }
257
254
  export function formatClaimedErrorMessage(args) {
258
255
  const holder = claimActor(args.claim, args.heldBy);
259
- const reason = claimReason(args.claim);
260
256
  const description = claimDescription(args.claim);
261
257
  const expiresIn = secondsUntil(claimExpiresAt(args.claim));
262
- if (!holder && !reason && !description) {
258
+ if (!holder && !description) {
263
259
  return args.fallback ?? `Model row is claimed: ${args.targetLabel}.`;
264
260
  }
265
261
  const actor = holder ?? 'another participant';
266
- const reasonPart = reason ? ` (${reason})` : '';
267
262
  const descriptionPart = description ? `: ${description}` : '';
268
263
  const expiresPart = expiresIn !== undefined ? ` - expires in ${expiresIn}s` : '';
269
264
  const policyPart = args.policyReason
270
265
  ? ` Policy reason: ${args.policyReason}.`
271
266
  : '';
272
- return `Claimed by ${actor}${reasonPart}${descriptionPart}${expiresPart} on ${args.targetLabel}.${policyPart}`;
267
+ return `Claimed by ${actor}${descriptionPart}${expiresPart} on ${args.targetLabel}.${policyPart}`;
273
268
  }
274
269
  /**
275
270
  * The target entity is currently claimed by another participant and the caller
package/dist/index.d.ts CHANGED
@@ -63,6 +63,10 @@ export type { AbloOptions, LocalCountOptions, LocalReadOptions, ModelListScope,
63
63
  * the next major release because it was explicitly documented in 0.20.1.
64
64
  */
65
65
  export type { HeldClaim } from './client/Ablo.js';
66
+ /** The resolved value of the row-free `ablo.<model>.claim(id)` overload — a
67
+ * held lease on a key whose row Ablo does not hold (a {@link HeldClaim} with
68
+ * no `.data`). */
69
+ export type { HeldLease } from './client/Ablo.js';
66
70
  export type { AbloPersistence } from './client/persistence.js';
67
71
  export { durableWritesConfigSchema, durableWriteStoreSchema, pendingWriteSchema, } from './transactions/durableWriteStore.js';
68
72
  export type { DurableWritesConfig, DurableWriteStore, PendingWrite, } from './transactions/durableWriteStore.js';