@abloatai/ablo 0.35.0 → 0.36.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 (215) hide show
  1. package/CHANGELOG.md +713 -629
  2. package/README.md +56 -519
  3. package/bin/ablo.cjs +39 -0
  4. package/dist/BaseSyncedStore.d.ts +24 -4
  5. package/dist/BaseSyncedStore.js +53 -37
  6. package/dist/Database.d.ts +8 -20
  7. package/dist/Database.js +61 -59
  8. package/dist/InstanceCache.d.ts +6 -2
  9. package/dist/InstanceCache.js +18 -16
  10. package/dist/Model.d.ts +17 -7
  11. package/dist/Model.js +17 -7
  12. package/dist/ModelRegistry.d.ts +4 -0
  13. package/dist/ModelRegistry.js +17 -15
  14. package/dist/NetworkMonitor.d.ts +3 -1
  15. package/dist/NetworkMonitor.js +7 -5
  16. package/dist/SyncClient.d.ts +5 -15
  17. package/dist/SyncClient.js +60 -57
  18. package/dist/client/Ablo.js +30 -19
  19. package/dist/client/createInternalComponents.d.ts +4 -0
  20. package/dist/client/createInternalComponents.js +8 -2
  21. package/dist/client/createModelProxy.d.ts +21 -1
  22. package/dist/client/createModelProxy.js +123 -57
  23. package/dist/client/humans.d.ts +30 -9
  24. package/dist/client/humans.js +45 -19
  25. package/dist/client/reactiveEngine.d.ts +16 -11
  26. package/dist/client/reactiveEngine.js +113 -335
  27. package/dist/client/storeCluster.d.ts +47 -0
  28. package/dist/client/storeCluster.js +118 -0
  29. package/dist/client/storeLifecycle.d.ts +61 -0
  30. package/dist/client/storeLifecycle.js +231 -0
  31. package/dist/context.d.ts +13 -0
  32. package/dist/context.js +23 -0
  33. package/dist/core/index.d.ts +1 -1
  34. package/dist/docs/catalog.js +6 -3
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.js +4 -2
  37. package/dist/query/client.d.ts +3 -0
  38. package/dist/query/client.js +6 -5
  39. package/dist/react/AbloProvider.d.ts +13 -1
  40. package/dist/react/AbloProvider.js +5 -2
  41. package/dist/react/context.d.ts +2 -2
  42. package/dist/react/createAbloReact.d.ts +56 -0
  43. package/dist/react/createAbloReact.js +51 -0
  44. package/dist/react/index.d.ts +1 -0
  45. package/dist/react/index.js +3 -0
  46. package/dist/react/useAblo.d.ts +9 -2
  47. package/dist/react/useAblo.js +25 -7
  48. package/dist/schema/coordination.js +5 -1
  49. package/dist/schema/index.d.ts +1 -0
  50. package/dist/schema/index.js +4 -0
  51. package/dist/schema/select.js +3 -0
  52. package/dist/schema/serialize.js +3 -0
  53. package/dist/source/adapter.d.ts +7 -5
  54. package/dist/source/adapter.js +7 -5
  55. package/dist/source/index.d.ts +1 -1
  56. package/dist/source/index.js +1 -1
  57. package/dist/{core/storeContract.d.ts → storeContract.d.ts} +5 -5
  58. package/dist/{core → stores}/DatabaseManager.d.ts +3 -1
  59. package/dist/{core → stores}/DatabaseManager.js +13 -12
  60. package/dist/{core → stores}/StoreManager.d.ts +5 -3
  61. package/dist/{core → stores}/StoreManager.js +24 -22
  62. package/dist/stores/SyncActionStore.d.ts +3 -1
  63. package/dist/stores/SyncActionStore.js +9 -7
  64. package/dist/sync/BootstrapFetcher.d.ts +4 -0
  65. package/dist/sync/BootstrapFetcher.js +28 -26
  66. package/dist/sync/OnDemandLoader.d.ts +3 -0
  67. package/dist/sync/OnDemandLoader.js +1 -0
  68. package/dist/sync/bootstrapApply.d.ts +3 -0
  69. package/dist/sync/bootstrapApply.js +2 -2
  70. package/dist/sync/deltaPipeline.d.ts +13 -12
  71. package/dist/sync/deltaPipeline.js +21 -4
  72. package/dist/sync/groupChange.d.ts +3 -0
  73. package/dist/sync/groupChange.js +16 -14
  74. package/dist/sync/participants.d.ts +19 -2
  75. package/dist/sync/participants.js +3 -1
  76. package/dist/sync/schemas.d.ts +2 -1
  77. package/dist/sync/schemas.js +3 -3
  78. package/dist/syncLog/contract.d.ts +20 -0
  79. package/dist/syncLog/contract.js +19 -0
  80. package/dist/syncLog/index.d.ts +1 -0
  81. package/dist/syncLog/index.js +1 -0
  82. package/dist/transaction/auth/capability.d.ts +35 -0
  83. package/dist/transaction/auth/capability.js +25 -0
  84. package/dist/transaction/coordination/awaitClaimGrant.d.ts +7 -0
  85. package/dist/transaction/coordination/awaitClaimGrant.js +12 -0
  86. package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +34 -0
  87. package/dist/transaction/coordination/claimHeartbeatLoop.js +20 -0
  88. package/dist/transaction/coordination/index.d.ts +4 -4
  89. package/dist/transaction/coordination/index.js +4 -3
  90. package/dist/transaction/coordination/locator.d.ts +23 -2
  91. package/dist/transaction/coordination/locator.js +22 -2
  92. package/dist/transaction/coordination/schema.d.ts +125 -62
  93. package/dist/transaction/coordination/schema.js +228 -64
  94. package/dist/transaction/coordination/targetConflict.js +32 -28
  95. package/dist/transaction/errorCodes.d.ts +2 -2
  96. package/dist/transaction/errorCodes.js +13 -9
  97. package/dist/transaction/plugin.d.ts +95 -2
  98. package/dist/transaction/plugin.js +21 -2
  99. package/dist/transaction/resources/httpResources.d.ts +57 -2
  100. package/dist/transaction/resources/modelOperations.d.ts +148 -40
  101. package/dist/transaction/resources/where.d.ts +16 -0
  102. package/dist/transaction/resources/where.js +45 -0
  103. package/dist/transaction/schema/field.d.ts +12 -18
  104. package/dist/transaction/schema/fieldRef.d.ts +38 -0
  105. package/dist/transaction/schema/fieldRef.js +11 -0
  106. package/dist/transaction/schema/openapi.d.ts +16 -15
  107. package/dist/transaction/schema/openapi.js +186 -25
  108. package/dist/transaction/schema/relation.d.ts +7 -2
  109. package/dist/transaction/schema/schema.d.ts +27 -0
  110. package/dist/transaction/schema/schema.js +20 -0
  111. package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +1 -1
  112. package/dist/transaction/transactions/settlement/pendingWrite.d.ts +1 -1
  113. package/dist/transaction/transport/httpClient.d.ts +9 -1
  114. package/dist/transaction/transport/httpClient.js +1 -0
  115. package/dist/transaction/transport/httpTransport.js +156 -44
  116. package/dist/transaction/transport/wsTransport.d.ts +2 -4
  117. package/dist/transaction/transport/wsTransport.js +16 -10
  118. package/dist/transaction/types/streams.d.ts +10 -0
  119. package/dist/transaction/utils/duration.d.ts +25 -0
  120. package/dist/transaction/utils/duration.js +32 -0
  121. package/dist/transaction/wire/accountResponses.d.ts +69 -0
  122. package/dist/transaction/wire/accountResponses.js +36 -1
  123. package/dist/transaction/wire/auth.d.ts +9 -2
  124. package/dist/transaction/wire/auth.js +7 -1
  125. package/dist/transaction/wire/claims.d.ts +164 -97
  126. package/dist/transaction/wire/claims.js +126 -28
  127. package/dist/transaction/wire/commit.d.ts +1 -1
  128. package/dist/transaction/wire/feedEvent.d.ts +27 -0
  129. package/dist/transaction/wire/feedEvent.js +27 -1
  130. package/dist/transaction/wire/frames.d.ts +2 -2
  131. package/dist/transaction/wire/inboundFrames.d.ts +12 -2
  132. package/dist/transaction/wire/index.d.ts +10 -6
  133. package/dist/transaction/wire/index.js +12 -3
  134. package/dist/transaction/wire/modelMutations.d.ts +31 -0
  135. package/dist/transaction/wire/modelMutations.js +52 -0
  136. package/dist/transaction/wire/modelShape.d.ts +78 -0
  137. package/dist/transaction/wire/modelShape.js +74 -0
  138. package/dist/transactions/mutations/MutationQueue.d.ts +6 -0
  139. package/dist/transactions/mutations/MutationQueue.js +55 -45
  140. package/dist/transactions/mutations/commitPayload.d.ts +3 -2
  141. package/dist/transactions/mutations/commitPayload.js +5 -5
  142. package/dist/transactions/mutations/deltaConfirmation.d.ts +4 -0
  143. package/dist/transactions/mutations/deltaConfirmation.js +10 -8
  144. package/dist/transactions/mutations/replayValidation.d.ts +2 -1
  145. package/dist/transactions/mutations/replayValidation.js +3 -2
  146. package/dist/{core → views}/QueryView.d.ts +1 -1
  147. package/dist/{core → views}/QueryView.js +1 -1
  148. package/dist/{core → views}/ViewRegistry.d.ts +1 -1
  149. package/dist/{core/queryUtils.d.ts → views/incrementalView.d.ts} +6 -6
  150. package/dist/{core/queryUtils.js → views/incrementalView.js} +6 -6
  151. package/docs/agents.md +1 -1
  152. package/docs/api-keys.md +6 -6
  153. package/docs/api.md +5 -43
  154. package/docs/audit.md +4 -3
  155. package/docs/cli.md +11 -11
  156. package/docs/client-behavior.md +4 -4
  157. package/docs/concurrency-convention.md +28 -42
  158. package/docs/coordination.md +235 -83
  159. package/docs/data-sources.md +4 -4
  160. package/docs/debugging.md +34 -12
  161. package/docs/deployment.md +8 -8
  162. package/docs/examples/scoped-agent.md +3 -3
  163. package/docs/groups.md +57 -3
  164. package/docs/guarantees.md +37 -10
  165. package/docs/how-it-works.md +29 -5
  166. package/docs/idempotency.md +6 -6
  167. package/docs/identity.md +22 -23
  168. package/docs/index.md +8 -8
  169. package/docs/integration-guide.md +14 -3
  170. package/docs/mcp.md +7 -7
  171. package/docs/migration.md +34 -15
  172. package/docs/projects.md +1 -1
  173. package/docs/react.md +19 -8
  174. package/docs/sessions.md +1 -1
  175. package/docs/webhooks.md +9 -9
  176. package/llms.txt +4 -4
  177. package/package.json +13 -20
  178. package/dist/cli.cjs +0 -288600
  179. package/dist/testing/fixtures/bootstrap.d.ts +0 -49
  180. package/dist/testing/fixtures/bootstrap.js +0 -59
  181. package/dist/testing/fixtures/deltas.d.ts +0 -83
  182. package/dist/testing/fixtures/deltas.js +0 -136
  183. package/dist/testing/fixtures/httpResponses.d.ts +0 -70
  184. package/dist/testing/fixtures/httpResponses.js +0 -90
  185. package/dist/testing/fixtures/models.d.ts +0 -83
  186. package/dist/testing/fixtures/models.js +0 -272
  187. package/dist/testing/helpers/reactWrapper.d.ts +0 -69
  188. package/dist/testing/helpers/reactWrapper.js +0 -67
  189. package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
  190. package/dist/testing/helpers/syncEngineHarness.js +0 -73
  191. package/dist/testing/helpers/wait.d.ts +0 -30
  192. package/dist/testing/helpers/wait.js +0 -49
  193. package/dist/testing/index.d.ts +0 -23
  194. package/dist/testing/index.js +0 -33
  195. package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
  196. package/dist/testing/mocks/FakeDatabase.js +0 -10
  197. package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
  198. package/dist/testing/mocks/MockMutationExecutor.js +0 -186
  199. package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
  200. package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
  201. package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
  202. package/dist/testing/mocks/MockSyncContext.js +0 -72
  203. package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
  204. package/dist/testing/mocks/MockSyncStore.js +0 -171
  205. package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
  206. package/dist/testing/mocks/MockWebSocket.js +0 -118
  207. package/docs/interaction-model.md +0 -99
  208. /package/dist/{core → query}/QueryProcessor.d.ts +0 -0
  209. /package/dist/{core → query}/QueryProcessor.js +0 -0
  210. /package/dist/{core/storeContract.js → storeContract.js} +0 -0
  211. /package/dist/{core → stores}/openIDBWithTimeout.d.ts +0 -0
  212. /package/dist/{core → stores}/openIDBWithTimeout.js +0 -0
  213. /package/dist/{source → transaction}/footprint.d.ts +0 -0
  214. /package/dist/{source → transaction}/footprint.js +0 -0
  215. /package/dist/{core → views}/ViewRegistry.js +0 -0
@@ -12,10 +12,10 @@ import { reconcileFunctionalUpdate, } from '../resources/functionalUpdate.js';
12
12
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, rejectRemovedDatabaseUrlOption, warnIfCliKeyMismatch, } from '../auth/apiKey.js';
13
13
  import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
14
14
  import { commitReceiptSchema } from '../wire/commit.js';
15
- import { claimAcquireResponseSchema, claimHeartbeatReplySchema, claimListResponseSchema, } from '../wire/claims.js';
15
+ import { claimAcquireResponseSchema, claimHeartbeatBatchReplySchema, claimHeartbeatReplySchema, claimListResponseSchema, claimStateSchema, } from '../wire/claims.js';
16
16
  import { modelListResponseSchema, modelReadResponseSchema, } from '../wire/modelResponses.js';
17
17
  import { toMs } from '../utils/duration.js';
18
- import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from '../coordination/claimHeartbeatLoop.js';
18
+ import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '../coordination/claimHeartbeatLoop.js';
19
19
  import { mintSession } from '../auth/sessionMint.js';
20
20
  import { parseIdentityResolveResponse } from '../auth/schemas.js';
21
21
  /**
@@ -34,8 +34,8 @@ function heldHeartbeatReply(reply, label) {
34
34
  }
35
35
  throw new AbloClaimedError(`The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`, { code: 'claim_lost' });
36
36
  }
37
- import { claimDescription } from '../coordination/schema.js';
38
- import { subTarget, streamTarget } from '../coordination/locator.js';
37
+ import { claimDescription, partName } from '../coordination/schema.js';
38
+ import { subTarget, streamTarget, batchFence, fenceTokenFor, } from '../coordination/locator.js';
39
39
  import { declaredMeta, wireMeta } from '../coordination/claimMeta.js';
40
40
  import { assertWriteOptions } from '../resources/writeOptionsSchema.js';
41
41
  import { createDurableHttpCommitEnvelope, canonicalHttpCommitBody, durableHttpCommitEnvelopeSchema, httpCommitEnvelopeRecordId, isHttpCommitReplayExpired, } from '../transactions/settlement/httpCommitEnvelope.js';
@@ -698,7 +698,7 @@ export function createHttpTransport(options) {
698
698
  ? crypto.randomUUID()
699
699
  : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
700
700
  }
701
- function normalizeCommitOperation(op, defaults, fenceToken) {
701
+ function normalizeCommitOperation(op, defaults, fence) {
702
702
  return {
703
703
  action: op.action,
704
704
  model: op.model,
@@ -707,18 +707,16 @@ export function createHttpTransport(options) {
707
707
  transactionId: op.transactionId ?? null,
708
708
  readAt: op.readAt ?? defaults.readAt ?? null,
709
709
  onStale: op.onStale ?? defaults.onStale ?? null,
710
- // The batch's claim (if any) supplies one token for every op, mirroring
711
- // how it supplies the batch `readAt`.
712
- fenceToken: op.fenceToken ?? fenceToken ?? null,
710
+ fenceToken: op.fenceToken ?? fenceTokenFor(fence, op.model, op.id ?? null),
713
711
  };
714
712
  }
715
- function normalizeCommitOperations(commitOptions, fenceToken) {
713
+ function normalizeCommitOperations(commitOptions, fence) {
716
714
  if (commitOptions.operations.length === 0) {
717
715
  throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
718
716
  code: 'commit_operation_required',
719
717
  });
720
718
  }
721
- return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions, fenceToken));
719
+ return commitOptions.operations.map((op) => normalizeCommitOperation(op, commitOptions, fence));
722
720
  }
723
721
  async function listClaimState(target) {
724
722
  const params = new URLSearchParams();
@@ -730,7 +728,105 @@ export function createHttpTransport(options) {
730
728
  params.set('field', target.field);
731
729
  const suffix = params.toString();
732
730
  const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' }, claimListResponseSchema);
733
- return { active: body.claims, queue: body.queue };
731
+ // One list, one resource in two states: holders and waiters are told
732
+ // apart by each entry's `status`, not by bespoke envelope members.
733
+ return {
734
+ active: body.data.filter((row) => row.status !== 'queued'),
735
+ queue: body.data.filter((row) => row.status === 'queued'),
736
+ };
737
+ }
738
+ // The claim-ticket surface: everything a caller does holding only a
739
+ // `claimId` — which is all a queued acquire leaves in its hand. Each method
740
+ // is a thin cast of its route; the reply schemas are the wire's own, so the
741
+ // surface cannot describe a response the server does not send.
742
+ const claims = {
743
+ retrieve({ claimId }) {
744
+ return requestJson(`/v1/claims/${encodeURIComponent(claimId)}`, { method: 'GET' }, claimStateSchema);
745
+ },
746
+ heartbeat({ claimId, ttl }) {
747
+ return requestJson(`/v1/claims/${encodeURIComponent(claimId)}/heartbeat`, {
748
+ method: 'POST',
749
+ body: JSON.stringify(ttl !== undefined ? { ttl } : {}),
750
+ }, claimHeartbeatReplySchema);
751
+ },
752
+ async heartbeatAll(options) {
753
+ const reply = await requestJson('/v1/claims/heartbeat', {
754
+ method: 'POST',
755
+ body: JSON.stringify(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
756
+ }, claimHeartbeatBatchReplySchema);
757
+ return reply.results;
758
+ },
759
+ async release({ claimId }) {
760
+ await requestRaw(`/v1/claims/${encodeURIComponent(claimId)}`, {
761
+ method: 'DELETE',
762
+ });
763
+ },
764
+ };
765
+ // How the stateless client waits its turn. The queued slot is real server
766
+ // state, so one heartbeat per tick does both jobs: it refreshes this
767
+ // waiter's slot and reports the line's answer — `queued` (still waiting) or
768
+ // `held` (granted). The first check comes quickly because most holds are a
769
+ // short claim→write→release; after that the cadence relaxes, with jitter so
770
+ // a fleet of waiters doesn't beat in step.
771
+ const GRANT_POLL_FIRST_MS = 250;
772
+ const GRANT_POLL_INTERVAL_MS = 1_000;
773
+ // An abort cuts the sleep short so the wait ends within a tick of the
774
+ // signal, not at the next scheduled beat.
775
+ const sleep = (ms, signal) => new Promise((resolve) => {
776
+ const done = () => {
777
+ clearTimeout(timer);
778
+ signal?.removeEventListener('abort', done);
779
+ resolve();
780
+ };
781
+ const timer = setTimeout(done, ms);
782
+ signal?.addEventListener('abort', done, { once: true });
783
+ });
784
+ async function awaitGrantOverHttp(targetLabel, queued, options) {
785
+ // The queued reply is a claim resource in its waiting state, so the
786
+ // handle is its `id` — same rule as the 201 and the poll.
787
+ const claimId = queued.id;
788
+ const { signal } = options;
789
+ // Leave the line before rejecting: an abandoned slot would otherwise sit
790
+ // in the queue until its TTL lapses, stalling every waiter behind it.
791
+ const rejectAndLeave = async (error) => {
792
+ await claims.release({ claimId }).catch(() => { });
793
+ throw error;
794
+ };
795
+ if (options.maxQueueDepth !== undefined && queued.position >= options.maxQueueDepth) {
796
+ return rejectAndLeave(new AbloClaimedError(`Claim queue for ${targetLabel} is ${queued.position} deep (max ${options.maxQueueDepth}).`, { code: 'queue_too_deep' }));
797
+ }
798
+ const deadline = options.waitTimeoutMs !== undefined ? Date.now() + options.waitTimeoutMs : undefined;
799
+ let delay = GRANT_POLL_FIRST_MS;
800
+ for (;;) {
801
+ if (signal?.aborted) {
802
+ return rejectAndLeave(new AbloClaimedError(`The wait for the claim on ${targetLabel} was aborted before the grant arrived.`, { code: 'claim_wait_aborted' }));
803
+ }
804
+ if (deadline !== undefined && Date.now() >= deadline) {
805
+ return rejectAndLeave(new AbloClaimedError(`Timed out after ${options.waitTimeoutMs}ms waiting for the queue grant on ${targetLabel}.`, { code: 'grant_timeout' }));
806
+ }
807
+ await sleep(deadline !== undefined ? Math.min(delay, Math.max(0, deadline - Date.now())) : delay, signal);
808
+ if (signal?.aborted) {
809
+ return rejectAndLeave(new AbloClaimedError(`The wait for the claim on ${targetLabel} was aborted before the grant arrived.`, { code: 'claim_wait_aborted' }));
810
+ }
811
+ delay = GRANT_POLL_INTERVAL_MS * (0.85 + Math.random() * 0.3);
812
+ // A lease that ended answers the beat with 409 `claim_lost`, which the
813
+ // wire error mapping raises as AbloClaimedError before this reads
814
+ // anything — the wait fails with the loss, as the socket wait does.
815
+ const beat = await claims.heartbeat({ claimId });
816
+ if (beat.status !== 'held')
817
+ continue;
818
+ // Granted. The heartbeat ack does not carry the fence token — the claim
819
+ // state does, server-stamped at grant.
820
+ const state = await claims.retrieve({ claimId });
821
+ if (state.status !== 'active') {
822
+ return rejectAndLeave(new AbloClaimedError(`Claim lost while queued for ${targetLabel}.`, {
823
+ code: 'claim_lost',
824
+ }));
825
+ }
826
+ return state.fenceToken !== undefined
827
+ ? { id: claimId, fenceToken: state.fenceToken }
828
+ : { id: claimId };
829
+ }
734
830
  }
735
831
  async function applyClaimedPolicy(target, options, defaultPolicy = 'return') {
736
832
  const policy = options?.ifClaimed ?? defaultPolicy;
@@ -760,7 +856,7 @@ export function createHttpTransport(options) {
760
856
  ...commitOptions,
761
857
  readAt: commitOptions.readAt ?? claim?.readAt ?? null,
762
858
  onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
763
- }, claim?.fenceToken ?? null);
859
+ }, batchFence(claim?.target, claim?.fenceToken));
764
860
  const requestBody = {
765
861
  operations,
766
862
  reads: commitOptions.reads,
@@ -953,30 +1049,21 @@ export function createHttpTransport(options) {
953
1049
  queue: params.queue ?? true,
954
1050
  };
955
1051
  const body = await requestJson(claimPath(params.id), { method: 'POST', body: JSON.stringify(request) }, claimAcquireResponseSchema);
956
- // HELD BY ADR 0018 (`docs/decisions/0018-the-premise-is-the-missing-structure.md`).
957
- // Raising a successful outcome as an exception is wrong, and this stays
958
- // wrong on purpose: it is the symptom the ADR is derived from, so removing
959
- // the smell without the structure would hide the reason for the work.
960
- //
961
- // Being queued is not a transport failure it is "I cannot establish my
962
- // premise yet." Premise is the structure the codebase approximates six
963
- // ways and has never modelled, which is why this was never solvable here.
964
- // A client-side polling loop is specifically ruled out: it would become a
965
- // shape callers build against, and the better it worked the harder it
966
- // would be to replace with the status this should be.
967
- //
968
- // A caller that must wait today can poll `GET /v1/claims/{claimId}`, which
969
- // is served and published. That primitive is safe to build on; a blessed
970
- // wait algorithm here is not.
971
- // The two arms are told apart by `status`, which only the queued reply
972
- // carries — see `claimAcquireResponseSchema` for why they cannot be a
973
- // discriminated union.
974
- if ('status' in body) {
975
- throw new AbloClaimedError(`Target ${name}/${params.id} is held; queued at position ${body.position}. ` +
976
- `Poll \`GET /v1/claims/{claimId}\` for the grant — the HTTP client does not await it.`, { code: 'claim_queued' });
1052
+ // One resource, two states, discriminated by `status`. The queued arm
1053
+ // WAITS, exactly as the socket client does: `claim({ id })` means
1054
+ // "serialize me behind the holder" on every transport, and the grant
1055
+ // machinery is the SDK's to own, not a loop each caller re-derives.
1056
+ // (Being queued is still not an error — ADR 0018 — which is precisely
1057
+ // why it no longer surfaces as one here. The `claims` namespace remains
1058
+ // the manual ticket surface.)
1059
+ if (body.status === 'queued') {
1060
+ return awaitGrantOverHttp(`${name}/${params.id}`, body, params);
977
1061
  }
978
- const { id, fenceToken } = body.claim;
979
- return fenceToken !== undefined ? { id, fenceToken } : { id };
1062
+ // The lease's own fields are mirrored at the top level, the same place
1063
+ // the poll puts them one reader for both answers.
1064
+ return body.fenceToken !== undefined
1065
+ ? { id: body.id, fenceToken: body.fenceToken }
1066
+ : { id: body.id };
980
1067
  };
981
1068
  const releaseClaim = (params) => requestRaw(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
982
1069
  method: 'DELETE',
@@ -996,13 +1083,30 @@ export function createHttpTransport(options) {
996
1083
  return heldHeartbeatReply(reply, `claim ${claimId} on ${name}/${id}`);
997
1084
  };
998
1085
  async function claimImpl(params) {
999
- const { id: claimId, fenceToken } = await acquireClaim(params);
1086
+ let acquired;
1087
+ try {
1088
+ acquired = await acquireClaim(params);
1089
+ }
1090
+ catch (error) {
1091
+ // The try-claim: a held target is an expected outcome of `queue:
1092
+ // false`, not an error — resolve `null` and let the caller move on.
1093
+ // Every other failure (auth, validation, network) stays a rejection,
1094
+ // and the write-site claim path calls `acquireClaim` directly, so a
1095
+ // write that could not claim still fails loudly.
1096
+ if (params.queue === false &&
1097
+ error instanceof AbloClaimedError &&
1098
+ (error.code === 'entity_claimed' || error.code === 'claim_conflict')) {
1099
+ return null;
1100
+ }
1101
+ throw error;
1102
+ }
1103
+ const { id: claimId, fenceToken } = acquired;
1000
1104
  observability?.captureClaim({
1001
1105
  phase: 'acquired',
1002
1106
  claimId,
1003
1107
  model: name,
1004
1108
  id: params.id,
1005
- ...(params.field ? { field: params.field } : {}),
1109
+ ...(params.field ? { field: partName(params.field) } : {}),
1006
1110
  description: claimDescription(params),
1007
1111
  });
1008
1112
  const { data, stamp } = await retrieveModel(name, { id: params.id });
@@ -1012,23 +1116,27 @@ export function createHttpTransport(options) {
1012
1116
  if (data === undefined) {
1013
1117
  throw new AbloNotFoundError(`Cannot claim ${name}/${params.id}: it does not exist (or is outside this credential's scope).`, [params.id]);
1014
1118
  }
1119
+ // One reading of the heartbeat options — cadence and callbacks from
1120
+ // whichever spelling the caller used (plan object, shorthand, or the
1121
+ // deprecated flat callbacks).
1122
+ const plan = resolveHeartbeatPlan(params);
1015
1123
  const heartbeat = async (beatOptions) => {
1016
1124
  const resolved = resolveHeartbeatOptions(beatOptions);
1017
1125
  const beat = await heartbeatClaim(params.id, claimId, {
1018
1126
  ttl: resolved.ttl ?? params.ttl,
1019
1127
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
1020
1128
  });
1021
- params.onHeartbeat?.(beat);
1129
+ plan.onBeat?.(beat);
1022
1130
  return beat;
1023
1131
  };
1024
1132
  // Opt-in auto-heartbeat — the background-worker cadence. The stateless
1025
1133
  // HTTP claim defaults to the server's acquire window when no TTL
1026
1134
  // was requested, so the default cadence lands at 20s beats.
1027
- const stopHeartbeatLoop = params.heartbeat
1135
+ const stopHeartbeatLoop = plan.loop
1028
1136
  ? startClaimHeartbeatLoop({
1029
1137
  beat: () => heartbeat(),
1030
- intervalMs: heartbeatCadenceMs(params.ttl !== undefined ? toMs(params.ttl) : DEFAULT_CLAIM_TTL_MS, params.heartbeat),
1031
- ...(params.onHeartbeatLost ? { onLost: params.onHeartbeatLost } : {}),
1138
+ intervalMs: heartbeatCadenceMs(params.ttl !== undefined ? toMs(params.ttl) : DEFAULT_CLAIM_TTL_MS, plan.cadence),
1139
+ ...(plan.onLost ? { onLost: plan.onLost } : {}),
1032
1140
  })
1033
1141
  : undefined;
1034
1142
  const release = () => {
@@ -1060,19 +1168,22 @@ export function createHttpTransport(options) {
1060
1168
  [Symbol.asyncDispose]: release,
1061
1169
  };
1062
1170
  }
1063
- const claimsForEntity = (params) => requestJson(`/v1/claims?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${params.field ? `&field=${encodeURIComponent(params.field)}` : ''}`, { method: 'GET' }, claimListResponseSchema);
1171
+ const claimsForEntity = (params) => requestJson(`/v1/claims?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${params.field ? `&field=${encodeURIComponent(partName(params.field))}` : ''}`, { method: 'GET' }, claimListResponseSchema);
1064
1172
  const claim = Object.assign(claimImpl, {
1065
1173
  release: releaseClaim,
1066
1174
  state: async (params) => {
1067
1175
  const res = await claimsForEntity(params);
1068
- const first = res.claims?.[0];
1176
+ // Holders come first in the one list; a `queued` entry is a waiter.
1177
+ const first = res.data.find((row) => row.status !== 'queued');
1069
1178
  return first ? claimFromModelClaim(first) : null;
1070
1179
  },
1071
1180
  queue: async (params) => {
1072
1181
  const res = await claimsForEntity(params);
1073
1182
  return {
1074
1183
  object: 'list',
1075
- data: (res.queue ?? []).map(claimFromModelClaim),
1184
+ data: res.data
1185
+ .filter((row) => row.status === 'queued')
1186
+ .map(claimFromModelClaim),
1076
1187
  };
1077
1188
  },
1078
1189
  reorder: async (params) => {
@@ -1224,6 +1335,7 @@ export function createHttpTransport(options) {
1224
1335
  async dispose() { },
1225
1336
  async purge() { },
1226
1337
  commits,
1338
+ claims,
1227
1339
  model,
1228
1340
  sessions: {
1229
1341
  async create(params) {
@@ -21,7 +21,7 @@ import { EventEmitter } from 'events';
21
21
  import type { ParticipantKind } from '../types/participant.js';
22
22
  import type { BootstrapReason } from '../wire/bootstrapReason.js';
23
23
  import type { ClientSyncDelta } from '../wire/delta.js';
24
- import type { ClaimAcquired, PresenceUpdate, ClaimExpired, ClaimGranted, ClaimHeartbeatAckPayload, ClaimLost, ClaimQueue, ClaimQueued, ClaimRejection, StaleNotification, ReadDependency, TrackDependency } from '../coordination/schema.js';
24
+ import type { ClaimAcquired, PresenceUpdate, ClaimExpired, ClaimGranted, ClaimHeartbeatAckPayload, ClaimLost, ClaimQueue, ClaimQueued, ClaimRejection, ParticipantClaimPayload, StaleNotification, ReadDependency, TrackDependency } from '../coordination/schema.js';
25
25
  import { type AuthTokenGetter } from '../auth/credentialSource.js';
26
26
  import { type CommitAck, type CommitFrameOperation } from './commitFrames.js';
27
27
  import { type Logger } from '../logger.js';
@@ -444,9 +444,7 @@ export declare class WsTransport<TCollaboration extends EventMap<TCollaboration>
444
444
  * and effective `ttlSeconds` once `claim_ack` arrives, or rejects with a typed
445
445
  * error on a failed ack, a timeout, or a disconnect.
446
446
  */
447
- sendClaim(claimId: string, syncGroups: readonly string[], options?: {
448
- capabilityToken?: string;
449
- ttlSeconds?: number;
447
+ sendClaim(claimId: string, syncGroups: readonly string[], options?: Pick<ParticipantClaimPayload, 'capabilityToken' | 'ttlSeconds'> & {
450
448
  timeoutMs?: number;
451
449
  }): Promise<{
452
450
  syncGroups: string[];
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { EventEmitter } from 'events';
21
21
  import { AbloConnectionError, AbloError, AbloSessionError, AbloValidationError, toAbloError, } from '../errors.js';
22
- import { updateSubscriptionPayloadSchema } from '../coordination/schema.js';
22
+ import { participantClaimPayloadSchema, updateSubscriptionPayloadSchema, } from '../coordination/schema.js';
23
23
  import { PROTOCOL_VERSION, WS_CLOSE_PROTOCOL_VERSION } from '../wire/protocolVersion.js';
24
24
  import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
25
25
  import { buildCommitFrame } from './commitFrames.js';
@@ -685,6 +685,20 @@ export class WsTransport extends EventEmitter {
685
685
  if (this.ws?.readyState !== WebSocket.OPEN) {
686
686
  return Promise.reject(this.notConnectedError('claim'));
687
687
  }
688
+ // Checked against the schema the server ingests it with, for the same
689
+ // reason `updateSubscription` below is: the two frames name their scopes
690
+ // identically, so a group that would be refused there is refused here, at
691
+ // the call that asked for it, rather than coming back as a failed ack a
692
+ // round trip later with nothing to point at.
693
+ const payload = participantClaimPayloadSchema.safeParse({
694
+ claimId,
695
+ syncGroups: [...syncGroups],
696
+ capabilityToken: options?.capabilityToken,
697
+ ttlSeconds: options?.ttlSeconds,
698
+ });
699
+ if (!payload.success) {
700
+ return Promise.reject(new AbloValidationError(`join was given a sync group the protocol does not accept: ${payload.error.issues[0]?.message ?? 'unreadable'}. A group is 'default' or 'kind:id'.`, { code: 'malformed_claim' }));
701
+ }
688
702
  const timeoutMs = options?.timeoutMs ?? 15_000;
689
703
  return new Promise((resolve, reject) => {
690
704
  const timeout = setTimeout(() => {
@@ -695,15 +709,7 @@ export class WsTransport extends EventEmitter {
695
709
  }, timeoutMs);
696
710
  this.pendingClaims.set(claimId, { resolve, reject, timeout });
697
711
  try {
698
- this.ws.send(JSON.stringify({
699
- type: 'claim',
700
- payload: {
701
- claimId,
702
- syncGroups: [...syncGroups],
703
- capabilityToken: options?.capabilityToken,
704
- ttlSeconds: options?.ttlSeconds,
705
- },
706
- }));
712
+ this.ws.send(JSON.stringify({ type: 'claim', payload: payload.data }));
707
713
  }
708
714
  catch (error) {
709
715
  clearTimeout(timeout);
@@ -446,6 +446,16 @@ export interface Claim<T = Record<string, unknown>, M = ResolveClaimMeta> {
446
446
  * claiming.
447
447
  */
448
448
  readonly ttlSeconds?: number;
449
+ /**
450
+ * The claim's open metadata bag, as it stands on the wire.
451
+ *
452
+ * A heartbeat's `details` land here under `progress` — last beat wins — so
453
+ * an observer can read what a long hold is doing without waiting for the
454
+ * holder to release. Deliberately the open record rather than the declared
455
+ * `ClaimMeta`: a shape the program declared has no member for a key the
456
+ * coordinator wrote, and `target.meta` beside it is that declared shape.
457
+ */
458
+ readonly meta?: Record<string, unknown>;
449
459
  /**
450
460
  * 0-based place in the FIFO line — present only when `status: 'queued'`
451
461
  * (`0` = next behind the holder). Absent for the active holder.
@@ -12,14 +12,39 @@
12
12
  * caller can pass the same field interchangeably. {@link toMs} returns
13
13
  * milliseconds; {@link toSeconds} returns whole seconds.
14
14
  */
15
+ import { z } from 'zod';
15
16
  export type Duration = number | `${number}ms` | `${number}s` | `${number}m` | `${number}h`;
17
+ /**
18
+ * The same grammar as a boundary schema, so a duration crossing the wire is
19
+ * checked against the parser that will read it rather than against a second
20
+ * description of it.
21
+ *
22
+ * The regex is shared with {@link toMs} deliberately: `z.toJSONSchema` emits it
23
+ * as a `pattern`, which is how a non-TypeScript caller learns the grammar at
24
+ * all. A union of `number | string` — which is what this used to be on the
25
+ * claim bodies — publishes as "some number or some string" and leaves the units
26
+ * to be guessed.
27
+ */
28
+ export declare const durationSchema: z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>;
16
29
  /**
17
30
  * Parses a duration and returns the equivalent number of milliseconds. Accepts
18
31
  * either a bare number, interpreted as seconds, or a unit-suffixed string such
19
32
  * as `'500ms'`, `'30s'`, `'3m'`, or `'24h'`. Throws an
20
33
  * {@link AbloValidationError} with code `duration_invalid` when a string does
21
34
  * not match a supported unit.
35
+ *
36
+ * Two doors, one implementation. {@link toMs} takes the narrow {@link Duration}
37
+ * template type, so an SDK caller writing a literal gets the unit checked at
38
+ * compile time. {@link parseDurationMs} takes the widened `string | number` that
39
+ * {@link durationSchema} infers — the shape a value has after it crossed the
40
+ * wire and was validated at the boundary. Neither is a cast of the other, and
41
+ * neither carries its own copy of the grammar.
22
42
  */
23
43
  export declare function toMs(input: Duration): number;
44
+ /**
45
+ * {@link toMs} for a value that arrived over the wire, where the type system
46
+ * knows only `string | number` because that is what the boundary schema infers.
47
+ */
48
+ export declare function parseDurationMs(input: string | number): number;
24
49
  /** Parses a duration like {@link toMs} but returns whole seconds, rounding down. */
25
50
  export declare function toSeconds(input: Duration): number;
@@ -12,8 +12,26 @@
12
12
  * caller can pass the same field interchangeably. {@link toMs} returns
13
13
  * milliseconds; {@link toSeconds} returns whole seconds.
14
14
  */
15
+ import { z } from 'zod';
15
16
  import { AbloValidationError } from '../errors.js';
16
17
  const PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/;
18
+ /**
19
+ * The same grammar as a boundary schema, so a duration crossing the wire is
20
+ * checked against the parser that will read it rather than against a second
21
+ * description of it.
22
+ *
23
+ * The regex is shared with {@link toMs} deliberately: `z.toJSONSchema` emits it
24
+ * as a `pattern`, which is how a non-TypeScript caller learns the grammar at
25
+ * all. A union of `number | string` — which is what this used to be on the
26
+ * claim bodies — publishes as "some number or some string" and leaves the units
27
+ * to be guessed.
28
+ */
29
+ export const durationSchema = z.union([
30
+ z.number().positive(),
31
+ z.string().regex(PATTERN, {
32
+ message: 'expected a duration such as "500ms", "30s", "3m" or "24h"',
33
+ }),
34
+ ]);
17
35
  const UNIT_MS = {
18
36
  ms: 1,
19
37
  s: 1_000,
@@ -26,8 +44,22 @@ const UNIT_MS = {
26
44
  * as `'500ms'`, `'30s'`, `'3m'`, or `'24h'`. Throws an
27
45
  * {@link AbloValidationError} with code `duration_invalid` when a string does
28
46
  * not match a supported unit.
47
+ *
48
+ * Two doors, one implementation. {@link toMs} takes the narrow {@link Duration}
49
+ * template type, so an SDK caller writing a literal gets the unit checked at
50
+ * compile time. {@link parseDurationMs} takes the widened `string | number` that
51
+ * {@link durationSchema} infers — the shape a value has after it crossed the
52
+ * wire and was validated at the boundary. Neither is a cast of the other, and
53
+ * neither carries its own copy of the grammar.
29
54
  */
30
55
  export function toMs(input) {
56
+ return parseDurationMs(input);
57
+ }
58
+ /**
59
+ * {@link toMs} for a value that arrived over the wire, where the type system
60
+ * knows only `string | number` because that is what the boundary schema infers.
61
+ */
62
+ export function parseDurationMs(input) {
31
63
  if (typeof input === 'number')
32
64
  return input * 1_000;
33
65
  const match = PATTERN.exec(input);
@@ -131,6 +131,29 @@ export declare const schemaModelResponseSchema: z.ZodObject<{
131
131
  }>>;
132
132
  }, z.core.$strip>>;
133
133
  hash: z.ZodString;
134
+ fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
135
+ type: z.ZodEnum<{
136
+ string: "string";
137
+ number: "number";
138
+ boolean: "boolean";
139
+ date: "date";
140
+ enum: "enum";
141
+ json: "json";
142
+ }>;
143
+ isOptional: z.ZodBoolean;
144
+ isIndexed: z.ZodBoolean;
145
+ column: z.ZodOptional<z.ZodString>;
146
+ enumValues: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
147
+ }, z.core.$strip>>>;
148
+ relations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
149
+ type: z.ZodEnum<{
150
+ belongsTo: "belongsTo";
151
+ hasMany: "hasMany";
152
+ hasOne: "hasOne";
153
+ }>;
154
+ target: z.ZodString;
155
+ foreignKey: z.ZodString;
156
+ }, z.core.$strip>>>;
134
157
  }, z.core.$strip>;
135
158
  export type SchemaModelResponse = z.infer<typeof schemaModelResponseSchema>;
136
159
  /**
@@ -168,6 +191,29 @@ export declare const schemaReadResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObj
168
191
  }>>;
169
192
  }, z.core.$strip>>;
170
193
  hash: z.ZodString;
194
+ fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
195
+ type: z.ZodEnum<{
196
+ string: "string";
197
+ number: "number";
198
+ boolean: "boolean";
199
+ date: "date";
200
+ enum: "enum";
201
+ json: "json";
202
+ }>;
203
+ isOptional: z.ZodBoolean;
204
+ isIndexed: z.ZodBoolean;
205
+ column: z.ZodOptional<z.ZodString>;
206
+ enumValues: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
207
+ }, z.core.$strip>>>;
208
+ relations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
209
+ type: z.ZodEnum<{
210
+ belongsTo: "belongsTo";
211
+ hasMany: "hasMany";
212
+ hasOne: "hasOne";
213
+ }>;
214
+ target: z.ZodString;
215
+ foreignKey: z.ZodString;
216
+ }, z.core.$strip>>>;
171
217
  }, z.core.$strip>>>;
172
218
  }, z.core.$strip>, z.ZodObject<{
173
219
  active: z.ZodLiteral<true>;
@@ -194,6 +240,29 @@ export declare const schemaReadResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObj
194
240
  }>>;
195
241
  }, z.core.$strip>>;
196
242
  hash: z.ZodString;
243
+ fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
244
+ type: z.ZodEnum<{
245
+ string: "string";
246
+ number: "number";
247
+ boolean: "boolean";
248
+ date: "date";
249
+ enum: "enum";
250
+ json: "json";
251
+ }>;
252
+ isOptional: z.ZodBoolean;
253
+ isIndexed: z.ZodBoolean;
254
+ column: z.ZodOptional<z.ZodString>;
255
+ enumValues: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
256
+ }, z.core.$strip>>>;
257
+ relations: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
258
+ type: z.ZodEnum<{
259
+ belongsTo: "belongsTo";
260
+ hasMany: "hasMany";
261
+ hasOne: "hasOne";
262
+ }>;
263
+ target: z.ZodString;
264
+ foreignKey: z.ZodString;
265
+ }, z.core.$strip>>>;
197
266
  }, z.core.$strip>>>;
198
267
  schemaId: z.ZodString;
199
268
  version: z.ZodNumber;
@@ -13,6 +13,10 @@
13
13
  * schemas is what removes them from it.
14
14
  */
15
15
  import { z } from 'zod';
16
+ // Composed, never restated: these are the artifact's own shapes, and a
17
+ // hand-written mirror here would be a second copy of the exact record this
18
+ // response exists to stop withholding.
19
+ import { fieldMetaSchema, relationMetaSchema } from './modelShape.js';
16
20
  import { onStaleModeSchema } from '../coordination/schema.js';
17
21
  /**
18
22
  * One project — an app's own schema, data planes, and keys. `default` marks the
@@ -85,8 +89,39 @@ export const schemaModelResponseSchema = z.object({
85
89
  typename: z.string(),
86
90
  /** Declared disposition, or null for the engine default. */
87
91
  conflict: conflictAxisWireSchema.nullable(),
88
- /** Per-model content hash — the unit of the client's drift check. */
92
+ /**
93
+ * Per-model content hash — the unit of the client's drift check, and the
94
+ * revalidation key for everything below it.
95
+ *
96
+ * `fields` and `relations` change only when this does, so a client reads the
97
+ * shape once and thereafter compares hashes: a cheap poll that refetches on a
98
+ * push and never otherwise. Introspection that costs one request per schema
99
+ * version rather than one per question.
100
+ */
89
101
  hash: z.string(),
102
+ /**
103
+ * The model's fields, by name.
104
+ *
105
+ * The schema artifact has always carried these and this response dropped
106
+ * them, so a caller with no local schema declaration — which is every caller
107
+ * that is not TypeScript — had no way to learn that `task.title` is a
108
+ * required string. It found out from a 400 at the end of a round trip.
109
+ *
110
+ * For an agent that is not merely slow, it is a reasoning detour: a typo
111
+ * discovered through a rejected write costs a turn and an explanation, where
112
+ * the same typo checked locally costs a millisecond.
113
+ *
114
+ * Absent on an artifact written before this was reported, which is why it is
115
+ * optional rather than empty — "this server does not tell me" and "this model
116
+ * has no fields" are different facts.
117
+ */
118
+ fields: z.record(z.string(), fieldMetaSchema).optional(),
119
+ /**
120
+ * The model's relations, by name — what a caller may expand, and into what.
121
+ *
122
+ * Optional for the same reason as {@link fields}.
123
+ */
124
+ relations: z.record(z.string(), relationMetaSchema).optional(),
90
125
  });
91
126
  /**
92
127
  * `GET /api/schema` — the schema active on the caller's plane.
@@ -44,6 +44,13 @@ export type EphemeralKeyRequest = z.infer<typeof ephemeralKeyRequestSchema>;
44
44
  * `auth/capability.ts` — the grant is one structure, and its request form is a
45
45
  * face of it rather than a separate shape. Re-exported here so the wire barrel
46
46
  * stays the single import path for the boundary.
47
+ *
48
+ * This is the only import wire makes for something larger than a schema leaf,
49
+ * so it carries exactly the shape the barrel forwards and nothing else: the
50
+ * grant's own vocabulary — the verb enum, the `model.verb` spelling, the
51
+ * helpers that derive one from the other — is read from `auth/capability.ts`
52
+ * by the callers that build a grant, not through here.
47
53
  */
48
- export { capabilityRequestSchema, grantedOperationSchema, capabilityOperationSchema, } from '../auth/capability.js';
49
- export type { CapabilityRequest, GrantedOperation, CapabilityOperation, } from '../auth/capability.js';
54
+ export { capabilityRequestSchema, capabilityMintResponseSchema, } from '../auth/capability.js';
55
+ export type { CapabilityMintResponse } from '../auth/capability.js';
56
+ export type { CapabilityRequest } from '../auth/capability.js';