@abloatai/ablo 0.18.0 → 0.20.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 (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/Model.d.ts +22 -0
  3. package/dist/Model.js +45 -0
  4. package/dist/ObjectPool.d.ts +14 -1
  5. package/dist/ObjectPool.js +8 -1
  6. package/dist/SyncClient.js +7 -1
  7. package/dist/SyncEngineContext.js +2 -0
  8. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  9. package/dist/ai-sdk/coordination-context.js +3 -3
  10. package/dist/ai-sdk/index.d.ts +3 -4
  11. package/dist/ai-sdk/index.js +2 -3
  12. package/dist/ai-sdk/wrap.d.ts +13 -18
  13. package/dist/ai-sdk/wrap.js +2 -6
  14. package/dist/cli.cjs +253 -160
  15. package/dist/client/Ablo.d.ts +83 -22
  16. package/dist/client/Ablo.js +116 -32
  17. package/dist/client/ApiClient.d.ts +2 -2
  18. package/dist/client/ApiClient.js +27 -32
  19. package/dist/client/auth.d.ts +26 -0
  20. package/dist/client/auth.js +208 -0
  21. package/dist/client/createModelProxy.d.ts +16 -11
  22. package/dist/client/createModelProxy.js +15 -15
  23. package/dist/client/sessionMint.d.ts +10 -0
  24. package/dist/client/sessionMint.js +8 -1
  25. package/dist/coordination/schema.d.ts +10 -10
  26. package/dist/coordination/schema.js +7 -8
  27. package/dist/coordination/trace.d.ts +91 -0
  28. package/dist/coordination/trace.js +147 -0
  29. package/dist/errorCodes.d.ts +2 -0
  30. package/dist/errorCodes.js +2 -0
  31. package/dist/errors.d.ts +1 -2
  32. package/dist/errors.js +6 -9
  33. package/dist/index.d.ts +6 -1
  34. package/dist/index.js +13 -0
  35. package/dist/interfaces/index.d.ts +45 -2
  36. package/dist/mutators/undoApply.d.ts +7 -2
  37. package/dist/mutators/undoApply.js +7 -35
  38. package/dist/policy/types.d.ts +18 -9
  39. package/dist/policy/types.js +26 -16
  40. package/dist/react/AbloProvider.d.ts +2 -2
  41. package/dist/react/useAblo.js +12 -2
  42. package/dist/schema/sugar.js +10 -2
  43. package/dist/server/read-config.d.ts +7 -0
  44. package/dist/sync/HydrationCoordinator.js +7 -3
  45. package/dist/sync/SyncWebSocket.d.ts +11 -2
  46. package/dist/sync/SyncWebSocket.js +67 -3
  47. package/dist/sync/createClaimStream.d.ts +9 -2
  48. package/dist/sync/createClaimStream.js +9 -7
  49. package/dist/sync/participants.d.ts +12 -11
  50. package/dist/sync/participants.js +5 -5
  51. package/dist/transactions/TransactionQueue.d.ts +1 -0
  52. package/dist/transactions/TransactionQueue.js +40 -3
  53. package/dist/types/streams.d.ts +57 -135
  54. package/dist/utils/json.d.ts +39 -0
  55. package/dist/utils/json.js +88 -0
  56. package/docs/coordination.md +16 -0
  57. package/docs/debugging.md +194 -0
  58. package/docs/index.md +1 -0
  59. package/package.json +1 -1
  60. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  61. package/dist/ai-sdk/claim-broadcast.js +0 -79
@@ -29,7 +29,7 @@ import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
29
29
  import type { SyncGroupInput } from '../schema/roles.js';
30
30
  import { type SyncStatus } from '../BaseSyncedStore.js';
31
31
  import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
32
- import type { ClaimHandle, Duration } from '../types/streams.js';
32
+ import type { Claim, Duration } from '../types/streams.js';
33
33
  import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
34
34
  import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
35
35
  /**
@@ -96,18 +96,19 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
96
96
  */
97
97
  apiKey?: string | ApiKeySetter | null | undefined;
98
98
  /**
99
- * Direct-URL convenience connector: a connection string to your own Postgres
100
- * that Ablo can register for a dedicated tenant.
99
+ * @deprecated The direct connector lets Ablo dial INTO your Postgres and write to
100
+ * it the operate-their-database posture we are moving off. Ablo is Stripe-shaped:
101
+ * it hosts only the transaction log (the ordered sync_deltas) + coordination, never
102
+ * your data; your rows always live in your own database. Use the signed Data Source
103
+ * endpoint instead — keep `DATABASE_URL` in your app, expose `dataSource(...)`, and
104
+ * let your server own the write while Ablo coordinates the sync stream. To keep the
105
+ * log in your infra too, self-host the engine. See
106
+ * docs/plans/stripe-shaped-storage-posture.md.
101
107
  *
102
- * This is NOT the default Data Source path. For the Zero-shaped default, keep
103
- * `DATABASE_URL` in your app, expose `dataSource(...)`, and let your server
104
- * write the database while Ablo coordinates the sync stream.
105
- *
106
- * SERVER-ONLY: this carries credentials, so it is never sent from the browser
107
- * — constructing a client with `databaseUrl` and `dangerouslyAllowBrowser`
108
- * throws. If you opt into this connector, provide a NON-superuser,
109
- * non-`BYPASSRLS` role; the direct connector rejects privileged roles that
110
- * cannot enforce RLS.
108
+ * Still honored at runtime for back-compat. SERVER-ONLY: it carries credentials, so
109
+ * it is never sent from the browser constructing a client with `databaseUrl` and
110
+ * `dangerouslyAllowBrowser` throws. If you use it, provide a NON-superuser,
111
+ * non-`BYPASSRLS` role; the connector rejects privileged roles that cannot enforce RLS.
111
112
  */
112
113
  databaseUrl?: string | null | undefined;
113
114
  /**
@@ -403,7 +404,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
403
404
  * `create({ data })` / `update({ id, data })` / `delete({ id })` — writes
404
405
  * `claim({ id })` — durable claim handle for coordinated writes
405
406
  */
406
- export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
407
+ export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ModelOperations, } from './createModelProxy.js';
407
408
  import type { ModelOperations, ClaimOptions, ClaimParams, ClaimReadApi, AwaitedClaimMethod, ServerReadOptions } from './createModelProxy.js';
408
409
  export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
409
410
  export type CommitWait = 'queued' | 'confirmed';
@@ -428,7 +429,7 @@ export interface ModelReadOptions extends ClaimedOptions {
428
429
  export interface ClaimCreateOptions {
429
430
  readonly target: ModelTarget;
430
431
  /** Human-readable phase shown to peers — `'editing'`, `'writing'`. The same
431
- * word on every claim surface; serialized on the wire as `action`. */
432
+ * word on every claim surface. */
432
433
  readonly reason: string;
433
434
  readonly ttl?: Duration;
434
435
  /**
@@ -474,7 +475,7 @@ export interface CommitCreateOptions {
474
475
  * against concurrent edits without re-stating the watermark by hand.
475
476
  * Explicit `readAt`/`onStale` on the options win.
476
477
  */
477
- readonly claim?: ClaimHandle<Record<string, unknown>> | null;
478
+ readonly claim?: Claim<Record<string, unknown>> | null;
478
479
  readonly operation?: CommitOperationInput;
479
480
  readonly operations?: readonly CommitOperationInput[];
480
481
  readonly wait?: CommitWait;
@@ -513,7 +514,7 @@ export interface CommitResource {
513
514
  create(options: CommitCreateOptions): Promise<CommitReceipt>;
514
515
  }
515
516
  export interface ClaimResource extends ClaimStream {
516
- create(options: ClaimCreateOptions): Promise<ClaimHandle>;
517
+ create(options: ClaimCreateOptions): Promise<Claim>;
517
518
  list(target?: Partial<ModelTarget>): readonly ModelClaim[];
518
519
  waitFor(target: Partial<ModelTarget>, options?: ClaimWaitOptions): Promise<void>;
519
520
  }
@@ -525,7 +526,7 @@ export interface ModelMutationOptions extends ClaimedOptions {
525
526
  readonly readAt?: number | null;
526
527
  readonly onStale?: 'reject' | 'overwrite' | 'notify' | null;
527
528
  readonly wait?: CommitWait;
528
- readonly claim?: ClaimHandle | ClaimOptions | null;
529
+ readonly claim?: Claim | ClaimOptions | null;
529
530
  }
530
531
  /**
531
532
  * The HTTP/stateless claim surface. Normal tools usually put `claim` directly
@@ -543,7 +544,7 @@ export interface ModelMutationOptions extends ClaimedOptions {
543
544
  * wrapper that statelessness forces. `claim({ id })` is identical (already async
544
545
  * on both); `state`/`queue`/`reorder`/`release` are the awaited form.
545
546
  */
546
- export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>) => Promise<ClaimHandle<T>>) & {
547
+ export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>) => Promise<Claim<T>>) & {
547
548
  [K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
548
549
  };
549
550
  export interface ModelClient<T = Record<string, unknown>> {
@@ -646,6 +647,38 @@ export interface CreateAgentSessionParams<S extends SchemaRecord> {
646
647
  * `{ user }` for a full-authority end-user session (`ek_`) or `{ agent, can }`
647
648
  * for a scoped agent session (`rk_`). */
648
649
  export type CreateSessionParams<S extends SchemaRecord> = CreateUserSessionParams | CreateAgentSessionParams<S>;
650
+ /** Params for {@link Ablo.agents}.create — a flattened agent descriptor (no
651
+ * `{ agent }` discriminator: `agents.create` only ever mints an agent). Unlike
652
+ * {@link CreateSessionParams} it resolves to a connected, scoped {@link Ablo}
653
+ * client rather than a raw token. */
654
+ export interface CreateAgentClientParams<S extends SchemaRecord> {
655
+ /** Wire participant identity (`agent:<id>`) — what claim exclusion and the
656
+ * FIFO queue gate on. OMIT to get a fresh `crypto.randomUUID()`: a distinct,
657
+ * independent participant (the default, and what you want for concurrent
658
+ * agents). Pass a STABLE string only when one logical agent must re-attach
659
+ * to its own held claims across reconnects/restarts. */
660
+ id?: string;
661
+ /** Human-readable label for logs / attribution (carried in `userMeta.name`).
662
+ * INDEPENDENT of `id`: two agents that share a `name` still receive distinct
663
+ * ids and coordinate as SEPARATE participants — `name` never derives or
664
+ * collapses identity. */
665
+ name?: string;
666
+ /** Per-model operation allowlist, typed against the schema's model names. */
667
+ can: {
668
+ [M in keyof S & string]?: readonly SessionOperation[];
669
+ };
670
+ /** Sync groups this agent may subscribe to — typed (`'default'` or
671
+ * `<namespace>:<id>`). Omit for the server default (org anchor + the
672
+ * agent's own anchor). */
673
+ syncGroups?: readonly SyncGroupInput[];
674
+ /** Token lifetime in seconds. Defaults to 900 (15m); the returned client
675
+ * auto-re-mints before expiry, so a long-running agent never handles
676
+ * rotation itself. */
677
+ ttlSeconds?: number;
678
+ /** Extra opaque identity blob echoed on the session scope. Merged with
679
+ * `name` (the `name` param wins if you also set `userMeta.name`). */
680
+ userMeta?: Record<string, unknown>;
681
+ }
649
682
  /** A minted session token — the Stripe ephemeral-key / Supabase session
650
683
  * resource. `token` is the secret the holder presents as its bearer. */
651
684
  export interface AbloSession {
@@ -766,6 +799,35 @@ export type Ablo<S extends SchemaRecord> = {
766
799
  sessions: {
767
800
  create(params: CreateSessionParams<S>): Promise<AbloSession>;
768
801
  };
802
+ /**
803
+ * Mint a scoped **agent identity** and return a ready-to-use client bound to
804
+ * it — the `ablo.<resource>.<verb>` shape for the agent use case. One call
805
+ * replaces `sessions.create({ agent })` + constructing a second
806
+ * `Ablo({ apiKey: token })`:
807
+ *
808
+ * ```ts
809
+ * const agent = await ablo.agents.create({
810
+ * name: 'researcher', // readable label (optional)
811
+ * can: { documents: ['read', 'update'] },
812
+ * // id omitted → a fresh uuid: a distinct, independent participant
813
+ * });
814
+ * await agent.documents.update({ id, data, claim });
815
+ * await agent.dispose(); // when the agent is done
816
+ * ```
817
+ *
818
+ * Server-side only (requires the `sk_` secret key, like `sessions.create`);
819
+ * throws `AbloAuthenticationError` in the browser. The returned client holds
820
+ * its own auto-refreshing `rk_`, so a long run never hits token expiry, and
821
+ * the `sk_` never leaves this process. Each call is a DISTINCT participant by
822
+ * default (omit `id` → fresh uuid), so even two agents sharing a `name` queue
823
+ * behind one another on a contended row — `name` is display only and never
824
+ * collapses identity. Humans don't get a server-built client — ship them a
825
+ * token via `sessions.create({ user })`. Need the raw token for revocation,
826
+ * or a stable re-attachable id? Use `sessions.create({ agent })` / pass `id`.
827
+ */
828
+ agents: {
829
+ create(params: CreateAgentClientParams<S>): Promise<Ablo<S>>;
830
+ };
769
831
  /**
770
832
  * The organization this client resolved to — `null` until `ready()`
771
833
  * completes. Use it instead of scraping CLI output or hardcoding env vars:
@@ -1007,7 +1069,7 @@ export declare namespace Ablo {
1007
1069
  type RotatedCapability = import('./ApiClient.js').RotatedCapability;
1008
1070
  type IfClaimedPolicy = import('./Ablo.js').IfClaimedPolicy;
1009
1071
  type ClaimedOptions = import('./Ablo.js').ClaimedOptions;
1010
- type EntityRef = _Streams.EntityRef;
1072
+ type ClaimTarget = _Streams.ClaimTarget;
1011
1073
  type PresenceTarget = _Streams.PresenceTarget;
1012
1074
  type TargetRange = _Streams.TargetRange;
1013
1075
  type Duration = _Streams.Duration;
@@ -1015,8 +1077,7 @@ export declare namespace Ablo {
1015
1077
  type ClaimStream = _Streams.ClaimStream;
1016
1078
  type Peer = _Streams.Peer;
1017
1079
  type Activity = _Streams.Activity;
1018
- type ActiveClaim = _Streams.ActiveClaim;
1019
- type ClaimHandle = _Streams.ClaimHandle;
1080
+ type Claim = _Streams.Claim;
1020
1081
  type ClaimRejection = _Streams.ClaimRejection;
1021
1082
  type ClaimLost = _Streams.ClaimLost;
1022
1083
  type Snapshot<TSchema extends _SchemaTypes.Schema = _SchemaTypes.Schema, K extends keyof TSchema['models'] = keyof TSchema['models']> = _Streams.Snapshot<TSchema, K>;
@@ -1053,7 +1114,7 @@ export declare namespace Ablo {
1053
1114
  type Client = import('./Ablo.js').CommitResource;
1054
1115
  }
1055
1116
  namespace Claim {
1056
- type Handle = import('./Ablo.js').ClaimHandle;
1117
+ type Handle = import('./Ablo.js').Claim;
1057
1118
  type CreateOptions = import('./Ablo.js').ClaimCreateOptions;
1058
1119
  type WaitOptions = import('./Ablo.js').ClaimWaitOptions;
1059
1120
  type Client = import('./Ablo.js').ClaimResource;
@@ -42,7 +42,7 @@ import { createProtocolClient, } from './ApiClient.js';
42
42
  // Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
43
43
  // which imports this module type-only.
44
44
  import { createAbloHttpClient, } from './httpClient.js';
45
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
45
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
46
46
  import { registerDataSource } from './registerDataSource.js';
47
47
  import { shouldUseInMemoryPersistence, } from './persistence.js';
48
48
  import { createModelProxy } from './createModelProxy.js';
@@ -240,8 +240,19 @@ function registerModelsFromSchema(schema, registry) {
240
240
  jsonSubFields.push({ fieldName, subSchema: inner });
241
241
  }
242
242
  }
243
- // Create a dynamic Model subclass with JSON sub-property getters
244
- const isLazy = modelDef.lazyObservable === true;
243
+ // Create a dynamic Model subclass with JSON sub-property getters.
244
+ //
245
+ // Field-level MobX observability is ON BY DEFAULT. A reactive read like
246
+ // `useAblo((a) => a.documents.get(id))` must re-render when a remote delta
247
+ // mutates the row IN PLACE (the common collaborative case); without
248
+ // per-field observability that update fires no reaction and the UI silently
249
+ // goes stale. Models opt OUT with an explicit `lazyObservable: false` —
250
+ // appropriate only for very large read-only list models where per-field
251
+ // atoms cost more than the QueryView's entry-replaced reactivity already
252
+ // provides. json fields register as `observable.ref` (see
253
+ // `registerModelsFromSchema`), so the default is ~one atom per scalar field
254
+ // per loaded row — cheap — not a deep atom tree per blob.
255
+ const isLazy = modelDef.lazyObservable !== false;
245
256
  // Base provenance fields (`organizationId`, `createdBy`) live in
246
257
  // `baseFieldsSchema`, not the per-model `shape`. The server stamps + emits
247
258
  // them (camelCased on the wire), but hydration (`Model.assignFieldsFromData`)
@@ -746,6 +757,8 @@ export function Ablo(options) {
746
757
  // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
747
758
  // passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
748
759
  warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
760
+ warnIfDatabaseUrlDeprecated(authInput, (m) => logger.warn(m));
761
+ void warnIfCliKeyMismatch(authInput, (m) => logger.warn(m));
749
762
  const schema = options.schema;
750
763
  const url = resolveBaseURL(authInput);
751
764
  // 1. Derive config from schema
@@ -894,6 +907,19 @@ export function Ablo(options) {
894
907
  store.syncStatus.state = 'error';
895
908
  store.syncStatus.error = _validationError;
896
909
  }
910
+ // Deprecated identity overrides are a silent no-op under hosted cloud: when an
911
+ // `apiKey` is configured the SERVER derives participant kind + id from the
912
+ // key's scope, so `kind` / `agentId` passed here are ignored. Setting them and
913
+ // trusting them is the trap (you think you're an agent; the key says user).
914
+ // Warn loudly rather than removing the fields — `agentId` is still load-bearing
915
+ // on the self-hosted path (no apiKey; paired with `capabilityToken`).
916
+ if (configuredApiKey && (internalOptions.kind || internalOptions.agentId)) {
917
+ logger.warn('Ablo: `kind` / `agentId` are ignored when an `apiKey` is configured — ' +
918
+ 'the server derives participant identity from the key’s scope. Remove ' +
919
+ 'them (or mint a scoped session via `ablo.sessions.create({ agent })` ' +
920
+ 'for a distinct agent identity). They apply only to the self-hosted ' +
921
+ '`capabilityToken` path.');
922
+ }
897
923
  // 7. The ready() promise drives the BaseSyncedStore.initialize() generator
898
924
  // to completion. First call kicks off the initialization; subsequent
899
925
  // calls return the same promise (idempotent).
@@ -1117,7 +1143,7 @@ export function Ablo(options) {
1117
1143
  return (typeof value === 'object' &&
1118
1144
  value !== null &&
1119
1145
  value.object === 'claim' &&
1120
- typeof value.claimId === 'string');
1146
+ typeof value.id === 'string');
1121
1147
  }
1122
1148
  function normalizeCommitOperation(op, defaults) {
1123
1149
  const model = op.model ?? op.target?.model;
@@ -1152,13 +1178,13 @@ export function Ablo(options) {
1152
1178
  const description = descriptionFromMeta(claim.target.meta);
1153
1179
  return {
1154
1180
  id: claim.id,
1155
- actor: claim.heldBy,
1156
- participantKind: claim.participantKind,
1181
+ actor: claim.heldBy ?? "",
1182
+ participantKind: claim.participantKind ?? "user",
1157
1183
  reason: claim.reason,
1158
1184
  ...(description ? { description } : {}),
1159
1185
  field: claim.target.field,
1160
1186
  status: 'active',
1161
- expiresAt: claim.expiresAt,
1187
+ expiresAt: claim.expiresAt ?? 0,
1162
1188
  target: {
1163
1189
  model: claim.target.type,
1164
1190
  id: claim.target.id,
@@ -1172,14 +1198,14 @@ export function Ablo(options) {
1172
1198
  function modelClaimFromQueued(claim) {
1173
1199
  return {
1174
1200
  id: claim.id,
1175
- actor: claim.heldBy,
1176
- participantKind: claim.participantKind,
1201
+ actor: claim.heldBy ?? "",
1202
+ participantKind: claim.participantKind ?? "user",
1177
1203
  reason: claim.reason,
1178
1204
  ...(claim.description ? { description: claim.description } : {}),
1179
1205
  field: claim.target.field,
1180
1206
  status: 'queued',
1181
1207
  position: claim.position,
1182
- expiresAt: claim.expiresAt,
1208
+ expiresAt: claim.expiresAt ?? 0,
1183
1209
  target: {
1184
1210
  model: claim.target.type,
1185
1211
  id: claim.target.id,
@@ -1271,11 +1297,11 @@ export function Ablo(options) {
1271
1297
  }
1272
1298
  function wrapClaimHandle(claim, waited = false) {
1273
1299
  const release = async () => {
1274
- claim.revoke();
1300
+ claim.revoke?.();
1275
1301
  };
1276
1302
  return {
1277
1303
  object: 'claim',
1278
- claimId: claim.claimId,
1304
+ id: claim.id,
1279
1305
  reason: claim.reason,
1280
1306
  target: claim.target,
1281
1307
  waited,
@@ -1309,7 +1335,7 @@ export function Ablo(options) {
1309
1335
  const ws = store.getSyncWebSocket();
1310
1336
  if (ws) {
1311
1337
  try {
1312
- ({ waited } = await awaitClaimGrant(ws, claim.claimId, {
1338
+ ({ waited } = await awaitClaimGrant(ws, claim.id, {
1313
1339
  timeoutMs: claimOptions.waitTimeoutMs,
1314
1340
  maxQueueDepth: claimOptions.maxQueueDepth,
1315
1341
  }));
@@ -1318,7 +1344,7 @@ export function Ablo(options) {
1318
1344
  // Gave up waiting (queue too deep, timed out, or lost) — abandon
1319
1345
  // the queued claim so we don't leave a phantom entry in the
1320
1346
  // line that would block or mislead other claimers.
1321
- claim.revoke();
1347
+ claim.revoke?.();
1322
1348
  throw err;
1323
1349
  }
1324
1350
  }
@@ -1430,7 +1456,7 @@ export function Ablo(options) {
1430
1456
  onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
1431
1457
  });
1432
1458
  const wait = commitOptions.wait ?? 'confirmed';
1433
- const claimId = normalizeClaimId(commitOptions.claimRef) ?? claim?.claimId;
1459
+ const claimId = normalizeClaimId(commitOptions.claimRef) ?? claim?.id;
1434
1460
  void claimId; // The current wire clears claims by entity after commit.
1435
1461
  // Route through the TransactionQueue's commit lane so the call
1436
1462
  // tolerates WS disconnects: the envelope stays in memory until
@@ -1579,6 +1605,34 @@ export function Ablo(options) {
1579
1605
  async function controlPlaneApiKey() {
1580
1606
  return resolveApiKeyValue(configuredApiKey);
1581
1607
  }
1608
+ /**
1609
+ * Resolve the control-plane context a session/agent mint needs (sk_ +
1610
+ * bootstrap base URL + the schema-key→typename map the Hub gates on).
1611
+ * Shared by `sessions.create` and `agents.create` so the two mint doors
1612
+ * can never drift on how a token is minted. Throws if no `sk_` is present —
1613
+ * minting is a backend-only operation.
1614
+ */
1615
+ async function buildMintContext(resource) {
1616
+ const apiKey = await controlPlaneApiKey();
1617
+ if (!apiKey) {
1618
+ throw new AbloAuthenticationError(`${resource} requires a secret (sk_) API key — call it from your backend, not the browser.`, { code: 'apikey_missing' });
1619
+ }
1620
+ return {
1621
+ apiKey,
1622
+ baseUrl: resolveBootstrapBaseUrl({
1623
+ url,
1624
+ bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
1625
+ }),
1626
+ ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1627
+ // Map every `can` schema-key to the wire typename the Hub gates on, so a
1628
+ // typename override (`documents` → `Document`) doesn't mint a capability
1629
+ // the server then denies. See `MintSessionContext`.
1630
+ modelTypenames: Object.fromEntries(Object.entries(schema.models).map(([key, def]) => [
1631
+ key,
1632
+ def.typename ?? key,
1633
+ ])),
1634
+ };
1635
+ }
1582
1636
  const engine = {
1583
1637
  ...modelProxies,
1584
1638
  ready,
@@ -1631,23 +1685,53 @@ export function Ablo(options) {
1631
1685
  // agent credential silently replacing the secret key on control-plane
1632
1686
  // calls is how humans get minted as agents — attribution is the product.
1633
1687
  async create(params) {
1634
- const apiKey = await controlPlaneApiKey();
1635
- if (!apiKey) {
1636
- throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
1637
- }
1638
- const baseUrl = resolveBootstrapBaseUrl({
1639
- url,
1640
- bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
1641
- });
1642
- // The two mint doors (`{ user }` /auth/ephemeral-keys `ek_`,
1643
- // `{ agent, can }` /auth/capability scoped `rk_`) live in the shared
1644
- // `mintSession` so this stateful client and the stateless HTTP client can
1645
- // never drift on how a token is minted.
1646
- return mintSession(params, {
1647
- apiKey,
1648
- baseUrl,
1649
- ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1650
- });
1688
+ // Both mint doors (`{ user }` → /auth/ephemeral-keys → `ek_`,
1689
+ // `{ agent, can }` → /auth/capability → scoped `rk_`) resolve their
1690
+ // control-plane context through the shared `buildMintContext`, so this
1691
+ // client, `agents.create`, and the stateless HTTP client can never drift
1692
+ // on how a token is minted.
1693
+ return mintSession(params, await buildMintContext('sessions.create'));
1694
+ },
1695
+ },
1696
+ // Mint a scoped agent IDENTITY and hand back a connected client bound to it
1697
+ // `sessions.create({ agent })` + `Ablo({ apiKey })` fused into one call,
1698
+ // for agents that run in THIS (sk_-holding) process. Omitting `id` yields a
1699
+ // fresh uuid per call, so concurrent agents are distinct participants that
1700
+ // queue behind each other (even when they share a `name`). Humans don't get
1701
+ // a server-built client — ship them a token via `sessions.create({ user })`.
1702
+ agents: {
1703
+ async create(params) {
1704
+ // Distinct participant by default: omit `id` → a fresh uuid, so even two
1705
+ // agents that share a `name` are INDEPENDENT participants and queue
1706
+ // behind one another. `name` is display only (→ userMeta.name); it never
1707
+ // derives the id. Pass an explicit `id` only to re-attach an agent to
1708
+ // its own held claims.
1709
+ const id = params.id ?? globalThis.crypto.randomUUID();
1710
+ const userMeta = params.name !== undefined ? { ...params.userMeta, name: params.name } : params.userMeta;
1711
+ const sessionParams = {
1712
+ agent: { id },
1713
+ can: params.can,
1714
+ ...(params.syncGroups ? { syncGroups: params.syncGroups } : {}),
1715
+ ...(params.ttlSeconds !== undefined ? { ttlSeconds: params.ttlSeconds } : {}),
1716
+ ...(userMeta ? { userMeta } : {}),
1717
+ };
1718
+ // Re-mint the `rk_` on every resolver call so a long-lived agent client
1719
+ // never hits token expiry; the `sk_` stays in THIS process — the child
1720
+ // only ever sees its own short-lived `rk_`.
1721
+ const mintToken = async () => (await mintSession(sessionParams, await buildMintContext('agents.create')))
1722
+ .token;
1723
+ // Mint once up front so a bad key / denied scope throws HERE, not later
1724
+ // inside the child's bootstrap; reuse that first token, re-mint on refresh.
1725
+ let pending = await mintToken();
1726
+ const apiKey = async () => {
1727
+ if (pending !== null) {
1728
+ const token = pending;
1729
+ pending = null;
1730
+ return token;
1731
+ }
1732
+ return mintToken();
1733
+ };
1734
+ return Ablo({ ...internalOptions, apiKey });
1651
1735
  },
1652
1736
  },
1653
1737
  async dispose() {
@@ -7,14 +7,14 @@
7
7
  */
8
8
  import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions, ModelClient, ModelClaim, ModelTarget, CreateSessionParams, AbloSession } from './Ablo.js';
9
9
  import type { SchemaRecord } from '../schema/schema.js';
10
- import type { ClaimHandle } from './createModelProxy.js';
11
10
  import type { Duration } from '../utils/duration.js';
11
+ import type { Claim } from '../types/streams.js';
12
12
  export type AbloApiClientOptions = Omit<AbloOptions, 'schema'> & {
13
13
  readonly schema?: null | undefined;
14
14
  readonly bootstrapBaseUrl?: string | undefined;
15
15
  };
16
16
  export interface AbloApiClaims {
17
- create(options: ClaimCreateOptions): Promise<ClaimHandle>;
17
+ create(options: ClaimCreateOptions): Promise<Claim>;
18
18
  list(target?: Partial<ModelTarget>): Promise<readonly ModelClaim[]>;
19
19
  waitFor(target: Partial<ModelTarget>, options?: ClaimWaitOptions): Promise<void>;
20
20
  }
@@ -6,23 +6,11 @@
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
8
  import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
9
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
9
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
10
10
  import { registerDataSource } from './registerDataSource.js';
11
11
  import { toSeconds } from '../utils/duration.js';
12
12
  import { mintSession } from './sessionMint.js';
13
13
  import { assertWriteOptions } from './writeOptionsSchema.js';
14
- /**
15
- * The `/v1/claims` and model-query routes still emit the wire field `action`
16
- * for the claim phase; the public `Claim` / `ModelClaim` expose it as `reason`.
17
- * Heal on read so the SDK shape is consistent without a coordinated server
18
- * deploy — `reason ?? action`. When the server adopts `reason`, this is a no-op.
19
- */
20
- function healClaimPhase(claim) {
21
- const raw = claim;
22
- if (raw.reason !== undefined)
23
- return claim;
24
- return { ...claim, reason: raw.action ?? 'editing' };
25
- }
26
14
  const DEFAULT_AGENT_LEASE = '10m';
27
15
  export function createProtocolClient(options) {
28
16
  const env = readProcessEnv();
@@ -33,6 +21,8 @@ export function createProtocolClient(options) {
33
21
  // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
34
22
  // passed — no logger on this path, so the helper falls back to console.warn.
35
23
  warnIfDatabaseUrlEnvIgnored(authInput);
24
+ warnIfDatabaseUrlDeprecated(authInput);
25
+ void warnIfCliKeyMismatch(authInput);
36
26
  assertBrowserSafety({
37
27
  apiKey: configuredApiKey,
38
28
  databaseUrl: configuredDatabaseUrl,
@@ -185,8 +175,8 @@ export function createProtocolClient(options) {
185
175
  const suffix = params.toString();
186
176
  const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
187
177
  return {
188
- active: (body.claims ?? []).map(healClaimPhase),
189
- queue: (body.queue ?? []).map(healClaimPhase),
178
+ active: body.claims ?? [],
179
+ queue: body.queue ?? [],
190
180
  };
191
181
  }
192
182
  function delay(ms, signal) {
@@ -272,7 +262,7 @@ export function createProtocolClient(options) {
272
262
  body: JSON.stringify({
273
263
  clientTxId,
274
264
  idempotencyKey: clientTxId,
275
- claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.claimId,
265
+ claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
276
266
  operations,
277
267
  }),
278
268
  });
@@ -389,8 +379,7 @@ export function createProtocolClient(options) {
389
379
  body: JSON.stringify({
390
380
  claimId,
391
381
  target: claimOptions.target,
392
- // Wire field stays `action`; public option is `reason`.
393
- action: claimOptions.reason,
382
+ reason: claimOptions.reason,
394
383
  ttl: claimOptions.ttl,
395
384
  queue: claimOptions.queue,
396
385
  }),
@@ -415,9 +404,16 @@ export function createProtocolClient(options) {
415
404
  };
416
405
  return {
417
406
  object: 'claim',
418
- claimId: id,
407
+ id,
419
408
  reason: claimOptions.reason,
420
- target: claimOptions.target,
409
+ target: {
410
+ type: claimOptions.target.model,
411
+ id: claimOptions.target.id,
412
+ ...(claimOptions.target.field ? { field: claimOptions.target.field } : {}),
413
+ ...(claimOptions.target.path ? { path: claimOptions.target.path } : {}),
414
+ ...(claimOptions.target.range ? { range: claimOptions.target.range } : {}),
415
+ ...(claimOptions.target.meta ? { meta: claimOptions.target.meta } : {}),
416
+ },
421
417
  release,
422
418
  revoke: () => {
423
419
  void release().catch(() => { });
@@ -467,7 +463,7 @@ export function createProtocolClient(options) {
467
463
  return {
468
464
  data,
469
465
  stamp: query.stamp ?? 0,
470
- claims: (query.claims ?? []).map(healClaimPhase),
466
+ claims: query.claims ?? [],
471
467
  };
472
468
  }
473
469
  /**
@@ -501,13 +497,13 @@ export function createProtocolClient(options) {
501
497
  const claimHandle = typeof options?.claim === 'object' &&
502
498
  options?.claim !== null &&
503
499
  options.claim.object === 'claim' &&
504
- typeof options.claim.claimId === 'string'
500
+ typeof options.claim.id === 'string'
505
501
  ? options.claim
506
502
  : undefined;
507
503
  const readAt = options?.readAt ?? claimHandle?.readAt;
508
504
  const requestBody = {
509
505
  idempotencyKey: clientTxId,
510
- claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.claimId,
506
+ claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
511
507
  onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
512
508
  readAt,
513
509
  };
@@ -538,7 +534,7 @@ export function createProtocolClient(options) {
538
534
  const isClaimHandle = (value) => typeof value === 'object' &&
539
535
  value !== null &&
540
536
  value.object === 'claim' &&
541
- typeof value.claimId === 'string' &&
537
+ typeof value.id === 'string' &&
542
538
  typeof value.release === 'function';
543
539
  const claimMeta = (options) => {
544
540
  if (!options?.description)
@@ -549,8 +545,7 @@ export function createProtocolClient(options) {
549
545
  const body = await requestJson(claimPath(params.id), {
550
546
  method: 'POST',
551
547
  body: JSON.stringify({
552
- // Wire field stays `action`; public option is `reason`.
553
- action: params.reason ?? 'editing',
548
+ reason: params.reason ?? 'editing',
554
549
  ...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
555
550
  ...(params.description !== undefined ? { description: params.description } : {}),
556
551
  ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
@@ -563,7 +558,7 @@ export function createProtocolClient(options) {
563
558
  throw new AbloClaimedError(`Target ${name}/${params.id} is held; queued at position ${body.position ?? 0}. ` +
564
559
  `The HTTP client cannot await the grant without a WebSocket.`, { code: 'claim_queued' });
565
560
  }
566
- return body.claim?.id ?? body.id ?? body.claimId ?? createClaimId();
561
+ return body.claim?.id ?? body.id ?? body.id ?? createClaimId();
567
562
  };
568
563
  const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
569
564
  async function claimImpl(params) {
@@ -572,10 +567,10 @@ export function createProtocolClient(options) {
572
567
  const release = () => releaseClaim(params);
573
568
  return {
574
569
  object: 'claim',
575
- claimId,
570
+ id: claimId,
576
571
  readAt: stamp,
577
572
  target: {
578
- model: name,
573
+ type: name,
579
574
  id: params.id,
580
575
  ...(params.field ? { field: params.field } : {}),
581
576
  ...(params.path ? { path: params.path } : {}),
@@ -598,11 +593,11 @@ export function createProtocolClient(options) {
598
593
  state: async (params) => {
599
594
  const res = await claimsForEntity(params);
600
595
  const first = res.claims?.[0];
601
- return first ? healClaimPhase(first) : null;
596
+ return first ?? null;
602
597
  },
603
598
  queue: async (params) => {
604
599
  const res = await claimsForEntity(params);
605
- return { object: 'list', data: (res.queue ?? []).map(healClaimPhase) };
600
+ return { object: 'list', data: res.queue ?? [] };
606
601
  },
607
602
  reorder: async (params) => {
608
603
  await requestJson(`${claimPath(params.id)}/reorder`, {
@@ -618,7 +613,7 @@ export function createProtocolClient(options) {
618
613
  if (!claimInput)
619
614
  return run(input);
620
615
  if (isClaimHandle(claimInput)) {
621
- return run({ ...input, claimRef: { id: claimInput.claimId }, claim: undefined });
616
+ return run({ ...input, claimRef: { id: claimInput.id }, claim: undefined });
622
617
  }
623
618
  const claimId = await acquireClaim({ id, ...claimInput });
624
619
  try {