@abloatai/ablo 0.24.0 → 0.26.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 (246) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +45 -36
  4. package/dist/BaseSyncedStore.d.ts +72 -195
  5. package/dist/BaseSyncedStore.js +240 -625
  6. package/dist/Database.d.ts +8 -9
  7. package/dist/Database.js +9 -14
  8. package/dist/LazyReferenceCollection.js +5 -0
  9. package/dist/Model.js +6 -8
  10. package/dist/ModelRegistry.d.ts +25 -6
  11. package/dist/ModelRegistry.js +12 -6
  12. package/dist/NetworkMonitor.js +1 -1
  13. package/dist/ObjectPool.js +9 -3
  14. package/dist/SyncClient.d.ts +15 -5
  15. package/dist/SyncClient.js +152 -66
  16. package/dist/SyncEngineContext.d.ts +1 -3
  17. package/dist/SyncEngineContext.js +1 -2
  18. package/dist/agent/Agent.d.ts +21 -8
  19. package/dist/agent/Agent.js +26 -13
  20. package/dist/ai-sdk/coordination-context.js +9 -3
  21. package/dist/ai-sdk/wrap.d.ts +1 -1
  22. package/dist/ai-sdk/wrap.js +1 -1
  23. package/dist/auth/credentialPolicy.d.ts +10 -2
  24. package/dist/auth/credentialSource.d.ts +1 -13
  25. package/dist/auth/credentialSource.js +6 -13
  26. package/dist/auth/index.js +3 -3
  27. package/dist/cli.cjs +583 -287
  28. package/dist/client/Ablo.d.ts +35 -764
  29. package/dist/client/Ablo.js +64 -688
  30. package/dist/client/ApiClient.d.ts +14 -1
  31. package/dist/client/ApiClient.js +63 -9
  32. package/dist/client/auth.d.ts +8 -17
  33. package/dist/client/auth.js +42 -6
  34. package/dist/client/consoleLogger.d.ts +36 -0
  35. package/dist/client/consoleLogger.js +45 -0
  36. package/dist/client/createInternalComponents.js +1 -1
  37. package/dist/client/createModelProxy.js +10 -6
  38. package/dist/client/credentialEndpoint.d.ts +63 -0
  39. package/dist/client/credentialEndpoint.js +87 -0
  40. package/dist/client/hostedEndpoints.d.ts +24 -0
  41. package/dist/client/hostedEndpoints.js +24 -0
  42. package/dist/client/httpClient.d.ts +1 -1
  43. package/dist/client/identity.js +2 -1
  44. package/dist/client/modelRegistration.d.ts +14 -0
  45. package/dist/client/modelRegistration.js +321 -0
  46. package/dist/client/options.d.ts +396 -0
  47. package/dist/client/options.js +10 -0
  48. package/dist/client/resourceTypes.d.ts +344 -0
  49. package/dist/client/resourceTypes.js +13 -0
  50. package/dist/client/schemaConfig.d.ts +56 -0
  51. package/dist/client/schemaConfig.js +188 -0
  52. package/dist/client/sessionMint.d.ts +1 -1
  53. package/dist/client/validateAbloOptions.js +1 -1
  54. package/dist/client/wsMutationExecutor.d.ts +33 -0
  55. package/dist/client/wsMutationExecutor.js +77 -0
  56. package/dist/context.js +0 -3
  57. package/dist/coordination/index.d.ts +8 -1
  58. package/dist/coordination/index.js +24 -1
  59. package/dist/coordination/trace.d.ts +0 -1
  60. package/dist/coordination/trace.js +0 -1
  61. package/dist/core/DatabaseManager.d.ts +0 -1
  62. package/dist/core/DatabaseManager.js +26 -24
  63. package/dist/core/QueryProcessor.js +7 -8
  64. package/dist/core/QueryView.d.ts +14 -2
  65. package/dist/core/QueryView.js +4 -5
  66. package/dist/core/StoreManager.d.ts +2 -2
  67. package/dist/core/StoreManager.js +5 -1
  68. package/dist/core/index.d.ts +1 -1
  69. package/dist/core/openIDBWithTimeout.js +15 -11
  70. package/dist/core/query-utils.js +1 -1
  71. package/dist/core/storeContract.d.ts +143 -0
  72. package/dist/core/storeContract.js +16 -0
  73. package/dist/errorCodes.d.ts +11 -2
  74. package/dist/errorCodes.js +155 -137
  75. package/dist/errors.d.ts +18 -7
  76. package/dist/errors.js +14 -1
  77. package/dist/index.js +5 -0
  78. package/dist/interfaces/index.d.ts +6 -20
  79. package/dist/keys/index.js +18 -9
  80. package/dist/mutators/UndoManager.js +6 -1
  81. package/dist/mutators/defineMutators.d.ts +1 -3
  82. package/dist/policy/types.d.ts +1 -1
  83. package/dist/query/client.d.ts +11 -0
  84. package/dist/query/client.js +81 -52
  85. package/dist/react/AbloProvider.d.ts +6 -10
  86. package/dist/react/AbloProvider.js +22 -15
  87. package/dist/react/context.d.ts +2 -118
  88. package/dist/react/useAblo.js +1 -1
  89. package/dist/react/useErrorListener.js +1 -1
  90. package/dist/react/useMutationFailureListener.js +1 -1
  91. package/dist/react/useMutators.js +7 -1
  92. package/dist/react/useReactive.js +0 -1
  93. package/dist/react/useUndoScope.js +2 -2
  94. package/dist/schema/ddlLock.d.ts +39 -0
  95. package/dist/schema/ddlLock.js +51 -0
  96. package/dist/schema/field.js +1 -1
  97. package/dist/schema/index.d.ts +4 -3
  98. package/dist/schema/index.js +15 -5
  99. package/dist/schema/model.d.ts +3 -3
  100. package/dist/schema/model.js +2 -2
  101. package/dist/schema/openapi.js +5 -1
  102. package/dist/schema/queries.d.ts +1 -1
  103. package/dist/schema/queries.js +1 -0
  104. package/dist/schema/residency.d.ts +35 -0
  105. package/dist/schema/residency.js +27 -0
  106. package/dist/schema/roles.d.ts +25 -0
  107. package/dist/schema/roles.js +37 -0
  108. package/dist/schema/schema.d.ts +3 -3
  109. package/dist/schema/schema.js +11 -7
  110. package/dist/schema/serialize.d.ts +8 -8
  111. package/dist/schema/sync-delta-row.d.ts +2 -2
  112. package/dist/schema/sync-delta-row.js +2 -2
  113. package/dist/server/storage-mode.d.ts +8 -1
  114. package/dist/server/storage-mode.js +14 -4
  115. package/dist/source/adapter.d.ts +1 -1
  116. package/dist/source/adapters/drizzle.js +4 -3
  117. package/dist/source/adapters/kysely.js +4 -3
  118. package/dist/source/adapters/memory.js +1 -1
  119. package/dist/source/adapters/prisma.js +5 -4
  120. package/dist/source/conformance.js +10 -8
  121. package/dist/source/connector.js +4 -2
  122. package/dist/source/factory.d.ts +106 -0
  123. package/dist/source/factory.js +273 -0
  124. package/dist/source/index.d.ts +11 -461
  125. package/dist/source/index.js +14 -416
  126. package/dist/source/next.d.ts +1 -1
  127. package/dist/source/next.js +1 -1
  128. package/dist/source/pushQueue.d.ts +1 -1
  129. package/dist/source/pushQueue.js +6 -3
  130. package/dist/source/signing.d.ts +59 -0
  131. package/dist/source/signing.js +142 -0
  132. package/dist/source/types.d.ts +334 -0
  133. package/dist/source/types.js +44 -0
  134. package/dist/stores/ObjectStore.d.ts +1 -1
  135. package/dist/stores/ObjectStore.js +23 -23
  136. package/dist/stores/SyncActionStore.d.ts +1 -1
  137. package/dist/stores/SyncActionStore.js +70 -35
  138. package/dist/surface.d.ts +1 -1
  139. package/dist/surface.js +1 -0
  140. package/dist/sync/AreaOfInterestManager.d.ts +2 -2
  141. package/dist/sync/AreaOfInterestManager.js +1 -1
  142. package/dist/sync/BootstrapHelper.d.ts +1 -3
  143. package/dist/sync/BootstrapHelper.js +29 -8
  144. package/dist/sync/ConnectionManager.d.ts +8 -0
  145. package/dist/sync/ConnectionManager.js +19 -4
  146. package/dist/sync/HydrationCoordinator.d.ts +12 -0
  147. package/dist/sync/HydrationCoordinator.js +25 -5
  148. package/dist/sync/NetworkProbe.d.ts +3 -2
  149. package/dist/sync/NetworkProbe.js +7 -6
  150. package/dist/sync/SyncWebSocket.d.ts +71 -120
  151. package/dist/sync/SyncWebSocket.js +324 -630
  152. package/dist/sync/awaitClaimGrant.js +27 -19
  153. package/dist/sync/bootstrapApply.d.ts +60 -0
  154. package/dist/sync/bootstrapApply.js +65 -0
  155. package/dist/sync/commitFrames.d.ts +43 -0
  156. package/dist/sync/commitFrames.js +94 -0
  157. package/dist/sync/createClaimStream.js +3 -2
  158. package/dist/sync/createSnapshot.d.ts +1 -3
  159. package/dist/sync/credentialLifecycle.d.ts +175 -0
  160. package/dist/sync/credentialLifecycle.js +329 -0
  161. package/dist/sync/deltaPipeline.d.ts +111 -0
  162. package/dist/sync/deltaPipeline.js +262 -0
  163. package/dist/sync/groupChange.d.ts +111 -0
  164. package/dist/sync/groupChange.js +246 -0
  165. package/dist/sync/heartbeat.d.ts +62 -0
  166. package/dist/sync/heartbeat.js +91 -0
  167. package/dist/sync/participants.d.ts +8 -8
  168. package/dist/sync/syncCursor.d.ts +44 -0
  169. package/dist/sync/syncCursor.js +59 -0
  170. package/dist/sync/syncPlan.d.ts +62 -0
  171. package/dist/sync/syncPlan.js +51 -0
  172. package/dist/sync/wsFrameHandlers.d.ts +117 -0
  173. package/dist/sync/wsFrameHandlers.js +378 -0
  174. package/dist/testing/fixtures/bootstrap.d.ts +9 -11
  175. package/dist/testing/fixtures/deltas.d.ts +1 -1
  176. package/dist/testing/fixtures/models.js +1 -0
  177. package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
  178. package/dist/testing/helpers/sync-engine-harness.js +2 -1
  179. package/dist/testing/index.d.ts +1 -1
  180. package/dist/testing/index.js +1 -1
  181. package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
  182. package/dist/testing/mocks/MockSyncContext.js +1 -32
  183. package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
  184. package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
  185. package/dist/transactions/TransactionQueue.d.ts +20 -56
  186. package/dist/transactions/TransactionQueue.js +101 -487
  187. package/dist/transactions/TransactionStore.d.ts +18 -0
  188. package/dist/transactions/TransactionStore.js +51 -0
  189. package/dist/transactions/coalesceRules.d.ts +34 -0
  190. package/dist/transactions/coalesceRules.js +117 -0
  191. package/dist/transactions/commitPayload.d.ts +134 -0
  192. package/dist/transactions/commitPayload.js +152 -0
  193. package/dist/transactions/deltaConfirmation.d.ts +60 -0
  194. package/dist/transactions/deltaConfirmation.js +223 -0
  195. package/dist/transactions/optimistic.d.ts +24 -0
  196. package/dist/transactions/optimistic.js +45 -0
  197. package/dist/transactions/persistedReplay.d.ts +93 -0
  198. package/dist/transactions/persistedReplay.js +105 -0
  199. package/dist/types/index.d.ts +3 -3
  200. package/dist/types/index.js +0 -5
  201. package/dist/types/modelData.d.ts +12 -0
  202. package/dist/types/modelData.js +11 -0
  203. package/dist/types/participant.d.ts +21 -0
  204. package/dist/types/participant.js +12 -0
  205. package/dist/types/streams.d.ts +8 -14
  206. package/dist/utils/mobx-setup.d.ts +1 -1
  207. package/dist/utils/mobx-setup.js +2 -2
  208. package/dist/wire/errorEnvelope.d.ts +11 -2
  209. package/dist/wire/errorEnvelope.js +14 -2
  210. package/dist/wire/frames.d.ts +90 -0
  211. package/dist/wire/frames.js +55 -1
  212. package/dist/wire/index.d.ts +4 -1
  213. package/dist/wire/index.js +20 -1
  214. package/dist/wire/protocol.d.ts +45 -0
  215. package/dist/wire/protocol.js +45 -0
  216. package/dist/wire/protocolVersion.d.ts +56 -0
  217. package/dist/wire/protocolVersion.js +63 -0
  218. package/docs/api-keys.md +4 -3
  219. package/docs/data-sources.md +12 -5
  220. package/docs/examples/existing-python-backend.md +3 -3
  221. package/docs/identity.md +4 -4
  222. package/docs/integration-guide.md +1 -1
  223. package/docs/react.md +1 -1
  224. package/docs/sessions.md +5 -7
  225. package/llms.txt +4 -2
  226. package/package.json +15 -13
  227. package/dist/client/index.d.ts +0 -36
  228. package/dist/client/index.js +0 -33
  229. package/dist/config/index.d.ts +0 -10
  230. package/dist/config/index.js +0 -12
  231. package/dist/interfaces/headless.d.ts +0 -95
  232. package/dist/interfaces/headless.js +0 -41
  233. package/dist/query/index.d.ts +0 -6
  234. package/dist/query/index.js +0 -5
  235. package/dist/realtime/index.d.ts +0 -10
  236. package/dist/realtime/index.js +0 -9
  237. package/dist/schema/plane.d.ts +0 -23
  238. package/dist/schema/plane.js +0 -19
  239. package/dist/server/next.d.ts +0 -51
  240. package/dist/server/next.js +0 -47
  241. package/dist/sync/OfflineFlush.d.ts +0 -9
  242. package/dist/sync/OfflineFlush.js +0 -22
  243. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  244. package/dist/sync/OfflineTransactionStore.js +0 -263
  245. package/dist/transactions/index.d.ts +0 -16
  246. package/dist/transactions/index.js +0 -7
@@ -0,0 +1,16 @@
1
+ /**
2
+ * storeContract — the framework-neutral store contract.
3
+ *
4
+ * `SyncStoreContract` is the minimal store interface the SDK's hooks and
5
+ * mutators program against; `BaseSyncedStore` is the concrete engine class
6
+ * that implements it. The contract used to live in `react/context.ts`, which
7
+ * meant the CORE store layer imported its own contract from the React
8
+ * adapter (a module that runtime-imports 'react') — an L2-core →
9
+ * react-integration inversion and a module cycle. It now lives here, in a
10
+ * dependency-free core leaf: `react/context.ts` re-exports these types so
11
+ * React consumers are unchanged, and the core layer never touches 'react'.
12
+ *
13
+ * Everything in this module is type-only — no runtime imports, no runtime
14
+ * exports beyond erased interfaces.
15
+ */
16
+ export {};
@@ -37,9 +37,9 @@ import { z } from 'zod';
37
37
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
38
38
  * and on the `Ablo-Version` response header so a consumer can detect drift.
39
39
  */
40
- export declare const ERROR_CONTRACT_VERSION = "2026-06-20";
40
+ export declare const ERROR_CONTRACT_VERSION = "2026-07-03";
41
41
  /** Coarse grouping for metrics dashboards and docs sectioning. */
42
- export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'claim' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
42
+ export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
43
43
  /**
44
44
  * The closed taxonomy of *how a failure recovers* — one rung above the raw
45
45
  * `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
@@ -172,6 +172,7 @@ export declare const ERROR_CODES: {
172
172
  readonly cli_invalid_arguments: ErrorCodeSpec;
173
173
  readonly turn_validation_failed: ErrorCodeSpec;
174
174
  readonly commit_operation_required: ErrorCodeSpec;
175
+ readonly commit_operation_invalid: ErrorCodeSpec;
175
176
  readonly commit_operation_model_required: ErrorCodeSpec;
176
177
  readonly commit_operations_ambiguous: ErrorCodeSpec;
177
178
  readonly commit_too_many_operations: ErrorCodeSpec;
@@ -307,9 +308,15 @@ export declare const ERROR_CODES: {
307
308
  readonly events_required: ErrorCodeSpec;
308
309
  readonly ingest_failed: ErrorCodeSpec;
309
310
  readonly migration_failed: ErrorCodeSpec;
311
+ readonly schema_provisioning_forbidden: ErrorCodeSpec;
310
312
  readonly model_query_failed: ErrorCodeSpec;
311
313
  readonly queries_required: ErrorCodeSpec;
312
314
  readonly query_unsupported_operator: ErrorCodeSpec;
315
+ readonly query_invalid_like_pattern: ErrorCodeSpec;
316
+ readonly query_invalid_boolean: ErrorCodeSpec;
317
+ readonly protocol_version_unsupported: ErrorCodeSpec;
318
+ readonly database_unreachable: ErrorCodeSpec;
319
+ readonly database_not_replication_ready: ErrorCodeSpec;
313
320
  readonly query_unknown_relation: ErrorCodeSpec;
314
321
  readonly query_relation_target_unknown: ErrorCodeSpec;
315
322
  readonly query_invalid_identifier: ErrorCodeSpec;
@@ -318,6 +325,7 @@ export declare const ERROR_CODES: {
318
325
  readonly upload_fields_required: ErrorCodeSpec;
319
326
  readonly upload_items_required: ErrorCodeSpec;
320
327
  readonly presigned_url_failed: ErrorCodeSpec;
328
+ readonly upload_not_configured: ErrorCodeSpec;
321
329
  readonly task_id_required: ErrorCodeSpec;
322
330
  readonly claim_id_required: ErrorCodeSpec;
323
331
  readonly commit_operation_action_required: ErrorCodeSpec;
@@ -336,6 +344,7 @@ export declare const ERROR_CODES: {
336
344
  readonly turn_foreign_agent: ErrorCodeSpec;
337
345
  readonly invalid_intent: ErrorCodeSpec;
338
346
  readonly schema_too_large: ErrorCodeSpec;
347
+ readonly request_too_large: ErrorCodeSpec;
339
348
  readonly invalid_schema: ErrorCodeSpec;
340
349
  readonly incompatible_change: ErrorCodeSpec;
341
350
  };
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
38
38
  * and on the `Ablo-Version` response header so a consumer can detect drift.
39
39
  */
40
- export const ERROR_CONTRACT_VERSION = '2026-06-20';
40
+ export const ERROR_CONTRACT_VERSION = '2026-07-03';
41
41
  /**
42
42
  * The closed taxonomy of *how a failure recovers* — one rung above the raw
43
43
  * `code`. Where `code` says **what** went wrong, `RecoveryClass` says **what
@@ -83,30 +83,30 @@ const client = (category, message) => ({ category, surface: 'client', retryable:
83
83
  */
84
84
  export const ERROR_CODES = {
85
85
  // ── auth (401) ─────────────────────────────────────────────────────
86
- apikey_invalid: wire('auth', 401, false, 'API key is unknown or malformed.'),
87
- apikey_revoked: wire('auth', 401, false, 'API key has been revoked.'),
86
+ apikey_invalid: wire('auth', 401, false, "This API key isn't one Ablo recognizes — it may be mistyped, truncated, or belong to a different environment. Check the key and try again."),
87
+ apikey_revoked: wire('auth', 401, false, 'This API key has been revoked and can no longer be used. Mint a new key from the dashboard.'),
88
88
  // THE sync-engine access credential — the Stripe-style ephemeral key
89
89
  // (`ek_` for users, `rk_` for agents) minted server-side from the login and
90
90
  // presented as a Bearer. Its expiry is routine and re-mintable: get a fresh
91
91
  // key from the still-valid session and retry — NEVER a sign-out. (An agent's
92
92
  // expired `rk_` must not log a human out either.) This is the ONLY code on
93
93
  // the silent re-mint path; see RecoveryClass `access_credential_expiry`.
94
- apikey_expired: wire('auth', 401, false, 'API key has expired.', 'access_credential_expiry'),
95
- apikey_missing: wire('auth', 401, false, 'No API key was supplied on the request.'),
96
- api_key_required: wire('auth', 401, false, 'This operation requires an API key.'),
97
- capability_id_missing: wire('auth', 401, false, 'A capability id was expected but not provided.'),
98
- exchange_failed: wire('auth', 401, false, 'The API-key credential exchange was rejected.'),
99
- identity_resolve_failed: wire('auth', 401, false, 'Identity resolution was rejected.'),
94
+ apikey_expired: wire('auth', 401, false, 'This ephemeral API key has expired. Mint a fresh key from your still-valid session and retry the request.', 'access_credential_expiry'),
95
+ apikey_missing: wire('auth', 401, false, 'The request arrived without an API key. Send one as `Authorization: Bearer <key>`.'),
96
+ api_key_required: wire('auth', 401, false, 'This operation requires an API key, and none was presented. Send one as `Authorization: Bearer <key>`.'),
97
+ capability_id_missing: wire('auth', 401, false, 'This request must name a capability id, but none was provided.'),
98
+ exchange_failed: wire('auth', 401, false, 'The API key could not be exchanged for a working credential — the exchange was rejected. Check that the key is still valid.'),
99
+ identity_resolve_failed: wire('auth', 401, false, 'The server could not resolve an identity for this credential — the identity lookup was rejected. Check that the credential is still valid.'),
100
100
  auth_no_credentials: wire('auth', 401, false, 'No recognized authentication credential was presented — no API key and no bearer JWT. Send `Authorization: Bearer <token>`.'),
101
- identity_missing_organization: wire('auth', 401, false, 'Authentication succeeded but resolved to no organization context.'),
101
+ identity_missing_organization: wire('auth', 401, false, 'Authentication succeeded, but the credential resolves to no organization, so requests cannot be scoped. Check that the key or token carries an organization.'),
102
102
  // The long-lived login is gone — terminal, drives sign-out + re-auth.
103
- session_expired: wire('auth', 401, false, 'The session is invalid or expired; re-authenticate.', 'session_expiry'),
103
+ session_expired: wire('auth', 401, false, 'Your session has expired or is no longer valid. Sign in again to continue.', 'session_expiry'),
104
104
  // `jwt_invalid` is the residual fallback; the codes below split out the
105
105
  // specific failure modes so an integrating customer can tell "I registered
106
106
  // the wrong JWKS" from "my token has no org claim" from "wrong audience"
107
107
  // rather than getting one opaque code for all of them.
108
- jwt_invalid: wire('auth', 401, false, 'The bearer JWT could not be validated (unclassified).'),
109
- jwt_malformed: wire('auth', 401, false, 'The bearer JWT is not a well-formed JWT and could not be decoded.'),
108
+ jwt_invalid: wire('auth', 401, false, "The bearer JWT failed validation for a reason the server could not classify further. Check the token's issuer, signature, audience, and expiry."),
109
+ jwt_malformed: wire('auth', 401, false, 'The bearer token is not a well-formed JWT and could not be decoded. Check that the full, unmodified token was sent.'),
110
110
  jwt_missing_issuer: wire('auth', 401, false, 'The bearer JWT has no `iss` (issuer) claim, so it cannot be routed to a trusted issuer.'),
111
111
  jwt_issuer_untrusted: wire('auth', 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Register it via POST /v1/trusted-issuers, or check the token's issuer claim."),
112
112
  jwt_signature_invalid: wire('auth', 401, false, "The bearer JWT's signature could not be verified against the issuer's JWKS (wrong key, rotated key, or forged token)."),
@@ -118,24 +118,24 @@ export const ERROR_CODES = {
118
118
  // When a customer DOES present an external-IdP JWT, its expiry means
119
119
  // re-authenticate against that IdP, so it classifies as a session expiry
120
120
  // (which also keeps `isSessionErrorResponse` behaviour unchanged).
121
- jwt_expired: wire('auth', 401, false, 'The bearer JWT has expired; obtain a fresh token.', 'session_expiry'),
121
+ jwt_expired: wire('auth', 401, false, 'The bearer JWT has expired. Obtain a fresh token from your identity provider and retry.', 'session_expiry'),
122
122
  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)."),
123
- file_upload_auth_required: wire('auth', 401, false, 'File upload requires an authenticated session.'),
124
- browser_apikey_blocked: client('auth', 'Raw API keys must not be used from a browser context.'),
125
- browser_database_url_blocked: client('auth', 'A database connection string must not be used from a browser context it carries DB credentials.'),
126
- datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl as a datasource.'),
127
- datasource_connection_unsupported: wire('validation', 400, false, 'This deployment cannot register a direct (connection string) datasource use the signed endpoint kind.'),
123
+ file_upload_auth_required: wire('auth', 401, false, 'File uploads require an authenticated session. Sign in and retry.'),
124
+ 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.'),
125
+ 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.'),
126
+ 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.'),
127
+ datasource_connection_unsupported: wire('validation', 400, false, 'This deployment does not accept direct connection-string data sources. Register a signed Data Source endpoint instead.'),
128
128
  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.'),
129
129
  // ── permission / capability (403) ──────────────────────────────────
130
- capability_scope_denied: wire('capability', 403, false, "The connection's resolved scope does not cover the attempted action."),
131
- issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
132
- capability_invalid: wire('capability', 403, false, 'The capability is unknown, revoked, or expired.'),
130
+ capability_scope_denied: wire('capability', 403, false, 'This action falls outside the scope granted to the connection, so it was denied.'),
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.'),
132
+ capability_invalid: wire('capability', 403, false, 'This capability cannot be used — it is unknown, revoked, or expired. Request a fresh grant.'),
133
133
  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.'),
134
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."),
135
- database_role_cannot_enforce_rls: wire('permission', 403, false, 'The connected database role cannot enforce row-level security (superuser or BYPASSRLS).'),
136
- database_role_unreadable: wire('permission', 403, false, 'The connected database role could not be introspected.'),
137
- database_tables_unforced_rls: wire('permission', 403, false, 'Synced tables in the connected database do not have FORCE ROW LEVEL SECURITY applied.'),
138
- database_host_not_allowed: wire('permission', 403, false, 'The connected database host resolves to a private, loopback, or link-local address and cannot be used.'),
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.'),
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.'),
137
+ database_tables_unforced_rls: wire('permission', 403, false, 'Some synced tables do not have `FORCE ROW LEVEL SECURITY` applied, so the table owner can bypass row isolation. Run `ALTER TABLE ... FORCE ROW LEVEL SECURITY` on each synced table.'),
138
+ database_host_not_allowed: wire('permission', 403, false, "The database host resolves to a private, loopback, or link-local address, which Ablo's servers will not connect to. Use a publicly resolvable host."),
139
139
  // Deprecated spellings of the `database_*` codes above — still emitted by
140
140
  // older servers; kept so they classify identically. Do not use in new code.
141
141
  byo_role_cannot_enforce_rls: wire('permission', 403, false, 'The direct Postgres connector role cannot enforce row-level security.'),
@@ -152,50 +152,60 @@ export const ERROR_CODES = {
152
152
  // behind the holder) or re-read and rebase. `retryable: true` here turned
153
153
  // every cross-client claim conflict into an infinite client resend loop
154
154
  // (~150ms storm — found by the claims journey, 2026-06-10).
155
- claim_conflict: wire('claim', 409, false, 'The target entity is claimed by another participant.'),
156
- claim_lost: wire('claim', 409, false, 'A previously held claim was lost before the write applied.'),
157
- entity_claimed: wire('claim', 409, false, 'The target entity is currently claimed; write was blocked.'),
158
- malformed_claim: wire('claim', 400, false, 'The claim payload was malformed.'),
159
- malformed_subscription: wire('validation', 400, false, 'The update_subscription payload was malformed; expected { syncGroups: string[] }.'),
160
- model_claimed: wire('claim', 409, false, 'The model instance is claimed by another participant.'),
161
- model_claimed_timeout: wire('claim', 409, false, 'Timed out waiting for a model claim to clear.'),
155
+ 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.'),
156
+ 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.'),
157
+ 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.'),
158
+ 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.'),
159
+ malformed_subscription: wire('validation', 400, false, 'The `update_subscription` payload was malformed; expected `{ syncGroups: string[] }`.'),
160
+ model_claimed: wire('claim', 409, false, 'Another participant holds a claim on this row. Read `claim.state` to see who holds it, or queue behind them with a claim of your own.'),
161
+ model_claimed_timeout: wire('claim', 409, false, 'Another participant held a claim on this row and did not release it in time. Retry, or read `claim.state` to see who holds it.'),
162
162
  model_claim_not_configured: client('claim', 'Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically — there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path).'),
163
163
  model_watch_not_configured: client('claim', 'watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
164
164
  // ── stale context / idempotency (409) ──────────────────────────────
165
- stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
165
+ stale_context: wire('conflict', 409, true, "The row changed after you read it — the write's `readAt` watermark is older than the current row version. Re-read the row and retry."),
166
166
  // Raised by the functional `update(id, current => next)` form once its
167
167
  // internal reconcile budget is exhausted — the row stayed continuously
168
168
  // contended. Client-side: the SDK already retried; the caller decides whether
169
169
  // to back off, raise `retries`, or move the row to the WebSocket transport.
170
- contention_exhausted: client('conflict', 'A functional update could not land after exhausting its reconcile budget; the row stayed continuously contended.'),
171
- update_aborted: client('conflict', 'The functional update reconcile loop was aborted via its AbortSignal before the write landed.'),
172
- idempotency_conflict: wire('conflict', 409, false, 'The same Idempotency-Key was reused with a different request body.'),
173
- idempotency_key_too_long: wire('validation', 400, false, 'The supplied Idempotency-Key exceeds the maximum length.'),
170
+ 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.'),
171
+ update_aborted: client('conflict', 'The functional update was aborted via its `AbortSignal` before the write landed; nothing was written.'),
172
+ 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.'),
173
+ idempotency_key_too_long: wire('validation', 400, false, 'The supplied `Idempotency-Key` exceeds the maximum length. Use a shorter key — a UUID works well.'),
174
174
  // ── validation (400 / 422) ─────────────────────────────────────────
175
175
  write_options_invalid: client('validation', 'The write options (`idempotencyKey` / `label` / `wait` / `readAt` / `onStale` / `claim`) failed validation against the write-options schema.'),
176
176
  source_operation_id_required: client('validation', 'A data-source operation arrived without the entity `id` it targets.'),
177
- source_adapter_misconfigured: client('validation', 'The data-source ORM adapter could not map a schema model onto the backing client (missing delegate or model).'),
178
- source_event_invalid: client('validation', 'A data-source outbox event could not be built — the operation carries no entity id and none was supplied.'),
177
+ source_adapter_misconfigured: client('validation', 'The data-source ORM adapter could not map a schema model onto the backing client the client exposes no matching delegate or model. Check that the adapter and schema agree on model names.'),
178
+ // Wire since 2026-07-01: the sync-server validates every pushed/polled
179
+ // source event before appending to the log and rejects the whole batch
180
+ // with this code (`param` names the offending index + field path, e.g.
181
+ // `events[3].entityId`). Also raised client-side by
182
+ // `sourceEventForOperation` when an outbox event cannot be built.
183
+ source_event_invalid: wire('validation', 400, false, 'A data-source event was malformed — missing or invalid id, model, entityId, type, or field value. The whole event batch was rejected and nothing was ingested; fix the offending outbox row and re-send.'),
179
184
  duration_invalid: client('validation', 'A duration value was not a number of seconds or a "500ms" | "30s" | "3m" | "24h" string.'),
180
185
  schema_definition_invalid: client('validation', 'A schema definition value was invalid (bad column identifier, non-finite backfill, or unsupported schema-JSON version).'),
181
186
  cli_invalid_arguments: client('validation', 'The CLI was invoked with an unknown flag or a malformed flag value.'),
182
- turn_validation_failed: wire('validation', 422, false, 'The agent turn failed server-side validation.'),
187
+ turn_validation_failed: wire('validation', 422, false, 'The agent turn payload failed server-side validation and was not applied.'),
183
188
  commit_operation_required: wire('validation', 400, false, 'A commit must carry `operation` or `operations`.'),
189
+ // Wire since 2026-07-01: both commit transports (WS `commit` frame and HTTP
190
+ // `/v1/commits`) validate every operation against `commitOperationSchema`
191
+ // (`wire/frames.ts`) and reject the whole batch with this code. `param`
192
+ // names the offending index + field path (e.g. `operations[3].readAt`).
193
+ commit_operation_invalid: wire('validation', 400, false, 'A commit operation failed validation against the wire commit-operation schema — wrong field type (e.g. a string `readAt`), unknown `type`, or missing `model`. The whole batch was rejected; the error names the offending operation index and field path.'),
184
194
  commit_operation_model_required: wire('validation', 400, false, 'A commit operation is missing its `model`.'),
185
- commit_operations_ambiguous: wire('validation', 400, false, 'A commit supplied both `operation` and `operations`.'),
195
+ commit_operations_ambiguous: wire('validation', 400, false, 'A commit supplied both `operation` and `operations`. Send one or the other, not both.'),
186
196
  commit_too_many_operations: wire('validation', 400, false, 'A commit exceeded the per-commit operation limit; split it into smaller batches.'),
187
- model_required_field_missing: wire('validation', 400, false, 'A required field was absent from the model payload.'),
188
- model_identifier_missing: wire('validation', 400, false, 'The model payload is missing its identifier.'),
189
- snapshot_reserved_key: wire('validation', 400, false, 'A snapshot used a reserved key name.'),
190
- mesh_message_invalid_input: wire('validation', 400, false, 'The mesh message failed input validation.'),
191
- mesh_message_from_id_spoof: wire('validation', 403, false, 'The mesh message `from` id does not match the authenticated sender.'),
192
- mesh_message_from_kind_mismatch: wire('validation', 403, false, 'The mesh message `from` kind does not match the sender.'),
193
- agent_perception_missing_context: wire('validation', 422, false, 'The agent perception request lacked required context.'),
197
+ model_required_field_missing: wire('validation', 400, false, 'The write is missing a field the model marks as required. Include the field and retry.'),
198
+ model_identifier_missing: wire('validation', 400, false, "The payload is missing the model's identifier, so the target row cannot be determined. Include the `id` field."),
199
+ snapshot_reserved_key: wire('validation', 400, false, 'The snapshot uses a key name that is reserved by the runtime. Rename the key and retry.'),
200
+ mesh_message_invalid_input: wire('validation', 400, false, 'The mesh message payload failed input validation and was not delivered.'),
201
+ mesh_message_from_id_spoof: wire('validation', 403, false, "The mesh message's `from` id does not match the authenticated sender, so it was rejected — participants may only send as themselves."),
202
+ mesh_message_from_kind_mismatch: wire('validation', 403, false, "The mesh message's `from` kind does not match the kind of the authenticated sender, so it was rejected."),
203
+ agent_perception_missing_context: wire('validation', 422, false, 'The agent perception request is missing context it needs to run. Include the required context fields and retry.'),
194
204
  // ── not found (404) ────────────────────────────────────────────────
195
- entity_not_found: wire('not_found', 404, false, 'The referenced entity does not exist.'),
196
- model_not_found: wire('not_found', 404, false, 'The referenced model row does not exist.'),
197
- mutate_update_entity_not_found: wire('not_found', 404, false, 'The entity targeted by an update does not exist.'),
198
- task_id_missing: wire('server', 502, true, 'The task-create response did not include an id.'),
205
+ entity_not_found: wire('not_found', 404, false, 'No row exists with the requested id. It may have been deleted, or the id may belong to a different environment.'),
206
+ model_not_found: wire('not_found', 404, false, 'No row of this model exists with the requested id. It may have been deleted, or the id may belong to a different environment.'),
207
+ mutate_update_entity_not_found: wire('not_found', 404, false, 'The row targeted by this update does not exist — it may have been deleted since you read it. Re-read before retrying.'),
208
+ task_id_missing: wire('server', 502, true, 'The task-create response arrived without a task id, so the result cannot be used. Retry the request.'),
199
209
  // ── data integrity / DB constraints ────────────────────────────────
200
210
  // Emitted when a write is rejected by a database integrity constraint
201
211
  // (Postgres class-23). All NON-retryable: the same payload re-sent
@@ -203,83 +213,83 @@ export const ERROR_CODES = {
203
213
  // retry. The server normalizer maps SQLSTATE → these codes and tucks the
204
214
  // raw constraint/column/table detail into `details` rather than leaking
205
215
  // the driver's message text onto the wire.
206
- not_null_violation: wire('validation', 400, false, 'A required field was missing (database not-null constraint).'),
207
- foreign_key_violation: wire('conflict', 409, false, 'A referenced entity does not exist, or is still referenced (database foreign-key constraint).'),
208
- unique_violation: wire('conflict', 409, false, 'A value violates a uniqueness constraint.'),
209
- check_violation: wire('validation', 400, false, 'A value violates a database check constraint.'),
210
- constraint_violation: wire('validation', 400, false, 'A database integrity constraint was violated.'),
216
+ not_null_violation: wire('validation', 400, false, 'The database rejected the write because a required column was left empty — a not-null constraint. The error details name the column; supply a value and retry.'),
217
+ foreign_key_violation: wire('conflict', 409, false, 'The database rejected the write on a foreign-key constraint: a referenced row does not exist, or the row being deleted is still referenced by others. The error details name the constraint.'),
218
+ unique_violation: wire('conflict', 409, false, 'The write duplicates a value that must be unique — another row already holds it. Choose a different value, or update the existing row.'),
219
+ check_violation: wire('validation', 400, false, 'The database rejected a value that fails one of its check constraints. The error details name the constraint; adjust the value and retry.'),
220
+ constraint_violation: wire('validation', 400, false, 'The database rejected the write on an integrity constraint. The error details identify the specific constraint.'),
211
221
  column_type_mismatch: wire('validation', 400, false, 'A structured (JSON) value was written to a column whose database type cannot hold it. Ablo adapts a json field to either a jsonb column (native) or a text column (serialized) — but a scalar column (integer, boolean, uuid, timestamp, …) cannot store a JSON object or array. Use a jsonb or text column for this field. Ablo adapts to your column; it does not alter your schema.'),
212
222
  // ── tenant / unknown model (400) ───────────────────────────────────
213
223
  server_execute_unknown_model: wire('tenant', 400, false, 'Wrote to a model the server does not know. The server keeps its own copy of the schema — run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` before writing to new or changed models.'),
214
224
  mutate_create_unknown_model: wire('tenant', 400, false, 'Created a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` first — the server keeps its own copy of the schema.'),
215
- tenant_model_columns_unknown: wire('tenant', 400, false, "The tenant model's columns could not be resolved."),
216
- tenant_model_missing_organization_id: wire('tenant', 400, false, 'The tenant model is missing the organization_id column required for isolation.'),
225
+ tenant_model_columns_unknown: wire('tenant', 400, false, 'The columns for this model could not be resolved in the tenant database, so the operation cannot be mapped onto its table.'),
226
+ tenant_model_missing_organization_id: wire('tenant', 400, false, "This model's table has no `organization_id` column, which Ablo requires to isolate rows by organization. Add the column before syncing this model."),
217
227
  // ── schema migration / declaration (validation) ────────────────────
218
- schema_mutable_missing_meta: wire('schema', 400, false, 'A mutable schema is missing its required meta block.'),
219
- schema_scope_kind_invalid: wire('schema', 400, false, 'A scope kind in the schema is invalid.'),
220
- schema_field_not_camelcase: wire('schema', 400, false, 'A schema field name is not camelCase.'),
221
- schema_field_consecutive_caps: wire('schema', 400, false, 'A schema field name has consecutive capital letters.'),
228
+ schema_mutable_missing_meta: wire('schema', 400, false, 'The schema is declared mutable but is missing its required `meta` block.'),
229
+ schema_scope_kind_invalid: wire('schema', 400, false, 'A scope declaration in the schema uses a kind the engine does not recognize.'),
230
+ schema_field_not_camelcase: wire('schema', 400, false, 'A schema field name is not camelCase. Rename the field (for example `dueDate`) — Ablo derives column names from camelCase field names.'),
231
+ schema_field_consecutive_caps: wire('schema', 400, false, 'A schema field name contains consecutive capital letters, which cannot be mapped to a column name unambiguously. Write acronyms in lower case (`apiKey`, not `APIKey`).'),
222
232
  schema_reserved_field: client('schema', 'A model redeclared a reserved base field (id, createdAt, updatedAt, organizationId, createdBy) that the SDK provides automatically.'),
223
- schema_grants_shape_invalid: wire('schema', 400, false, 'A grants declaration has an invalid shape.'),
224
- schema_grants_identifier_unsafe: wire('schema', 400, false, 'A grants declaration referenced an unsafe identifier.'),
225
- schema_grants_relation_kind: wire('schema', 400, false, 'A grants relation referenced an invalid kind.'),
226
- schema_grants_relation_missing: wire('schema', 400, false, 'A grants declaration referenced a missing relation.'),
227
- schema_grants_target_not_scope_root: wire('schema', 400, false, 'A grants target is not a scope root.'),
228
- drop_field: client('schema', 'Migration would drop a field (destructive classification).'),
229
- drop_model: client('schema', 'Migration would drop a model (destructive classification).'),
230
- lossy_recreate: client('schema', 'Migration would require a lossy table recreate.'),
231
- made_required: client('schema', 'Migration would make an existing field required.'),
232
- required_field_added: client('schema', 'Migration adds a new required field.'),
233
- enum_value_removed: client('schema', 'Migration removes an enum value (destructive classification).'),
234
- risky_cast: client('schema', 'Migration would perform a risky column type cast.'),
233
+ schema_grants_shape_invalid: wire('schema', 400, false, 'A `grants` declaration in the schema has an invalid shape and could not be parsed.'),
234
+ schema_grants_identifier_unsafe: wire('schema', 400, false, 'A `grants` declaration references an identifier that is not safe to use in SQL. Use plain column and relation names.'),
235
+ schema_grants_relation_kind: wire('schema', 400, false, 'A `grants` declaration references a relation of a kind it cannot traverse.'),
236
+ schema_grants_relation_missing: wire('schema', 400, false, 'A `grants` declaration references a relation the model does not define. Check the relation name against the model.'),
237
+ schema_grants_target_not_scope_root: wire('schema', 400, false, 'A `grants` declaration targets a model that is not a scope root, so access cannot be derived from it.'),
238
+ drop_field: client('schema', 'This migration would drop an existing field, destroying the data stored in it.'),
239
+ drop_model: client('schema', 'This migration would drop an entire model and its table, destroying the rows stored in it.'),
240
+ lossy_recreate: client('schema', 'This migration can only apply by recreating the table, which would not preserve its existing rows.'),
241
+ made_required: client('schema', 'This migration makes an existing optional field required, which rows without a value for it would violate.'),
242
+ required_field_added: client('schema', 'This migration adds a new required field that existing rows have no value for.'),
243
+ enum_value_removed: client('schema', 'This migration removes an enum value that existing rows may still hold.'),
244
+ risky_cast: client('schema', 'This migration changes a column to a type its current values may not convert to cleanly.'),
235
245
  // ── claim / lease (409 / transport) ───────────────────────────────
236
- claim_lease_unavailable: wire('claim', 503, true, 'The claim-lease coordination subsystem is unavailable; retry.'),
237
- claim_not_wired: client('claim', 'Claim support was used but is not wired in this runtime.'),
238
- claim_queued: wire('claim', 409, true, 'The claim was queued behind an active lease holder.'),
239
- claim_wait_aborted: wire('claim', 409, true, 'Waiting for the claim lease was aborted.'),
240
- claim_wait_poll_interval_required: client('claim', 'A poll interval is required when waiting on an claim.'),
241
- grant_timeout: wire('claim', 504, true, 'Timed out waiting for a capability grant.'),
242
- slide_intent_missing_deck_id: wire('claim', 400, false, 'A slide claim was missing its deck id.'),
243
- slide_intent_unknown_sibling: wire('claim', 400, false, 'A slide claim referenced an unknown sibling slide.'),
246
+ claim_lease_unavailable: wire('claim', 503, true, 'The claim-lease coordination subsystem is temporarily unavailable, so the claim could not be processed. Retry shortly.'),
247
+ claim_not_wired: client('claim', 'Claims were used, but this runtime has no claim support wired in. The standard `Ablo({ schema, apiKey })` client wires it up automatically.'),
248
+ claim_queued: wire('claim', 409, true, 'The claim was queued behind the current lease holder and will be granted in turn. Wait, or read `claim.queue` to see your position.'),
249
+ claim_wait_aborted: wire('claim', 409, true, 'The wait for this claim lease was aborted before the lease was granted.'),
250
+ claim_wait_poll_interval_required: client('claim', 'Waiting on a claim requires a poll interval, and none was provided.'),
251
+ grant_timeout: wire('claim', 504, true, 'The wait for a capability grant timed out before one arrived. Retry the request.'),
252
+ slide_intent_missing_deck_id: wire('claim', 400, false, 'This slide claim is missing the id of the deck it belongs to.'),
253
+ slide_intent_unknown_sibling: wire('claim', 400, false, 'This slide claim references a sibling slide that does not exist in the deck.'),
244
254
  // ── bootstrap (transport) ──────────────────────────────────────────
245
- bootstrap_fetch_timeout: wire('bootstrap', 504, true, 'The bootstrap fetch timed out.'),
246
- bootstrap_offline: wire('bootstrap', 503, true, 'Bootstrap could not run because the client is offline.'),
247
- bootstrap_offline_no_cache: wire('bootstrap', 503, false, 'Bootstrap is offline and no cached snapshot is available.'),
248
- bootstrap_response_invalid: wire('bootstrap', 502, true, 'The bootstrap response was malformed.'),
249
- bootstrap_response_schema_invalid: wire('bootstrap', 502, true, 'The bootstrap response failed schema validation.'),
255
+ bootstrap_fetch_timeout: wire('bootstrap', 504, true, 'The initial bootstrap fetch timed out before the server responded. Retry shortly.'),
256
+ bootstrap_offline: wire('bootstrap', 503, true, 'Bootstrap could not run because the client is offline. It can proceed once the network returns.'),
257
+ bootstrap_offline_no_cache: wire('bootstrap', 503, false, 'The client is offline and no cached snapshot is available to start from, so there is no data to load until the network returns.'),
258
+ bootstrap_response_invalid: wire('bootstrap', 502, true, 'The bootstrap response could not be parsed. Retrying may succeed.'),
259
+ bootstrap_response_schema_invalid: wire('bootstrap', 502, true, 'The bootstrap response parsed but failed schema validation, so it was not applied. Retrying may succeed.'),
250
260
  // ── transport / connection ─────────────────────────────────────────
251
- exchange_malformed_response: wire('transport', 502, true, 'The credential exchange returned a malformed response.'),
252
- exchange_network_error: wire('transport', 503, true, 'A network error occurred during credential exchange.'),
253
- source_network_error: wire('transport', 503, true, 'A network error occurred talking to the source.'),
254
- identity_network_error: wire('transport', 503, true, 'A network error occurred resolving identity.'),
255
- commit_no_result: wire('transport', 504, true, 'The commit was sent but no result frame arrived.'),
256
- commit_failed: wire('transport', 500, true, 'The commit failed to apply.'),
257
- commit_offline_grace_expired: wire('transport', 503, false, "The offline grace window expired before the commit could be sent."),
258
- queue_too_deep: wire('transport', 503, true, 'The transaction queue exceeded its depth limit.'),
259
- flush_timeout: wire('transport', 504, true, 'Timed out flushing the transaction queue.'),
260
- wait_for_timeout: wire('transport', 504, true, 'A wait-for condition timed out.'),
261
+ exchange_malformed_response: wire('transport', 502, true, 'The credential exchange returned a response that could not be parsed. Retrying may succeed.'),
262
+ exchange_network_error: wire('transport', 503, true, 'A network error interrupted the credential exchange. Check connectivity and retry.'),
263
+ source_network_error: wire('transport', 503, true, 'A network error occurred while talking to the data source. Check connectivity and retry.'),
264
+ identity_network_error: wire('transport', 503, true, 'A network error occurred while resolving your identity. Check connectivity and retry.'),
265
+ 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.'),
266
+ commit_failed: wire('transport', 500, true, 'The commit reached the server but failed to apply. Retrying may succeed.'),
267
+ 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.'),
268
+ 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.'),
269
+ flush_timeout: wire('transport', 504, true, 'Flushing the transaction queue timed out before every pending write was sent. Retry once connectivity stabilizes.'),
270
+ wait_for_timeout: wire('transport', 504, true, 'A wait-for condition timed out before it was satisfied. Retry, or extend the timeout.'),
261
271
  instance_at_capacity: wire('transport', 503, true, 'The server is at connection capacity. Retry shortly — transient and not specific to your credentials.'),
262
- fetch_unavailable: client('transport', 'No fetch implementation is available in this environment.'),
263
- base_url_missing: client('transport', 'No base URL was configured for the client.'),
264
- sync_not_ready: client('transport', 'A sync operation was attempted before the client was ready.'),
265
- ws_not_ready: client('transport', 'A frame was sent before the WebSocket was connected.'),
272
+ fetch_unavailable: client('transport', 'This environment provides no `fetch` implementation, so HTTP requests cannot be made. Run on a platform with `fetch` (Node 18+, modern browsers) or supply a polyfill.'),
273
+ base_url_missing: client('transport', 'The client has no base URL configured, so it cannot address the server. Set the base URL when constructing the client.'),
274
+ sync_not_ready: client('transport', 'A sync operation ran before the client finished initializing. Wait for the client to be ready before syncing.'),
275
+ ws_not_ready: client('transport', 'A frame was sent before the WebSocket connection was established. Wait for the connection to open before sending.'),
266
276
  // ── quota / rate limit (429) ──────────────────────────────────────
267
- quota_exceeded: wire('rate_limit', 429, true, 'The organization exceeded its configured usage quota.'),
277
+ quota_exceeded: wire('rate_limit', 429, true, 'Your organization has used up its configured usage quota. Requests will succeed again once the quota resets or the limit is raised.'),
268
278
  connection_limit_exceeded: wire('rate_limit', 429, true, 'Too many concurrent WebSocket connections for this principal or organization. Close idle connections, or retry once others drain.'),
269
279
  // Per-CREDENTIAL request-rate limit — the fast (RPS/burst) axis, distinct from
270
280
  // the slow-axis `quota_exceeded` (org daily/monthly usage). Keyed per API key,
271
281
  // so one noisy key backs off without affecting the rest of the org. The
272
282
  // `Retry-After` header carries the bucket-refill delay.
273
- rate_limit_exceeded: wire('rate_limit', 429, true, 'This API key is sending requests too quickly; slow down and retry after the indicated delay.'),
283
+ rate_limit_exceeded: wire('rate_limit', 429, true, 'This API key is sending requests faster than its rate limit allows. Slow down and retry after the delay in the `Retry-After` header.'),
274
284
  // ── server (5xx) ───────────────────────────────────────────────────
275
- internal_error: wire('server', 500, true, 'An unexpected server error occurred.'),
276
- quota_lookup_failed: wire('server', 503, true, 'The quota decision could not be loaded.'),
285
+ internal_error: wire('server', 500, true, "Something went wrong on Ablo's side — an unexpected server error. It is safe to retry."),
286
+ quota_lookup_failed: wire('server', 503, true, "The server could not load this organization's quota state, so the request was rejected rather than admitted unchecked. Retry shortly."),
277
287
  // The per-key rate-limiter backend (Redis) was unreachable and the API is
278
288
  // configured to FAIL CLOSED on that path, so the request was rejected rather
279
289
  // than admitted unchecked. Retryable: the next attempt re-probes the backend.
280
290
  rate_limiter_unavailable: wire('server', 503, true, 'The rate-limiter backend is unavailable and this endpoint is configured to fail closed; retry shortly.'),
281
- turn_open_failed: wire('server', 500, true, 'The agent turn failed to open.'),
282
- turn_close_failed: wire('server', 500, true, 'The agent turn failed to close cleanly.'),
291
+ turn_open_failed: wire('server', 500, true, 'The agent turn could not be opened on the server. It is safe to retry.'),
292
+ turn_close_failed: wire('server', 500, true, 'The agent turn could not be closed cleanly on the server. It is safe to retry the close.'),
283
293
  // ── client-only invariants (never serialized) ──────────────────────
284
294
  invalid_options: client('client', 'The Ablo client was constructed with invalid or incomplete options.'),
285
295
  no_ablo_provider: client('client', 'An Ablo hook was used outside of an Ablo provider.'),
@@ -325,52 +335,60 @@ export const ERROR_CODES = {
325
335
  capability_id_required: wire('validation', 400, false, 'A capability id is required for this request.'),
326
336
  organization_mismatch: wire('permission', 403, false, 'The request targeted an organization the caller is not scoped to.'),
327
337
  project_scope_denied: wire('permission', 403, false, "The request targeted a project the caller's key is not scoped to."),
328
- project_slug_taken: wire('validation', 409, false, 'A project with this slug already exists in the organization.'),
338
+ project_slug_taken: wire('validation', 409, false, 'A project with this slug already exists in the organization. Choose a different slug.'),
329
339
  forbidden: wire('permission', 403, false, 'The caller lacks permission for this operation.'),
330
- source_api_key_unresolved: wire('auth', 401, false, 'The source API key could not be resolved.'),
340
+ source_api_key_unresolved: wire('auth', 401, false, 'The API key presented for this data source could not be resolved to a known key. Check the key and its environment.'),
331
341
  capability_auth_disabled: wire('server', 503, false, 'Capability authentication is disabled on this server.'),
332
- provisioner_unavailable: wire('server', 503, false, 'No database provisioner is configured.'),
333
- invalid_model: wire('validation', 400, false, 'The request named an invalid model.'),
334
- invalid_id: wire('validation', 400, false, 'The request carried an invalid id.'),
342
+ provisioner_unavailable: wire('server', 503, false, 'This deployment has no database provisioner configured, so tables cannot be created here.'),
343
+ invalid_model: wire('validation', 400, false, 'The model name in the request is not a valid model identifier.'),
344
+ invalid_id: wire('validation', 400, false, 'The id in the request is not a valid identifier.'),
335
345
  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.'),
336
- model_not_tenant_scoped: wire('tenant', 400, false, 'The model is not tenant-scoped and cannot be queried this way.'),
346
+ 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.'),
337
347
  schema_table_invalid: wire('schema', 500, false, "The model's table identifier is invalid."),
338
348
  schema_scope_invalid: wire('schema', 500, false, "The model's scope predicate could not be built."),
339
- entity_fetch_failed: wire('server', 500, true, 'The entity fetch failed.'),
340
- events_required: wire('validation', 400, false, 'The request must include a non-empty events array.'),
341
- ingest_failed: wire('validation', 400, false, 'The source-event ingest failed.'),
342
- migration_failed: wire('server', 500, false, 'The schema migration failed to apply.'),
343
- model_query_failed: wire('validation', 400, false, 'The model query failed.'),
344
- queries_required: wire('validation', 400, false, 'The request must include a non-empty queries array.'),
349
+ entity_fetch_failed: wire('server', 500, true, 'The server failed to fetch the requested entity. It is safe to retry.'),
350
+ events_required: wire('validation', 400, false, 'The request must include a non-empty `events` array.'),
351
+ ingest_failed: wire('validation', 400, false, 'The source-event batch was rejected during ingest and nothing was appended. Check the events against the expected shape and re-send.'),
352
+ migration_failed: wire('server', 500, false, 'The schema migration failed while applying and did not complete.'),
353
+ schema_provisioning_forbidden: wire('permission', 403, false, 'Schema registration could not create tables in the target database: the engine is not permitted to run DDL there.'),
354
+ model_query_failed: wire('validation', 400, false, 'The model query failed to execute. Check the query filters and operators.'),
355
+ queries_required: wire('validation', 400, false, 'The request must include a non-empty `queries` array.'),
345
356
  query_unsupported_operator: wire('validation', 400, false, 'The query used an unsupported operator.'),
346
- query_unknown_relation: wire('validation', 400, false, 'The query referenced an unknown relation.'),
347
- query_relation_target_unknown: wire('schema', 500, false, 'A relation targets a model the schema does not define.'),
357
+ query_invalid_like_pattern: wire('validation', 400, false, 'The `LIKE` pattern must not end with an escape character.'),
358
+ query_invalid_boolean: wire('validation', 400, false, 'The query compared a boolean column against an invalid boolean literal.'),
359
+ protocol_version_unsupported: wire('transport', 426, false, 'The client sync-protocol version is outside the range this server supports — upgrade the SDK (or the server was rolled back mid-fleet).'),
360
+ database_unreachable: wire('validation', 400, false, "Ablo could not reach this database to check that it can stream replication. The connection string may be wrong, the host may not be reachable from Ablo's servers, or the credentials may not be accepted."),
361
+ database_not_replication_ready: wire('validation', 400, false, 'This database is not set up for logical replication yet. Every failing item — wal_level, the publication, the replication grant, a replica identity — is listed in the error details with its exact fix. `ablo connect` prints the one-time setup; `ablo connect --check` verifies it.'),
362
+ query_unknown_relation: wire('validation', 400, false, 'The query references a relation the model does not define. Check the relation name against the schema.'),
363
+ query_relation_target_unknown: wire('schema', 500, false, 'A relation in the query targets a model the schema does not define.'),
348
364
  query_invalid_identifier: wire('validation', 400, false, 'The query contained an invalid identifier.'),
349
365
  org_id_required: wire('validation', 400, false, 'An organization id is required for this request.'),
350
- presence_identity_required: wire('validation', 400, false, 'Both userId and organizationId are required.'),
351
- upload_fields_required: wire('validation', 400, false, 'A required upload field was missing.'),
352
- upload_items_required: wire('validation', 400, false, 'The request must include a non-empty items array.'),
353
- presigned_url_failed: wire('server', 500, true, 'Failed to generate a presigned upload URL.'),
366
+ presence_identity_required: wire('validation', 400, false, 'Presence requests must carry both `userId` and `organizationId`.'),
367
+ upload_fields_required: wire('validation', 400, false, 'The upload request is missing a required field.'),
368
+ upload_items_required: wire('validation', 400, false, 'The request must include a non-empty `items` array.'),
369
+ presigned_url_failed: wire('server', 500, true, 'The server could not generate a presigned upload URL. It is safe to retry.'),
370
+ upload_not_configured: wire('server', 503, false, 'Uploads are not configured on this deployment: the upload storage bucket and CDN domain are unset.'),
354
371
  task_id_required: wire('validation', 400, false, 'A task id is required for this request.'),
355
- claim_id_required: wire('validation', 400, false, 'An claim id is required for this request.'),
372
+ claim_id_required: wire('validation', 400, false, 'A claim id is required for this request.'),
356
373
  commit_operation_action_required: wire('validation', 400, false, 'A commit operation is missing its `action`.'),
357
374
  commit_operation_unsupported: wire('validation', 400, false, 'A commit operation used an unsupported `action`.'),
358
375
  usage_invalid: wire('validation', 400, false, 'The usage request was invalid.'),
359
376
  invalid_request: wire('validation', 400, false, 'The request parameters were invalid.'),
360
377
  capability_not_found: wire('not_found', 404, false, 'No capability exists with the given id.'),
361
- invalid_participant_kind: wire('validation', 400, false, 'The participant kind is invalid.'),
362
- invalid_sync_group: wire('validation', 400, false, 'Sync groups must be "default" or "<namespace>:<id>".'),
363
- narrow_scope_required: wire('validation', 400, false, 'A narrowed scope is required for this request.'),
364
- wide_scope_forbidden: wire('permission', 403, false, 'A wide scope is not permitted for this caller.'),
365
- capability_required: wire('auth', 401, false, 'This operation requires a capability.'),
378
+ invalid_participant_kind: wire('validation', 400, false, 'The participant kind is not one the server recognizes.'),
379
+ invalid_sync_group: wire('validation', 400, false, 'Sync groups must be `default` or `<namespace>:<id>`.'),
380
+ narrow_scope_required: wire('validation', 400, false, 'This request requires a scope narrowed to specific resources; the presented scope is too broad.'),
381
+ wide_scope_forbidden: wire('permission', 403, false, 'This caller may not use a wide scope. Request a scope narrowed to the resources you need.'),
382
+ capability_required: wire('auth', 401, false, 'This operation requires a capability, and none was presented.'),
366
383
  parent_turn_not_found: wire('not_found', 404, false, 'The referenced parent turn does not exist.'),
367
- parent_turn_foreign_agent: wire('permission', 403, false, 'The parent turn belongs to a different agent.'),
384
+ parent_turn_foreign_agent: wire('permission', 403, false, 'The referenced parent turn belongs to a different agent, so it cannot be used here.'),
368
385
  turn_not_found: wire('not_found', 404, false, 'The referenced turn does not exist.'),
369
- turn_foreign_agent: wire('permission', 403, false, 'The turn belongs to a different agent.'),
370
- invalid_intent: wire('validation', 400, false, 'The claim request was invalid.'),
386
+ turn_foreign_agent: wire('permission', 403, false, 'This turn belongs to a different agent, so the caller cannot act on it.'),
387
+ invalid_intent: wire('validation', 400, false, 'The claim request was malformed and could not be processed.'),
371
388
  schema_too_large: wire('validation', 413, false, 'The submitted schema exceeds the maximum size.'),
389
+ request_too_large: wire('validation', 413, false, 'The request body exceeds the maximum size.'),
372
390
  invalid_schema: wire('validation', 400, false, 'The submitted schema could not be parsed.'),
373
- incompatible_change: wire('conflict', 409, false, 'The schema change is incompatible with the current schema.'),
391
+ incompatible_change: wire('conflict', 409, false, 'The schema change is incompatible with the schema currently deployed and cannot be applied as-is.'),
374
392
  };
375
393
  /** Look up an error code's spec. Returns `undefined` for the dynamic
376
394
  * `policy:*` family and for any forward-compat code an older SDK doesn't