@abloatai/ablo 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/ObjectPool.d.ts +14 -1
  3. package/dist/ObjectPool.js +8 -1
  4. package/dist/SyncClient.js +7 -1
  5. package/dist/SyncEngineContext.js +2 -0
  6. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  7. package/dist/ai-sdk/coordination-context.js +3 -3
  8. package/dist/ai-sdk/index.d.ts +3 -4
  9. package/dist/ai-sdk/index.js +2 -3
  10. package/dist/ai-sdk/wrap.d.ts +13 -18
  11. package/dist/ai-sdk/wrap.js +2 -6
  12. package/dist/cli.cjs +253 -160
  13. package/dist/client/Ablo.d.ts +83 -22
  14. package/dist/client/Ablo.js +103 -30
  15. package/dist/client/ApiClient.d.ts +2 -2
  16. package/dist/client/ApiClient.js +27 -32
  17. package/dist/client/auth.d.ts +26 -0
  18. package/dist/client/auth.js +208 -0
  19. package/dist/client/createModelProxy.d.ts +16 -11
  20. package/dist/client/createModelProxy.js +15 -15
  21. package/dist/client/sessionMint.d.ts +10 -0
  22. package/dist/client/sessionMint.js +8 -1
  23. package/dist/coordination/schema.d.ts +10 -10
  24. package/dist/coordination/schema.js +7 -8
  25. package/dist/coordination/trace.d.ts +91 -0
  26. package/dist/coordination/trace.js +147 -0
  27. package/dist/errorCodes.d.ts +2 -0
  28. package/dist/errorCodes.js +2 -0
  29. package/dist/errors.d.ts +1 -2
  30. package/dist/errors.js +6 -9
  31. package/dist/index.d.ts +5 -1
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +45 -2
  34. package/dist/policy/types.d.ts +18 -9
  35. package/dist/policy/types.js +26 -16
  36. package/dist/react/AbloProvider.d.ts +2 -2
  37. package/dist/schema/ddl.d.ts +36 -0
  38. package/dist/schema/ddl.js +66 -0
  39. package/dist/schema/index.d.ts +1 -1
  40. package/dist/schema/index.js +1 -1
  41. package/dist/sync/HydrationCoordinator.js +7 -3
  42. package/dist/sync/SyncWebSocket.d.ts +11 -2
  43. package/dist/sync/SyncWebSocket.js +67 -3
  44. package/dist/sync/createClaimStream.d.ts +9 -2
  45. package/dist/sync/createClaimStream.js +9 -7
  46. package/dist/sync/participants.d.ts +12 -11
  47. package/dist/sync/participants.js +5 -5
  48. package/dist/transactions/TransactionQueue.d.ts +1 -0
  49. package/dist/transactions/TransactionQueue.js +40 -3
  50. package/dist/types/streams.d.ts +57 -135
  51. package/docs/coordination.md +16 -0
  52. package/docs/debugging.md +194 -0
  53. package/docs/index.md +1 -0
  54. package/package.json +1 -1
  55. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  56. package/dist/ai-sdk/claim-broadcast.js +0 -79
@@ -28,6 +28,160 @@ export function resolveApiKey(input) {
28
28
  export function resolveAuthToken(input) {
29
29
  return input.options.authToken ?? null;
30
30
  }
31
+ function keyPrefix(key) {
32
+ return `${key.slice(0, 12)}…`;
33
+ }
34
+ /** Infer sandbox/production from Ablo key prefixes without importing CLI code. */
35
+ export function modeFromApiKey(key) {
36
+ if (/^(sk|rk)_test_/.test(key))
37
+ return 'sandbox';
38
+ if (/^(sk|rk)_live_/.test(key))
39
+ return 'production';
40
+ return undefined;
41
+ }
42
+ function resolveStaticApiKey(input) {
43
+ if (typeof input.options.apiKey === 'string') {
44
+ return { key: input.options.apiKey, source: 'option' };
45
+ }
46
+ if (input.options.apiKey !== undefined && input.options.apiKey !== null) {
47
+ return null;
48
+ }
49
+ const envKey = input.env.ABLO_API_KEY;
50
+ if (typeof envKey === 'string' && envKey.length > 0) {
51
+ return { key: envKey, source: 'env' };
52
+ }
53
+ return null;
54
+ }
55
+ function readProfileKeys(value) {
56
+ if (!value || typeof value !== 'object')
57
+ return {};
58
+ const profiles = {};
59
+ for (const [name, raw] of Object.entries(value)) {
60
+ if (!raw || typeof raw !== 'object')
61
+ continue;
62
+ const row = raw;
63
+ const sandbox = row.sandbox;
64
+ const production = row.production;
65
+ profiles[name] = {
66
+ sandbox: sandbox && typeof sandbox === 'object' ? sandbox : undefined,
67
+ production: production && typeof production === 'object' ? production : undefined,
68
+ };
69
+ }
70
+ return profiles;
71
+ }
72
+ function legacyProfileKeys(value) {
73
+ if (!value)
74
+ return { sandbox: undefined, production: undefined };
75
+ const sandbox = value.sandbox;
76
+ const production = value.production;
77
+ if ((sandbox && typeof sandbox === 'object') ||
78
+ (production && typeof production === 'object')) {
79
+ return {
80
+ sandbox: sandbox && typeof sandbox === 'object' ? sandbox : undefined,
81
+ production: production && typeof production === 'object' ? production : undefined,
82
+ };
83
+ }
84
+ if (typeof value.apiKey === 'string') {
85
+ return { sandbox: { apiKey: value.apiKey }, production: undefined };
86
+ }
87
+ return { sandbox: undefined, production: undefined };
88
+ }
89
+ function normalizeCliMode(value) {
90
+ return value === 'sandbox' || value === 'production' ? value : undefined;
91
+ }
92
+ function activeProjectSlug(value) {
93
+ if (!value || typeof value !== 'object')
94
+ return undefined;
95
+ const slug = value.slug;
96
+ return typeof slug === 'string' && slug.length > 0 ? slug : undefined;
97
+ }
98
+ function importNodeBuiltin(specifier) {
99
+ return import(specifier);
100
+ }
101
+ async function readJsonIfPresent(path) {
102
+ try {
103
+ const { readFile } = await importNodeBuiltin('node:fs/promises');
104
+ const text = await readFile(path, 'utf8');
105
+ const parsed = JSON.parse(text);
106
+ return parsed && typeof parsed === 'object' ? parsed : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ async function readCliCredentialSnapshot(env) {
113
+ const processLike = globalThis.process;
114
+ if (!processLike?.versions?.node)
115
+ return null;
116
+ if (typeof window !== 'undefined')
117
+ return null;
118
+ const [{ homedir }, { join }] = await Promise.all([
119
+ importNodeBuiltin('node:os'),
120
+ importNodeBuiltin('node:path'),
121
+ ]);
122
+ const dir = env.ABLO_CONFIG_DIR
123
+ ?? (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, 'ablo') : join(homedir(), '.config', 'ablo'));
124
+ const [cfg, creds] = await Promise.all([
125
+ readJsonIfPresent(join(dir, 'config.json')),
126
+ readJsonIfPresent(join(dir, 'credentials.json')),
127
+ ]);
128
+ const mode = normalizeCliMode(cfg?.mode) ?? normalizeCliMode(creds?.mode);
129
+ const activeProfile = activeProjectSlug(cfg?.activeProject) ?? 'default';
130
+ const profiles = {
131
+ ...readProfileKeys(creds?.profiles),
132
+ ...readProfileKeys(cfg?.profiles),
133
+ };
134
+ if (!profiles[activeProfile]) {
135
+ const legacy = { ...legacyProfileKeys(cfg), ...legacyProfileKeys(creds) };
136
+ if (legacy.sandbox?.apiKey || legacy.production?.apiKey) {
137
+ profiles[activeProfile] = legacy;
138
+ }
139
+ }
140
+ const effectiveMode = mode ?? (Object.keys(profiles).length > 0 ? 'sandbox' : undefined);
141
+ if (!effectiveMode)
142
+ return null;
143
+ return {
144
+ mode: effectiveMode,
145
+ activeProfile,
146
+ ...(profiles[activeProfile]?.[effectiveMode]?.apiKey
147
+ ? { storedKey: profiles[activeProfile][effectiveMode].apiKey }
148
+ : {}),
149
+ };
150
+ }
151
+ export function describeCliKeyMismatch(configured, cli) {
152
+ const configuredMode = modeFromApiKey(configured.key);
153
+ const configuredKeyPrefix = keyPrefix(configured.key);
154
+ const storedKeyPrefix = cli.storedKey ? keyPrefix(cli.storedKey) : undefined;
155
+ const sourceLabel = configured.source === 'env' ? 'ABLO_API_KEY' : 'configured apiKey';
156
+ if (configuredMode && configuredMode !== cli.mode) {
157
+ return {
158
+ source: configured.source,
159
+ configuredKeyPrefix,
160
+ configuredMode,
161
+ cliMode: cli.mode,
162
+ ...(storedKeyPrefix ? { storedKeyPrefix } : {}),
163
+ kind: 'mode_mismatch',
164
+ message: `${sourceLabel} is a ${configuredMode} key (${configuredKeyPrefix}) but the Ablo CLI is in ` +
165
+ `${cli.mode} mode${storedKeyPrefix ? ` (active stored key ${storedKeyPrefix})` : ''}. ` +
166
+ `Requests will use ${configuredMode}. Use the ${cli.mode} key, unset ABLO_API_KEY, ` +
167
+ `or run \`ablo mode ${configuredMode}\` intentionally.`,
168
+ };
169
+ }
170
+ if (configured.source === 'env' && cli.storedKey && configured.key !== cli.storedKey) {
171
+ return {
172
+ source: configured.source,
173
+ configuredKeyPrefix,
174
+ ...(configuredMode ? { configuredMode } : {}),
175
+ cliMode: cli.mode,
176
+ storedKeyPrefix,
177
+ kind: 'key_override',
178
+ message: `ABLO_API_KEY (${configuredKeyPrefix}) overrides the CLI's stored active ${cli.mode} key ` +
179
+ `(${storedKeyPrefix}). Requests will use the environment key. Unset ABLO_API_KEY to use ` +
180
+ '`ablo status` / `ablo mode` credentials.',
181
+ };
182
+ }
183
+ return null;
184
+ }
31
185
  /**
32
186
  * Resolve the direct-URL connector's Postgres connection string.
33
187
  *
@@ -85,6 +239,60 @@ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
85
239
  else if (typeof console !== 'undefined')
86
240
  console.warn('[Ablo]', message);
87
241
  }
242
+ /**
243
+ * One-time deprecation nudge for the `databaseUrl` direct connector.
244
+ *
245
+ * `databaseUrl` registers the `dedicated` storage mode — Ablo opens a pool INTO
246
+ * the caller's Postgres and writes into it directly. That is the operate-their-
247
+ * database posture we are moving off. Ablo is Stripe-shaped: it hosts only the
248
+ * transaction log (the ordered sync_deltas) + coordination, never your data — your
249
+ * rows always live in your own database. The supported path is the signed Data
250
+ * Source endpoint (`dataSource(...)`), where your app owns the write and your
251
+ * credentials never leave it. See docs/plans/stripe-shaped-storage-posture.md.
252
+ *
253
+ * Still honored at runtime so existing integrations keep working; this only warns
254
+ * once per process (so it never spams) and falls back to `console.warn` when no
255
+ * logger is supplied (the `transport: 'http'`/`'api'` client has none).
256
+ */
257
+ let warnedDatabaseUrlDeprecated = false;
258
+ export function warnIfDatabaseUrlDeprecated(input, warn) {
259
+ if (warnedDatabaseUrlDeprecated)
260
+ return;
261
+ if (input.options.databaseUrl == null)
262
+ return;
263
+ warnedDatabaseUrlDeprecated = true;
264
+ const message = '`databaseUrl` (the direct connector) is deprecated and will be removed from ' +
265
+ 'the supported path. It lets Ablo dial into your database; we are moving off ' +
266
+ 'that. Ablo hosts only the transaction log — your data stays in your DB. Expose ' +
267
+ 'a signed Data Source endpoint (`dataSource(...)`) so your app owns the write, ' +
268
+ 'or self-host the engine to keep the log in your infra too. ' +
269
+ 'See docs/plans/stripe-shaped-storage-posture.md.';
270
+ if (warn)
271
+ warn(message);
272
+ else if (typeof console !== 'undefined')
273
+ console.warn('[Ablo]', message);
274
+ }
275
+ let warnedCliKeyMismatch = false;
276
+ export async function warnIfCliKeyMismatch(input, warn) {
277
+ if (warnedCliKeyMismatch)
278
+ return;
279
+ if (input.env.NODE_ENV === 'production')
280
+ return;
281
+ const configured = resolveStaticApiKey(input);
282
+ if (!configured)
283
+ return;
284
+ const cli = await readCliCredentialSnapshot(input.env);
285
+ if (!cli)
286
+ return;
287
+ const mismatch = describeCliKeyMismatch(configured, cli);
288
+ if (!mismatch)
289
+ return;
290
+ warnedCliKeyMismatch = true;
291
+ if (warn)
292
+ warn(mismatch.message);
293
+ else if (typeof console !== 'undefined')
294
+ console.warn('[Ablo]', mismatch.message);
295
+ }
88
296
  export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
89
297
  export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
90
298
  export const ABLO_DEFAULT_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
@@ -22,7 +22,7 @@ import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
22
22
  import type { JoinedParticipant } from '../sync/participants.js';
23
23
  import type { LoadWhere } from '../query/types.js';
24
24
  import { ModelScope } from '../types/index.js';
25
- import type { Duration, Claim, ClaimHandle, ClaimWaitOptions, Snapshot, TargetRange } from '../types/streams.js';
25
+ import type { Duration, Claim, ClaimWaitOptions, Snapshot, TargetRange } from '../types/streams.js';
26
26
  export interface ModelClientMeta {
27
27
  readonly key: string;
28
28
  readonly typename: string;
@@ -90,7 +90,7 @@ export interface ModelCollaboration<T> {
90
90
  range?: TargetRange;
91
91
  meta?: Record<string, unknown>;
92
92
  };
93
- /** Human-readable phase (`'editing'`); wire field is `action`. */
93
+ /** Human-readable phase (`'editing'`). */
94
94
  reason: string;
95
95
  ttl?: Duration;
96
96
  /**
@@ -101,7 +101,7 @@ export interface ModelCollaboration<T> {
101
101
  queue?: boolean;
102
102
  /** Reject (don't wait) if the queue is already this deep when we join. */
103
103
  maxQueueDepth?: number;
104
- }): Promise<ClaimHandle>;
104
+ }): Promise<Claim>;
105
105
  createSnapshot(modelKey: string, id: string): Snapshot;
106
106
  /**
107
107
  * Current coordination state on a target — who (if anyone) holds it.
@@ -185,7 +185,7 @@ export interface ModelCollaboration<T> {
185
185
  }
186
186
  export interface ClaimTargetOptions<T = Record<string, unknown>> {
187
187
  /** Human-readable phase shown to observers while held. Defaults to
188
- * `'editing'`. The same word on every claim surface; wire field is `action`. */
188
+ * `'editing'`. The same word on every claim surface. */
189
189
  reason?: string;
190
190
  /** Peer-visible explanation of the work being performed. */
191
191
  description?: string;
@@ -258,7 +258,7 @@ export interface ClaimReorderParams<T = Record<string, unknown>> extends ClaimLo
258
258
  * `ablo.<model>.update({ id, data, claim })` verb — the handle carries the
259
259
  * lease id and snapshot watermark for attribution + stale protection.
260
260
  */
261
- export type { ClaimHandle };
261
+ export type { Claim };
262
262
  export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
263
263
  /**
264
264
  * The coordination surface for a model, exposed as a callable namespace.
@@ -312,7 +312,7 @@ export interface ClaimReadApi<T = Record<string, unknown>> {
312
312
  */
313
313
  reorder(params: ClaimReorderParams<T>): void;
314
314
  /** Release a manual claim handle early. Single-write claims auto-release. */
315
- release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
315
+ release(params: ClaimLookupParams<T> | Claim<T>): Promise<void>;
316
316
  }
317
317
  /**
318
318
  * The awaited form of a claim method: a synchronous return becomes a `Promise`,
@@ -321,8 +321,13 @@ export interface ClaimReadApi<T = Record<string, unknown>> {
321
321
  */
322
322
  export type AwaitedClaimMethod<F> = F extends (...args: infer A) => infer R ? R extends Promise<unknown> ? (...args: A) => R : (...args: A) => Promise<R> : F;
323
323
  export interface ClaimApi<T> extends ClaimReadApi<T> {
324
- /** Take a claim and get an explicit held-work handle back. */
325
- (params: ClaimParams<T>): Promise<ClaimHandle<T>>;
324
+ /**
325
+ * Take a claim and get an explicit held-work handle back. Returns a
326
+ * {@link Claim} — `data` (and `readAt`) are guaranteed present
327
+ * because this door always re-reads the row under the lease, so callers use
328
+ * `handle.data` directly without a guard.
329
+ */
330
+ (params: ClaimParams<T>): Promise<Claim<T>>;
326
331
  }
327
332
  export interface ModelRetrieveParams extends ServerRetrieveOptions {
328
333
  readonly id: string;
@@ -330,16 +335,16 @@ export interface ModelRetrieveParams extends ServerRetrieveOptions {
330
335
  export interface ModelCreateParams<T, CreateInput> extends MutationOptions {
331
336
  readonly data: CreateInput;
332
337
  readonly id?: string | null;
333
- readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
338
+ readonly claim?: Claim<T> | ClaimTargetOptions<T> | null;
334
339
  }
335
340
  export interface ModelUpdateParams<T> extends MutationOptions {
336
341
  readonly id: string;
337
342
  readonly data: Partial<T>;
338
- readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
343
+ readonly claim?: Claim<T> | ClaimTargetOptions<T> | null;
339
344
  }
340
345
  export interface ModelDeleteParams<T> extends MutationOptions {
341
346
  readonly id: string;
342
- readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
347
+ readonly claim?: Claim<T> | ClaimTargetOptions<T> | null;
343
348
  }
344
349
  /** Options for the WebSocket-only `ablo.<model>.watch(ids, options?)`. */
345
350
  export interface WatchOptions {
@@ -39,7 +39,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
39
39
  // with the commit op's model name; a lease recorded under the schema key
40
40
  // never collides with it — which silently disarmed the guard for every
41
41
  // model whose schema key differs from its typename (i.e. nearly all of
42
- // them, plural key vs singular typename). Public surfaces (ClaimHandle.
42
+ // them, plural key vs singular typename). Public surfaces (Claim.
43
43
  // target.model) keep the schema key; only the wire/coordination targets
44
44
  // use this.
45
45
  const wireModel = registeredModelName.toLowerCase();
@@ -90,7 +90,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
90
90
  const isClaimHandle = (value) => typeof value === 'object' &&
91
91
  value !== null &&
92
92
  value.object === 'claim' &&
93
- typeof value.claimId === 'string' &&
93
+ typeof value.id === 'string' &&
94
94
  typeof value.release === 'function';
95
95
  const claimMeta = (options) => {
96
96
  if (!options?.description)
@@ -131,7 +131,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
131
131
  if (!held)
132
132
  return;
133
133
  activeClaims.delete(id);
134
- await held.lease.release();
134
+ await held.lease.release?.();
135
135
  };
136
136
  const takeClaim = async (params) => {
137
137
  if (!collaboration) {
@@ -214,7 +214,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
214
214
  }
215
215
  const snapshot = collaboration.createSnapshot(schemaKey, id);
216
216
  const reason = options?.reason ?? 'editing';
217
- // The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
217
+ // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
218
218
  // report (`state` maps `held.target.model` → `type`), so a holder and a
219
219
  // peer see the SAME target.type for one row — the wire model token.
220
220
  const selfTarget = {
@@ -235,7 +235,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
235
235
  expiresAt,
236
236
  });
237
237
  const target = {
238
- model: schemaKey,
238
+ type: schemaKey,
239
239
  id,
240
240
  ...(options?.field ? { field: options.field } : {}),
241
241
  ...(options?.path ? { path: options.path } : {}),
@@ -245,7 +245,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
245
245
  const release = () => releaseClaim(id);
246
246
  return {
247
247
  object: 'claim',
248
- claimId: lease.claimId,
248
+ id: lease.id,
249
249
  readAt: snapshot.stamp,
250
250
  target,
251
251
  reason,
@@ -278,7 +278,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
278
278
  if (own) {
279
279
  return {
280
280
  object: 'claim',
281
- id: own.lease.claimId,
281
+ id: own.lease.id,
282
282
  status: 'active',
283
283
  target: own.target,
284
284
  reason: own.reason,
@@ -403,7 +403,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
403
403
  const effective = {
404
404
  ...opts,
405
405
  ...(autoLease ? { claim: autoLease } : {}),
406
- ...(isClaimHandle(claim) ? { claim: { id: claim.claimId } } : {}),
406
+ ...(isClaimHandle(claim) ? { claim: { id: claim.id } } : {}),
407
407
  };
408
408
  try {
409
409
  syncClient.add(model, effective);
@@ -411,7 +411,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
411
411
  return modelAsRow(model);
412
412
  }
413
413
  finally {
414
- await autoLease?.release().catch(() => { });
414
+ await autoLease?.release?.().catch(() => { });
415
415
  }
416
416
  }),
417
417
  update: guard(async (params) => {
@@ -422,7 +422,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
422
422
  return await operations.update({ ...params, claim: handle });
423
423
  }
424
424
  finally {
425
- await handle.release();
425
+ await handle.release?.();
426
426
  }
427
427
  }
428
428
  const { id } = params;
@@ -439,7 +439,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
439
439
  wait: 'confirmed',
440
440
  readAt: claimed.snapshot.stamp,
441
441
  onStale: 'reject',
442
- claimRef: { id: claimed.lease.claimId },
442
+ claimRef: { id: claimed.lease.id },
443
443
  ...opts,
444
444
  }
445
445
  : {
@@ -454,7 +454,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
454
454
  }
455
455
  : {}),
456
456
  ...opts,
457
- ...(handle ? { claim: { id: handle.claimId } } : {}),
457
+ ...(handle ? { claim: { id: handle.id } } : {}),
458
458
  };
459
459
  // Local user update: `applyChanges` keeps change tracking ON so
460
460
  // the edited fields land in `modifiedProperties` and actually get
@@ -473,7 +473,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
473
473
  await operations.delete({ ...params, claim: handle });
474
474
  }
475
475
  finally {
476
- await handle.release();
476
+ await handle.release?.();
477
477
  }
478
478
  return;
479
479
  }
@@ -489,7 +489,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
489
489
  wait: 'confirmed',
490
490
  readAt: claimed.snapshot.stamp,
491
491
  onStale: 'reject',
492
- claimRef: { id: claimed.lease.claimId },
492
+ claimRef: { id: claimed.lease.id },
493
493
  ...opts,
494
494
  }
495
495
  : {
@@ -501,7 +501,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
501
501
  }
502
502
  : {}),
503
503
  ...opts,
504
- ...(handle ? { claim: { id: handle.claimId } } : {}),
504
+ ...(handle ? { claim: { id: handle.id } } : {}),
505
505
  };
506
506
  syncClient.delete(model, effective);
507
507
  await waitForMutation(model, effective);
@@ -6,6 +6,16 @@ export interface MintSessionContext {
6
6
  readonly apiKey: string;
7
7
  readonly baseUrl: string;
8
8
  readonly fetch?: typeof fetch;
9
+ /**
10
+ * Schema-key → wire typename map, supplied ONLY by the schema client
11
+ * (`Ablo`). A capability is scoped by the lowercased TYPENAME the Hub
12
+ * checks, but `can` is keyed by schema key — so `can: { documents: ['update'] }`
13
+ * on a model whose `typename` is overridden to `Document` must mint
14
+ * `document.update`, not `documents.update` (else the Hub denies it with
15
+ * `capability_scope_denied`). The schemaless `ApiClient` omits this map:
16
+ * there the `can` key already IS the wire token, so no translation applies.
17
+ */
18
+ readonly modelTypenames?: Readonly<Record<string, string>>;
9
19
  }
10
20
  /**
11
21
  * Mint a session token from an already-resolved `sk_` credential + base URL.
@@ -63,7 +63,14 @@ export async function mintSession(params, ctx) {
63
63
  userMeta: params.userMeta ?? { id: res.participantId },
64
64
  };
65
65
  }
66
- const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
66
+ const operations = Object.entries(params.can).flatMap(([model, ops]) => {
67
+ // Translate the schema key the developer used in `can` to the wire
68
+ // typename the Hub gates on — see `modelTypenames` above. Falls back to
69
+ // the key when no map is supplied (schemaless client) or the key isn't
70
+ // in it, preserving the prior behaviour exactly for those callers.
71
+ const ns = ctx.modelTypenames?.[model] ?? model;
72
+ return (ops ?? []).map((op) => `${ns.toLowerCase()}.${op}`);
73
+ });
67
74
  const res = await exchangeApiKey({
68
75
  apiKey,
69
76
  baseUrl,
@@ -209,7 +209,7 @@ export declare const wireClaimSummarySchema: z.ZodObject<{
209
209
  entityId: z.ZodString;
210
210
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
211
211
  claimId: z.ZodString;
212
- action: z.ZodString;
212
+ reason: z.ZodString;
213
213
  declaredAt: z.ZodNumber;
214
214
  expiresAt: z.ZodNumber;
215
215
  }, z.core.$strip>;
@@ -227,7 +227,7 @@ export declare const claimErrorSchema: z.ZodObject<{
227
227
  entityId: z.ZodString;
228
228
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
229
229
  claimId: z.ZodString;
230
- action: z.ZodString;
230
+ reason: z.ZodString;
231
231
  declaredAt: z.ZodNumber;
232
232
  expiresAt: z.ZodNumber;
233
233
  }, z.core.$strip>>;
@@ -237,7 +237,7 @@ export type ClaimError = z.infer<typeof claimErrorSchema>;
237
237
  /**
238
238
  * A declared pending-mutation claim — the unit broadcast in presence
239
239
  * `activeClaims`. Clients supply the descriptive `targetRef` fields, an
240
- * `action`, and a chosen `claimId`; the SERVER stamps `declaredAt` /
240
+ * explanatory `reason`, and a chosen `claimId`; the SERVER stamps `declaredAt` /
241
241
  * `expiresAt` and may set `status` / `error`.
242
242
  *
243
243
  * `status` and `error` are OPTIONAL: this single shape serves both the
@@ -259,7 +259,7 @@ export declare const wireClaimSchema: z.ZodObject<{
259
259
  field: z.ZodOptional<z.ZodString>;
260
260
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
261
261
  claimId: z.ZodString;
262
- action: z.ZodString;
262
+ reason: z.ZodString;
263
263
  declaredAt: z.ZodNumber;
264
264
  expiresAt: z.ZodNumber;
265
265
  status: z.ZodOptional<z.ZodEnum<{
@@ -280,7 +280,7 @@ export declare const wireClaimSchema: z.ZodObject<{
280
280
  entityId: z.ZodString;
281
281
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
282
282
  claimId: z.ZodString;
283
- action: z.ZodString;
283
+ reason: z.ZodString;
284
284
  declaredAt: z.ZodNumber;
285
285
  expiresAt: z.ZodNumber;
286
286
  }, z.core.$strip>>;
@@ -313,7 +313,7 @@ export declare const claimRejectionSchema: z.ZodObject<{
313
313
  entityId: z.ZodString;
314
314
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
315
315
  claimId: z.ZodString;
316
- action: z.ZodString;
316
+ reason: z.ZodString;
317
317
  declaredAt: z.ZodNumber;
318
318
  expiresAt: z.ZodNumber;
319
319
  }, z.core.$strip>>;
@@ -385,7 +385,7 @@ export declare const modelClaimSchema: z.ZodReadonly<z.ZodObject<{
385
385
  }, z.core.$strip>>;
386
386
  export type ModelClaim = z.infer<typeof modelClaimSchema>;
387
387
  /**
388
- * `claim_begin` payload (client → server). The descriptive target + action,
388
+ * `claim_begin` payload (client → server). The descriptive target + reason,
389
389
  * plus an optional duration hint and the opt-in fair-queue flag. The server
390
390
  * stamps the lifecycle/timestamp fields, so they are NOT part of the inbound
391
391
  * shape — this is exactly what the WS ingest validates.
@@ -403,7 +403,7 @@ export declare const claimBeginPayloadSchema: z.ZodObject<{
403
403
  field: z.ZodOptional<z.ZodString>;
404
404
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
405
405
  claimId: z.ZodString;
406
- action: z.ZodString;
406
+ reason: z.ZodString;
407
407
  estimatedMs: z.ZodOptional<z.ZodNumber>;
408
408
  queue: z.ZodOptional<z.ZodBoolean>;
409
409
  }, z.core.$strip>;
@@ -585,7 +585,7 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
585
585
  field: z.ZodOptional<z.ZodString>;
586
586
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
587
587
  claimId: z.ZodString;
588
- action: z.ZodString;
588
+ reason: z.ZodString;
589
589
  declaredAt: z.ZodNumber;
590
590
  expiresAt: z.ZodNumber;
591
591
  status: z.ZodOptional<z.ZodEnum<{
@@ -606,7 +606,7 @@ export declare const presenceUpdateFrameSchema: z.ZodObject<{
606
606
  entityId: z.ZodString;
607
607
  meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
608
608
  claimId: z.ZodString;
609
- action: z.ZodString;
609
+ reason: z.ZodString;
610
610
  declaredAt: z.ZodNumber;
611
611
  expiresAt: z.ZodNumber;
612
612
  }, z.core.$strip>>;
@@ -209,8 +209,8 @@ export const claimStatusSchema = z.enum([
209
209
  ]);
210
210
  const wireClaimBaseSchema = targetRefSchema.extend({
211
211
  claimId: z.string(),
212
- /** Verb the agent expects: 'update' | 'create' | 'editing' | 'reviewing' … */
213
- action: z.string(),
212
+ /** Human-readable phase: 'editing' | 'reviewing' | 'forecasting' … */
213
+ reason: z.string(),
214
214
  /** Server-stamped declaration time (epoch ms). */
215
215
  declaredAt: z.number(),
216
216
  /** Server-computed TTL deadline (epoch ms). Readers treat as advisory. */
@@ -219,7 +219,7 @@ const wireClaimBaseSchema = targetRefSchema.extend({
219
219
  });
220
220
  export const wireClaimSummarySchema = wireClaimBaseSchema.pick({
221
221
  claimId: true,
222
- action: true,
222
+ reason: true,
223
223
  declaredAt: true,
224
224
  expiresAt: true,
225
225
  entityType: true,
@@ -243,7 +243,7 @@ export const claimErrorSchema = z.object({
243
243
  /**
244
244
  * A declared pending-mutation claim — the unit broadcast in presence
245
245
  * `activeClaims`. Clients supply the descriptive `targetRef` fields, an
246
- * `action`, and a chosen `claimId`; the SERVER stamps `declaredAt` /
246
+ * explanatory `reason`, and a chosen `claimId`; the SERVER stamps `declaredAt` /
247
247
  * `expiresAt` and may set `status` / `error`.
248
248
  *
249
249
  * `status` and `error` are OPTIONAL: this single shape serves both the
@@ -298,8 +298,7 @@ export const modelClaimSchema = z
298
298
  id: z.string(),
299
299
  actor: z.string(),
300
300
  participantKind: wireParticipantKindSchema,
301
- /** Human-readable phase (`'editing'`). The public SDK field; the WS/HTTP
302
- * wire carries the same value as `action` (healed on read). */
301
+ /** Human-readable phase (`'editing'`). */
303
302
  reason: z.string(),
304
303
  description: z.string().optional(),
305
304
  field: z.string().optional(),
@@ -310,14 +309,14 @@ export const modelClaimSchema = z
310
309
  })
311
310
  .readonly();
312
311
  /**
313
- * `claim_begin` payload (client → server). The descriptive target + action,
312
+ * `claim_begin` payload (client → server). The descriptive target + reason,
314
313
  * plus an optional duration hint and the opt-in fair-queue flag. The server
315
314
  * stamps the lifecycle/timestamp fields, so they are NOT part of the inbound
316
315
  * shape — this is exactly what the WS ingest validates.
317
316
  */
318
317
  export const claimBeginPayloadSchema = targetRefSchema.extend({
319
318
  claimId: z.string(),
320
- action: z.string(),
319
+ reason: z.string(),
321
320
  /** Hint for `expiresAt`; the server caps it. */
322
321
  estimatedMs: z.number().optional(),
323
322
  /**