@astrale-os/kernel-client 0.6.0-beta.81 → 0.6.0-beta.83

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 (60) hide show
  1. package/dist/client/admission.js +1 -9
  2. package/dist/client/client.js +3 -1
  3. package/dist/client/index.d.ts +1 -1
  4. package/dist/client/index.js +1 -1
  5. package/dist/client/routing-control.d.ts +11 -3
  6. package/dist/client/routing-control.js +18 -3
  7. package/dist/errors/stale-route-error.d.ts +14 -3
  8. package/dist/errors/stale-route-error.js +15 -3
  9. package/dist/errors/transport-error.d.ts +3 -2
  10. package/dist/index.d.ts +1 -1
  11. package/dist/schema/introspection.js +10 -6
  12. package/dist/schema/schema.js +10 -2
  13. package/dist/session/content.js +4 -27
  14. package/dist/session/context/index.d.ts +2 -2
  15. package/dist/session/context/index.js +2 -2
  16. package/dist/session/context/limits.d.ts +1 -0
  17. package/dist/session/context/limits.js +1 -0
  18. package/dist/session/discovery/bundle.js +1 -1
  19. package/dist/session/discovery/discovery.d.ts +12 -4
  20. package/dist/session/discovery/discovery.js +28 -19
  21. package/dist/session/discovery/index.d.ts +8 -1
  22. package/dist/session/discovery/index.js +5 -8
  23. package/dist/session/discovery/legacy/index.d.ts +38 -0
  24. package/dist/session/discovery/legacy/index.js +38 -0
  25. package/dist/session/discovery/legacy/intrinsic.d.ts +33 -0
  26. package/dist/session/discovery/legacy/intrinsic.js +67 -0
  27. package/dist/session/discovery/legacy/projection.d.ts +8 -0
  28. package/dist/session/discovery/legacy/projection.js +17 -0
  29. package/dist/session/discovery/{publication.d.ts → legacy/publication.d.ts} +8 -5
  30. package/dist/session/discovery/{publication.js → legacy/publication.js} +13 -60
  31. package/dist/session/discovery/release.d.ts +29 -0
  32. package/dist/session/discovery/release.js +81 -0
  33. package/dist/session/discovery/response.d.ts +3 -0
  34. package/dist/session/discovery/response.js +59 -0
  35. package/dist/session/discovery/snapshot.d.ts +9 -2
  36. package/dist/session/discovery/snapshot.js +2 -2
  37. package/dist/session/discovery/transport.d.ts +1 -3
  38. package/dist/session/exchange/options.d.ts +12 -4
  39. package/dist/session/exchange/options.js +11 -3
  40. package/dist/session/exchange/presentation.d.ts +3 -2
  41. package/dist/session/index.d.ts +1 -1
  42. package/dist/session/reference.d.ts +3 -6
  43. package/dist/session/reference.js +0 -4
  44. package/dist/session/routing/cache.d.ts +1 -1
  45. package/dist/session/routing/cache.js +18 -2
  46. package/dist/session/routing/dispatch.js +42 -41
  47. package/dist/session/session.d.ts +21 -4
  48. package/dist/session/session.js +25 -13
  49. package/dist/session/session.typecheck.js +20 -0
  50. package/dist/transport/auto/transport.d.ts +0 -1
  51. package/dist/transport/auto/transport.js +0 -2
  52. package/dist/transport/http/response.d.ts +1 -1
  53. package/dist/transport/http/response.js +2 -6
  54. package/dist/transport/http/transport.d.ts +5 -3
  55. package/dist/transport/http/transport.js +40 -19
  56. package/dist/transport/index.d.ts +1 -1
  57. package/dist/transport/transport.d.ts +10 -2
  58. package/dist/transport/websocket/transport.d.ts +0 -3
  59. package/dist/transport/websocket/transport.js +1 -16
  60. package/package.json +6 -7
@@ -19,12 +19,12 @@ export class RouteCache {
19
19
  const delegate = auth.delegate === undefined ? 'anonymous' : await digestText(canonicalDelegate(auth.delegate));
20
20
  return JSON.stringify([source, call.target, credential, delegate]);
21
21
  }
22
- async get(key, sourceCredential, now = Date.now()) {
22
+ async get(key, sourceCredential, now, operationDeadline) {
23
23
  const cached = this.entries.get(key);
24
24
  if (cached === undefined)
25
25
  return undefined;
26
26
  const token = this.keyTokens.get(key);
27
- if (cached.learned.expiresAt <= now) {
27
+ if (cached.learned.expiresAt <= (now ?? Date.now())) {
28
28
  this.invalidate(key);
29
29
  return undefined;
30
30
  }
@@ -38,6 +38,17 @@ export class RouteCache {
38
38
  this.invalidate(key);
39
39
  return undefined;
40
40
  }
41
+ // HMAC verification can outlast route age. Check the exact-entry fence before live facts.
42
+ if (cached.learned.expiresAt <= (now ?? Date.now())) {
43
+ this.invalidate(key);
44
+ return undefined;
45
+ }
46
+ }
47
+ if (operationDeadline !== undefined &&
48
+ cached.credentialRefreshBoundary !== undefined &&
49
+ cached.credentialRefreshBoundary <= operationDeadline) {
50
+ // This operation needs a fresh carrier; a shorter concurrent operation may still reuse it.
51
+ return undefined;
41
52
  }
42
53
  this.entries.delete(key);
43
54
  this.entries.set(key, cached);
@@ -45,6 +56,7 @@ export class RouteCache {
45
56
  }
46
57
  async learn(key, route, credential, sourceCredential, now = Date.now()) {
47
58
  const credentialExpiry = credential === undefined ? undefined : jwtExpiry(credential);
59
+ const credentialRefreshBoundary = credentialExpiry === undefined ? undefined : refreshBoundary(credentialExpiry);
48
60
  const expiresAt = Math.min(now + this.maximumAgeMs, refreshBoundary(credentialExpiry));
49
61
  const entry = Object.freeze({
50
62
  route,
@@ -79,6 +91,7 @@ export class RouteCache {
79
91
  }
80
92
  this.entries.set(key, Object.freeze({
81
93
  learned: entry,
94
+ ...(credentialRefreshBoundary === undefined ? {} : { credentialRefreshBoundary }),
82
95
  ...(integrity === undefined ? {} : { sealed: entry, integrity }),
83
96
  }));
84
97
  this.persist();
@@ -210,6 +223,9 @@ function admitStoredEntry(input, now, maximumAgeMs) {
210
223
  }),
211
224
  sealed,
212
225
  integrity: value.integrity,
226
+ ...(credentialExpiry === undefined
227
+ ? {}
228
+ : { credentialRefreshBoundary: refreshBoundary(credentialExpiry) }),
213
229
  });
214
230
  }
215
231
  catch {
@@ -1,8 +1,8 @@
1
1
  import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
2
2
  import * as invocationProtocol from '@astrale-os/kernel-protocol/invocation';
3
- import { acceptCall, Client, dispatchTrace, isAdmittedMissError, isAdmittedStaleRouteError, } from '../../client/index.js';
3
+ import { acceptCall, Client, dispatchTrace, isAdmittedDeploymentRetiredError, isAdmittedMissError, isAdmittedStaleRouteError, } from '../../client/index.js';
4
4
  import { ClientError, ResponseError } from '../../errors/index.js';
5
- import { runSessionOperation, sessionTransportOptions, allowEndpoint } from '../context/index.js';
5
+ import { runSessionOperation, remainingSessionTime, sessionTransportOptions, allowEndpoint, } from '../context/index.js';
6
6
  import { resolveAuth } from './auth.js';
7
7
  import { selectEnvelope, selectEnvelopeTarget } from './envelope.js';
8
8
  export class SessionDispatcher {
@@ -27,14 +27,14 @@ export class SessionDispatcher {
27
27
  ? resolved
28
28
  : await this.state.exchange.resolve(call, resolved, operation);
29
29
  const source = this.state.kernel;
30
- const cacheKey = await this.state.cache.key(source, call, auth);
30
+ const cacheKey = await runSessionOperation(operation, () => this.state.cache.key(source, call, auth));
31
31
  const dispatch = Object.freeze({
32
32
  call,
33
33
  auth,
34
34
  source,
35
35
  cacheKey,
36
36
  });
37
- let learned = await this.state.cache.get(cacheKey, auth.credential);
37
+ let learned = await runSessionOperation(operation, () => this.state.cache.get(cacheKey, auth.credential, undefined, operation.deadline));
38
38
  if (learned !== undefined) {
39
39
  try {
40
40
  requireCachedRoute(learned, source, call.target, streaming === undefined ? this.state.envelopeTransport : 'http', this.state.websocketAvailable, this.state.policy);
@@ -52,6 +52,7 @@ export class SessionDispatcher {
52
52
  }
53
53
  async dispatchSource(state, routing, options, operation, streaming) {
54
54
  this.state.requireOpen();
55
+ remainingSessionTime(operation);
55
56
  allowEndpoint(this.state.sourceEndpoint, this.state.policy);
56
57
  const client = streaming === undefined
57
58
  ? this.state.pool.clientFor(Object.freeze({ transport: 'http', url: this.state.sourceEndpoint }))
@@ -77,7 +78,7 @@ export class SessionDispatcher {
77
78
  throw new ClientError('Redirect issuer differs from the session-pinned source Kernel identity.');
78
79
  }
79
80
  requireTargetPublication(redirect.route, state.call.target);
80
- const learned = await this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential, state.auth.credential);
81
+ const learned = await runSessionOperation(operation, () => this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential, state.auth.credential));
81
82
  return this.dispatchDestinationWithRecovery(learned, state, options, operation, redirect.trace, streaming);
82
83
  }
83
84
  async dispatchDestinationWithRecovery(learned, state, options, operation, trace, streaming) {
@@ -85,28 +86,29 @@ export class SessionDispatcher {
85
86
  return await this.dispatchDestination(learned, state.call, options, operation, state.source, trace, streaming);
86
87
  }
87
88
  catch (cause) {
88
- const stale = isAdmittedStaleRouteError(cause);
89
- const miss = isAdmittedMissError(cause);
90
- if (isRouteCompatibilityConflict(cause)) {
91
- this.state.cache.delete(state.cacheKey);
89
+ const control = routingControl(cause);
90
+ if (control === undefined) {
91
+ if (isRouteCompatibilityConflict(cause))
92
+ this.state.cache.delete(state.cacheKey);
92
93
  throw cause;
93
94
  }
94
- if (!stale && !miss)
95
- throw cause;
96
95
  this.state.cache.delete(state.cacheKey);
97
96
  if (!operation.claimRecovery())
98
97
  throw cause;
99
- const routing = stale
100
- ? Object.freeze({
98
+ // A stale or retired route names the release the destination no longer serves; a miss
99
+ // names a Path, so the source receives no digest for it.
100
+ const routing = control === 'miss'
101
+ ? undefined
102
+ : Object.freeze({
101
103
  kind: 'refresh',
102
- stale: learned.route.via.publication.etag,
103
- })
104
- : undefined;
104
+ stale: invocationProtocol.routedDigest(learned.route.via),
105
+ });
105
106
  const refreshed = await this.dispatchSource(state, routing, options, operation, streaming);
106
107
  return this.followSource(refreshed, state, options, operation, streaming);
107
108
  }
108
109
  }
109
110
  async dispatchDestination(learned, call, options, operation, source, trace, streaming) {
111
+ remainingSessionTime(operation);
110
112
  if (learned.route.via.issuer !== source) {
111
113
  throw new ClientError('Learned route issuer differs from the session-pinned source Kernel.');
112
114
  }
@@ -127,30 +129,16 @@ export class SessionDispatcher {
127
129
  ...sessionTransportOptions(operation),
128
130
  routing: learned.route.via,
129
131
  };
130
- // The source has admitted this fingerprint against the exact Publication carried by Via.
131
- // Pin that revision at the destination so existing Workers can execute the same admitted
132
- // callable without understanding the newer fingerprint Target representation. The cache
133
- // remains partitioned by the original fingerprint, and stale recovery re-admits it at source.
134
- const destinationCall = typeof call.target !== 'string' && call.target.fingerprint !== undefined
135
- ? {
136
- ...call,
137
- target: invocationProtocol.Target({
138
- callable: call.target.callable,
139
- revision: learned.route.via.publication.revision,
140
- ...(call.target.instance === undefined ? {} : { instance: call.target.instance }),
141
- }),
142
- }
143
- : call;
144
132
  const result = await runSessionOperation(operation, () => streaming?.body !== undefined
145
133
  ? trace === undefined
146
- ? authenticated(client, learned.credential).dispatch(destinationCall, {
134
+ ? authenticated(client, learned.credential).dispatch(call, {
147
135
  ...binaryRequestOptions,
148
136
  body: streaming.body,
149
137
  })
150
- : dispatchTrace(client, learned.credential, destinationCall, { ...binaryRequestOptions, body: streaming.body }, trace)
138
+ : dispatchTrace(client, learned.credential, call, { ...binaryRequestOptions, body: streaming.body }, trace)
151
139
  : trace === undefined
152
- ? authenticated(client, learned.credential).dispatch(destinationCall, requestOptions)
153
- : dispatchTrace(client, learned.credential, destinationCall, requestOptions, trace));
140
+ ? authenticated(client, learned.credential).dispatch(call, requestOptions)
141
+ : dispatchTrace(client, learned.credential, call, requestOptions, trace));
154
142
  if (result.kind === 'redirect') {
155
143
  throw new ClientError('A destination redirect is invalid; only the source Kernel may redirect.');
156
144
  }
@@ -175,6 +163,20 @@ function requireCachedRoute(learned, source, target, envelopeTransport, websocke
175
163
  requireTargetPublication(learned.route, target);
176
164
  selectEnvelopeTarget(learned.route.endpoint, envelopeTransport, websocketAvailable, policy);
177
165
  }
166
+ /** The privately witnessed pre-handler control a destination failure carries, if any. */
167
+ function routingControl(input) {
168
+ if (isAdmittedStaleRouteError(input))
169
+ return 'stale';
170
+ if (isAdmittedMissError(input))
171
+ return 'miss';
172
+ if (isAdmittedDeploymentRetiredError(input))
173
+ return 'retired';
174
+ return undefined;
175
+ }
176
+ /**
177
+ * A failure that proves the warm route can no longer serve this call without witnessing a
178
+ * pre-handler control: the route is evicted for later calls, but the invocation is never replayed.
179
+ */
178
180
  function isRouteCompatibilityConflict(input) {
179
181
  if (!(input instanceof ResponseError))
180
182
  return false;
@@ -183,17 +185,16 @@ function isRouteCompatibilityConflict(input) {
183
185
  message: input.message,
184
186
  ...(input.data === undefined ? {} : { data: input.data }),
185
187
  });
186
- return (failure?.data.code === invocationProtocol.SCHEMA_REVISION_MISMATCH ||
187
- failure?.data.code === invocationProtocol.CALLABLE_FINGERPRINT_MISMATCH ||
188
- failure?.data.code === invocationProtocol.SERVICE_TRUST_REQUIRED);
188
+ return (failure?.data.code === invocationProtocol.CALLABLE_FINGERPRINT_MISMATCH ||
189
+ failure?.data.code === invocationProtocol.SERVICE_TRUST_REQUIRED ||
190
+ failure?.data.code === invocationProtocol.DEPLOYMENT_RETIRED);
189
191
  }
190
192
  function requireTargetPublication(route, target) {
191
193
  if (typeof target === 'string')
192
194
  return;
193
- const actual = route.via.publication;
194
- if (actual.origin !== Key.origin(target.callable) ||
195
- (target.revision !== undefined && actual.revision !== target.revision)) {
196
- throw new ClientError('Resolved route does not match the expected Schema revision.');
195
+ const actual = 'release' in route.via ? route.via.release : route.via.publication;
196
+ if (actual.origin !== Key.origin(target.callable)) {
197
+ throw new ClientError('Resolved route does not match the Target origin.');
197
198
  }
198
199
  }
199
200
  export function requireResultKind(expected, result) {
@@ -13,7 +13,7 @@ import type { WebSocketConstructor } from '../transport/websocket/index.js';
13
13
  import type { ContentApi } from './content.js';
14
14
  import type { SessionControlOptions, SessionPolicy } from './context/index.js';
15
15
  import type { SessionCredential } from './credential/index.js';
16
- import type { SessionSnapshot } from './discovery/index.js';
16
+ import type { IntrinsicRelease, SessionSnapshot } from './discovery/index.js';
17
17
  import type { SessionExchangeOptions, SessionExchangeRequest } from './exchange/index.js';
18
18
  import type { CallableReference, InputModeOfReference, InputOfReference, OutputOfReference } from './reference.js';
19
19
  import type { SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, SessionRequestOptionsWithBody } from './routing/index.js';
@@ -29,7 +29,8 @@ export interface ClientSessionOptions {
29
29
  /**
30
30
  * Present each Domain callable with a credential its declaring Domain exchanges for the `auth`
31
31
  * caller, so the Domain is the principal and the caller is `auth`. Kernel and local callables keep
32
- * the `auth` credential.
32
+ * the `auth` credential. `connect` defaults to `auto` when `auth` is present; `new ClientSession`
33
+ * keeps source authority when this option is omitted. `{ mode: 'source' }` keeps it for every Call.
33
34
  */
34
35
  readonly exchange?: SessionExchangeOptions;
35
36
  /** Optional caller-owned persistence for bounded confidential route artifacts. */
@@ -37,6 +38,12 @@ export interface ClientSessionOptions {
37
38
  readonly policy: SessionPolicy;
38
39
  readonly envelopeTransport?: SessionEnvelopeTransport;
39
40
  readonly onWarning?: (warning: ClientWarning) => void;
41
+ /** Bounds the source Kernel's own `release.json`. */
42
+ readonly maximumReleaseBytes?: number;
43
+ /**
44
+ * Bounds every Publication the Session reads: the version 3 `domain.json` a Host that predates
45
+ * `release.json` serves, and the Publications Schema results carry.
46
+ */
40
47
  readonly maximumPublicationBytes?: number;
41
48
  readonly maximumBundleBytes?: number;
42
49
  readonly maximumResponseBytes?: number;
@@ -73,7 +80,17 @@ export declare class ClientSession {
73
80
  * `createSessionCredentialProvider`, for as long as it covers the calls it carries.
74
81
  */
75
82
  exchange(domain: string, options?: SessionExchangeRequest): Promise<SessionCredential>;
76
- /** Revalidate the intrinsic Publication without acquiring its bundle or Views. */
83
+ /**
84
+ * Revalidate the source Kernel's own release (`release.json`) without acquiring its bundle or
85
+ * Views; a Host that predates intrinsic `release.json` yields its version 3 Publication instead.
86
+ */
87
+ release(options?: SessionControlOptions): Promise<IntrinsicRelease>;
88
+ /**
89
+ * Revalidate the intrinsic release and return its v3 document: the Host's own version 3
90
+ * Publication, or the frozen v3 projection of its `DomainRelease`.
91
+ *
92
+ * @deprecated Use `release()`. Deleted when no supported Host predates intrinsic `release.json`.
93
+ */
77
94
  publication(options?: SessionControlOptions): Promise<publication.Publication>;
78
95
  snapshot(options?: SessionControlOptions): Promise<SessionSnapshot>;
79
96
  viewsFor(target: PathLike, options?: SessionControlOptions): Promise<ViewCatalog>;
@@ -91,5 +108,5 @@ export declare class ClientSession {
91
108
  private invokeDomainMethod;
92
109
  private operation;
93
110
  }
94
- /** Open one caller-owned conversation with a Kernel using secure defaults. */
111
+ /** Open a Kernel conversation with secure defaults and automatic Domain exchange when authenticated. */
95
112
  export declare function connect(options: ConnectOptions): ClientSession;
@@ -12,7 +12,7 @@ import { HttpTransport } from '../transport/http/index.js';
12
12
  import { WebSocketTransport } from '../transport/websocket/index.js';
13
13
  import { admitFunction } from './admission.js';
14
14
  import { createContentApi } from './content.js';
15
- import { admitControlOptions, admitPolicy, createSessionOperation, DEFAULT_MAXIMUM_BUNDLE_BYTES, DEFAULT_MAXIMUM_PUBLICATION_BYTES, DEFAULT_MAXIMUM_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, admitKernel, allowEndpoint, kernelInvocationEndpoint, platformDuration, positive, } from './context/index.js';
15
+ import { admitControlOptions, admitPolicy, createSessionOperation, DEFAULT_MAXIMUM_BUNDLE_BYTES, DEFAULT_MAXIMUM_PUBLICATION_BYTES, DEFAULT_MAXIMUM_RELEASE_BYTES, DEFAULT_MAXIMUM_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, admitKernel, allowEndpoint, kernelInvocationEndpoint, platformDuration, positive, } from './context/index.js';
16
16
  import { createSessionDiscovery } from './discovery/index.js';
17
17
  import { acquireDomainCredential, admitExchangeOptions, admitExchangeRequest, createSessionExchange, } from './exchange/index.js';
18
18
  import { admitReference } from './reference.js';
@@ -51,6 +51,7 @@ export class ClientSession {
51
51
  if (options.client !== undefined && options.pool !== undefined) {
52
52
  throw new TypeError('ClientSession accepts either client or pool, not both.');
53
53
  }
54
+ const maximumReleaseBytes = positive(options.maximumReleaseBytes ?? DEFAULT_MAXIMUM_RELEASE_BYTES, 'maximumReleaseBytes');
54
55
  const maximumPublicationBytes = positive(options.maximumPublicationBytes ?? DEFAULT_MAXIMUM_PUBLICATION_BYTES, 'maximumPublicationBytes');
55
56
  const maximumBundleBytes = positive(options.maximumBundleBytes ?? DEFAULT_MAXIMUM_BUNDLE_BYTES, 'maximumBundleBytes');
56
57
  const maximumResponseBytes = positive(options.maximumResponseBytes ?? DEFAULT_MAXIMUM_RESPONSE_BYTES, 'maximumResponseBytes');
@@ -82,6 +83,7 @@ export class ClientSession {
82
83
  kernel: this.kernel,
83
84
  fetch: fetchImpl,
84
85
  policy,
86
+ maximumReleaseBytes,
85
87
  maximumPublicationBytes,
86
88
  maximumBundleBytes,
87
89
  requireOpen: () => this.requireOpen(),
@@ -152,7 +154,19 @@ export class ClientSession {
152
154
  const request = admitExchangeRequest(domain, options);
153
155
  return acquireDomainCredential(this.exchangeDependencies, request, this.operation(request.control));
154
156
  }
155
- /** Revalidate the intrinsic Publication without acquiring its bundle or Views. */
157
+ /**
158
+ * Revalidate the source Kernel's own release (`release.json`) without acquiring its bundle or
159
+ * Views; a Host that predates intrinsic `release.json` yields its version 3 Publication instead.
160
+ */
161
+ release(options) {
162
+ return this.discovery.release(this.operation(admitControlOptions(options)));
163
+ }
164
+ /**
165
+ * Revalidate the intrinsic release and return its v3 document: the Host's own version 3
166
+ * Publication, or the frozen v3 projection of its `DomainRelease`.
167
+ *
168
+ * @deprecated Use `release()`. Deleted when no supported Host predates intrinsic `release.json`.
169
+ */
156
170
  publication(options) {
157
171
  return this.discovery.publication(this.operation(admitControlOptions(options)));
158
172
  }
@@ -178,16 +192,14 @@ export class ClientSession {
178
192
  const instance = last?.kind === 'method' && last.dispatch === 'instance'
179
193
  ? Path.fromAST({ anchor, steps: steps.slice(0, -1) }).raw
180
194
  : undefined;
181
- // A generated reference pins the callable meaning, a hand-written one its revision, and a Kernel
182
- // reference nothing, like every other syscall the Session addresses.
195
+ // A generated reference pins the callable meaning; a Kernel reference pins nothing, like every
196
+ // other syscall the Session addresses.
183
197
  const call = {
184
198
  target: Object.freeze({
185
199
  callable: admittedReference.callable,
186
- ...(admittedReference.fingerprint !== undefined
187
- ? { fingerprint: admittedReference.fingerprint }
188
- : admittedReference.revision !== undefined
189
- ? { revision: admittedReference.revision }
190
- : {}),
200
+ ...(admittedReference.fingerprint === undefined
201
+ ? {}
202
+ : { fingerprint: admittedReference.fingerprint }),
191
203
  ...(instance === undefined ? {} : { instance }),
192
204
  }),
193
205
  input,
@@ -241,7 +253,6 @@ export class ClientSession {
241
253
  throw new TypeError(`Method owner ${method.owner.ref.origin} is absent.`);
242
254
  return this.invoke({
243
255
  origin: owner.origin,
244
- revision: owner.revision,
245
256
  fingerprint: callableFingerprint(method),
246
257
  callable: method.key,
247
258
  target: Path.instanceMethod(Path.id(node.id), method.key),
@@ -253,16 +264,17 @@ export class ClientSession {
253
264
  return createSessionOperation(this.lifecycle.signal, options, this.timeoutMs);
254
265
  }
255
266
  }
256
- /** Open one caller-owned conversation with a Kernel using secure defaults. */
267
+ /** Open a Kernel conversation with secure defaults and automatic Domain exchange when authenticated. */
257
268
  export function connect(options) {
258
269
  if (options === null || typeof options !== 'object') {
259
270
  throw new TypeError('Connect options are invalid.');
260
271
  }
261
- const { url, ...rest } = options;
272
+ const { url, exchange, ...rest } = options;
262
273
  return new ClientSession({
263
274
  ...rest,
264
275
  kernel: url,
265
- policy: options.policy ?? DEFAULT_SESSION_POLICY,
276
+ exchange: exchange === undefined && rest.auth !== undefined ? { mode: 'auto' } : exchange,
277
+ policy: rest.policy ?? DEFAULT_SESSION_POLICY,
266
278
  });
267
279
  }
268
280
  function admitClientPool(input) {
@@ -12,6 +12,9 @@ void envelope;
12
12
  void policy;
13
13
  void request;
14
14
  void snapshot;
15
+ const observedRelease = session.release(control);
16
+ void observedRelease;
17
+ void observedRelease.then((current) => current.version === 4 ? current.build.digest : current.etag);
15
18
  const observedPublication = session.publication(control);
16
19
  void observedPublication;
17
20
  const admitted = session.auth.admit({
@@ -28,6 +31,23 @@ const credentials = createSessionCredentialProvider({
28
31
  // A credential provider is a Session authentication resolver; no adapter stands between them.
29
32
  const credentialAuth = credentials;
30
33
  void new ClientSession({ ...options, auth: credentialAuth });
34
+ void new ClientSession({ ...options, auth: credentialAuth, exchange: { mode: 'auto' } });
35
+ void new ClientSession({
36
+ ...options,
37
+ auth: credentialAuth,
38
+ exchange: { mode: 'auto', ttlSeconds: 300 },
39
+ });
40
+ void new ClientSession({ ...options, auth: credentialAuth, exchange: { mode: 'source' } });
41
+ // @ts-expect-error Exchange options always name their mode.
42
+ void new ClientSession({ ...options, auth: credentialAuth, exchange: {} });
43
+ // @ts-expect-error Exchange options always name their mode.
44
+ void new ClientSession({ ...options, auth: credentialAuth, exchange: { ttlSeconds: 300 } });
45
+ // @ts-expect-error Source mode requests no Domain credential lifetime.
46
+ void new ClientSession({ ...options, exchange: { mode: 'source', ttlSeconds: 300 } });
47
+ // @ts-expect-error Exchange accepts named modes, not a boolean switch.
48
+ void new ClientSession({ ...options, exchange: true });
49
+ // @ts-expect-error Unknown exchange modes are not admitted.
50
+ void new ClientSession({ ...options, exchange: { mode: 'invalid' } });
31
51
  // @ts-expect-error A minted credential must carry its own absolute expiry.
32
52
  void createSessionCredentialProvider({ ttlSeconds: 60, mint: async () => minted.credential });
33
53
  const dynamicReference = reference(dynamicDomain, dynamicMethod)(Path.id(NodeId('dynamic-node')));
@@ -7,7 +7,6 @@ export interface AutoTransportOptions {
7
7
  readonly http: string;
8
8
  readonly websocket: string;
9
9
  };
10
- readonly codec?: 'json' | 'msgpack';
11
10
  readonly fetch?: Fetch;
12
11
  readonly websocket?: WebSocketConstructor;
13
12
  readonly headers?: Readonly<Record<string, string>>;
@@ -26,7 +26,6 @@ export class AutoTransport {
26
26
  this.onWarning = options.onWarning;
27
27
  this.http = new HttpTransport({
28
28
  url: options.endpoints.http,
29
- ...(options.codec === undefined ? {} : { codec: options.codec }),
30
29
  ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
31
30
  ...(options.headers === undefined ? {} : { headers: options.headers }),
32
31
  timeoutMs: this.timeoutMs,
@@ -39,7 +38,6 @@ export class AutoTransport {
39
38
  });
40
39
  this.websocket = new WebSocketTransport({
41
40
  url: options.endpoints.websocket,
42
- ...(options.codec === undefined ? {} : { codec: options.codec }),
43
41
  ...(options.websocket === undefined ? {} : { websocket: options.websocket }),
44
42
  timeoutMs: this.timeoutMs,
45
43
  ...(options.maximumRequestBytes === undefined
@@ -1,6 +1,6 @@
1
1
  import * as codecs from '@astrale-os/kernel-protocol/codecs';
2
2
  import * as protocol from '@astrale-os/kernel-protocol/invocation';
3
- export declare function codecForResponse(contentType: string | null): Promise<codecs.Codec | undefined>;
3
+ export declare function codecForResponse(contentType: string | null): codecs.Codec | undefined;
4
4
  export declare function readBoundedBody(response: Response, maximumBytes: number, signal: AbortSignal): Promise<Uint8Array>;
5
5
  export declare function requireId(expected: protocol.RequestId, received: protocol.RequestId): void;
6
6
  export declare function decodeProtocol<T>(label: string, decode: () => T): T;
@@ -2,12 +2,8 @@ import * as codecs from '@astrale-os/kernel-protocol/codecs';
2
2
  import * as protocol from '@astrale-os/kernel-protocol/invocation';
3
3
  import { ProtocolError, ResponseError } from '../../errors/index.js';
4
4
  import { asError } from '../index.js';
5
- export async function codecForResponse(contentType) {
6
- const mediaType = normalizeMediaType(contentType);
7
- const supported = mediaType === codecs.CONTENT_TYPE_MSGPACK
8
- ? [codecs.json, await codecs.loadMsgpack()]
9
- : [codecs.json];
10
- return protocol.http.codecForContentType(contentType, supported);
5
+ export function codecForResponse(contentType) {
6
+ return protocol.http.codecForContentType(contentType, [codecs.json]);
11
7
  }
12
8
  export async function readBoundedBody(response, maximumBytes, signal) {
13
9
  const declared = response.headers.get('content-length');
@@ -3,7 +3,6 @@ import type { RequestOptions, Transport, TransportRequest, TransportResult } fro
3
3
  export type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
4
4
  export interface HttpTransportOptions {
5
5
  readonly url: string;
6
- readonly codec?: 'json' | 'msgpack';
7
6
  /** Inject this capability when the host does not provide global Fetch. */
8
7
  readonly fetch?: Fetch;
9
8
  readonly headers?: Readonly<Record<string, string>>;
@@ -16,7 +15,6 @@ export interface HttpTransportOptions {
16
15
  /** HTTP implementation of the universal invocation protocol. */
17
16
  export declare class HttpTransport implements Transport {
18
17
  readonly url: string;
19
- private readonly codecName;
20
18
  private readonly fetchImpl;
21
19
  private readonly headers;
22
20
  private readonly timeoutMs;
@@ -29,7 +27,11 @@ export declare class HttpTransport implements Transport {
29
27
  dispatchBinary(input: TransportRequest, body: Blob, options?: RequestOptions): Promise<TransportResult>;
30
28
  private dispatchWith;
31
29
  private fetchResponse;
30
+ /**
31
+ * Admit the reserved retirement control only as the exact 410, header and envelope triple; the
32
+ * header beside any other status, value or body is an invalid response, never an ordinary error.
33
+ */
34
+ private decodeDeploymentRetired;
32
35
  private decodeBinary;
33
36
  private nextRequestId;
34
- private requestCodec;
35
37
  }
@@ -1,6 +1,7 @@
1
1
  import { canonicalMediaType } from '@astrale-os/kernel-protocol/artifact';
2
2
  import * as codecs from '@astrale-os/kernel-protocol/codecs';
3
3
  import * as protocol from '@astrale-os/kernel-protocol/invocation';
4
+ import * as retirement from '@astrale-os/kernel-protocol/routes/deployment-retired';
4
5
  import { ProtocolError, ResponseError, TransportError } from '../../errors/index.js';
5
6
  import { invocationFailure } from '../failure.js';
6
7
  import { asError, controlledSignal, positiveInteger, requireLive, } from '../index.js';
@@ -13,7 +14,6 @@ const DEFAULT_MAXIMUM_RESPONSE_BYTES = 16 * 1024 * 1024;
13
14
  /** HTTP implementation of the universal invocation protocol. */
14
15
  export class HttpTransport {
15
16
  url;
16
- codecName;
17
17
  fetchImpl;
18
18
  headers;
19
19
  timeoutMs;
@@ -25,7 +25,6 @@ export class HttpTransport {
25
25
  throw new TypeError('HttpTransport options must be an object.');
26
26
  }
27
27
  this.url = normalizeUrl(options.url);
28
- this.codecName = admitCodecName(options.codec);
29
28
  this.fetchImpl = admitFetch(options.fetch);
30
29
  this.headers = admitHeaders(options.headers ?? {});
31
30
  this.timeoutMs = positiveInteger(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 'timeoutMs');
@@ -47,7 +46,7 @@ export class HttpTransport {
47
46
  async dispatchWith(input, options, binaryBody) {
48
47
  const requestId = this.nextRequestId();
49
48
  const timeoutMs = positiveInteger(options.timeoutMs ?? this.timeoutMs, 'timeoutMs');
50
- const codec = await this.requestCodec();
49
+ const codec = codecs.json;
51
50
  const { trace: traceInput, ...invocationInput } = input;
52
51
  const trace = traceInput === undefined ? undefined : protocol.admitTrace(traceInput);
53
52
  const request = protocol.decodeRequest({
@@ -82,7 +81,15 @@ export class HttpTransport {
82
81
  const binaryDelivery = response.headers.get(protocol.http.BINARY_DELIVERY_FIELD);
83
82
  const hasStreamSignal = streamFraming !== null;
84
83
  const hasBinarySignal = binaryId !== null || binaryManifest !== null || binaryDelivery !== null;
85
- const responseCodec = await codecForResponse(response.headers.get('content-type'));
84
+ const responseCodec = codecForResponse(response.headers.get('content-type'));
85
+ if (response.headers.has(retirement.HTTP_DEPLOYMENT_RETIRED)) {
86
+ if (hasStreamSignal || hasBinarySignal) {
87
+ throw new ProtocolError('server returned a deployment-retired control with a stream or binary signal.');
88
+ }
89
+ const retired = await this.decodeDeploymentRetired(response, responseCodec, control);
90
+ requireLive(control);
91
+ return retired;
92
+ }
86
93
  if (!response.ok && responseCodec === undefined && !hasStreamSignal && !hasBinarySignal) {
87
94
  throw invocationFailure(`server returned HTTP ${response.status} without an invocation response.`, 'receive', { delivery: 'unknown' });
88
95
  }
@@ -182,6 +189,29 @@ export class HttpTransport {
182
189
  };
183
190
  return this.fetchImpl(this.url, init);
184
191
  }
192
+ /**
193
+ * Admit the reserved retirement control only as the exact 410, header and envelope triple; the
194
+ * header beside any other status, value or body is an invalid response, never an ordinary error.
195
+ */
196
+ async decodeDeploymentRetired(response, codec, control) {
197
+ if (codec === undefined) {
198
+ throw new ProtocolError('server returned a deployment-retired control without an envelope.');
199
+ }
200
+ requireNoStore(response);
201
+ const body = await readBoundedBody(response, this.maximumResponseBytes, control.signal);
202
+ const decoded = decodeProtocol('deployment-retired body', () => codec.decode(body));
203
+ const retired = decodeProtocol('deployment-retired control', () => retirement.decodeDeploymentRetired(response.status, response.headers, decoded));
204
+ const envelope = decodeProtocol('deployment-retired envelope', () => protocol.decodeResponse(decoded));
205
+ if (retired === undefined || !('error' in envelope)) {
206
+ throw new ProtocolError('server returned an invalid deployment-retired control.');
207
+ }
208
+ return Object.freeze({
209
+ kind: 'deployment-retired',
210
+ reason: retired.reason,
211
+ message: envelope.error.message,
212
+ invocation: envelope.invocation,
213
+ });
214
+ }
185
215
  async decodeBinary(response, requestId, binaryId, streaming, control) {
186
216
  if (binaryId !== String(requestId)) {
187
217
  throw new ProtocolError(`binary response requestId ${JSON.stringify(binaryId)} does not match request requestId ${requestId}.`);
@@ -197,14 +227,13 @@ export class HttpTransport {
197
227
  if (streaming) {
198
228
  if (response.body === null)
199
229
  throw new ProtocolError('streaming binary has no body.');
200
- // An intermediary may rewrite Content-Length (for example when it compresses the body), so the
201
- // protocol length wins; Content-Length only counts for a Server that predates the field.
202
- const declared = response.headers.get(protocol.http.BINARY_LENGTH_FIELD);
203
- const length = declared ?? response.headers.get('content-length');
230
+ // An intermediary may rewrite Content-Length (for example when it compresses the body), so
231
+ // only the protocol length counts.
232
+ const length = response.headers.get(protocol.http.BINARY_LENGTH_FIELD);
204
233
  const expectedBytes = length === null ? undefined : Number(length);
205
234
  if (length !== null &&
206
235
  (!/^(?:0|[1-9][0-9]*)$/u.test(length) || !Number.isSafeInteger(expectedBytes))) {
207
- throw new ProtocolError(`streaming binary has an invalid ${declared === null ? 'Content-Length' : protocol.http.BINARY_LENGTH_FIELD}.`);
236
+ throw new ProtocolError(`streaming binary has an invalid ${protocol.http.BINARY_LENGTH_FIELD}.`);
208
237
  }
209
238
  if (expectedBytes !== undefined && expectedBytes > this.maximumResponseBytes) {
210
239
  throw new ProtocolError(`streaming binary exceeds maximumResponseBytes (${this.maximumResponseBytes}).`);
@@ -215,6 +244,8 @@ export class HttpTransport {
215
244
  invocation,
216
245
  value: {
217
246
  body: new HttpByteStream(response.body, this.maximumResponseBytes, control, expectedBytes),
247
+ // The byte stream enforces this exact count through its final read.
248
+ ...(expectedBytes === undefined ? {} : { length: expectedBytes }),
218
249
  mediaType,
219
250
  ...(response.status === 200 ? {} : { status: response.status }),
220
251
  ...(Object.keys(headers).length === 0 ? {} : { headers }),
@@ -249,9 +280,6 @@ export class HttpTransport {
249
280
  this.nextId = requestId === Number.MAX_SAFE_INTEGER ? 1 : requestId + 1;
250
281
  return requestId;
251
282
  }
252
- requestCodec() {
253
- return this.codecName === 'msgpack' ? codecs.loadMsgpack() : Promise.resolve(codecs.json);
254
- }
255
283
  }
256
284
  function redirectResult(response) {
257
285
  return Object.freeze({
@@ -340,13 +368,6 @@ function transportFailure(input, control, parent, timeoutMs, url, phase, deliver
340
368
  delivery,
341
369
  });
342
370
  }
343
- function admitCodecName(input) {
344
- if (input === undefined || input === 'json')
345
- return 'json';
346
- if (input === 'msgpack')
347
- return input;
348
- throw new TypeError('codec must be "json" or "msgpack".');
349
- }
350
371
  function admitFetch(input) {
351
372
  const implementation = input ?? globalThis.fetch;
352
373
  if (typeof implementation !== 'function') {
@@ -1,2 +1,2 @@
1
- export type { RequestOptions, MissControl, StaleControl, Transport, TransportRequest, TransportResult, } from './transport.js';
1
+ export type { RequestOptions, DeploymentRetired, MissControl, StaleControl, Transport, TransportRequest, TransportResult, } from './transport.js';
2
2
  export { asError, controlledSignal, positiveInteger, requireLive, type ControlledSignal, } from './deadline.js';
@@ -1,4 +1,4 @@
1
- import type { invocation as protocol } from '@astrale-os/kernel-protocol';
1
+ import type { invocation as protocol, routes } from '@astrale-os/kernel-protocol';
2
2
  import type { Result } from '../client/result.js';
3
3
  export interface RequestOptions {
4
4
  readonly signal?: AbortSignal;
@@ -25,7 +25,15 @@ export interface MissControl {
25
25
  readonly kind: 'miss';
26
26
  readonly invocation: protocol.InvocationId;
27
27
  }
28
- export type TransportResult = Result | StaleControl | MissControl;
28
+ /**
29
+ * A retired deployment's reserved 410 control: its typed `DEPLOYMENT_RETIRED` reason with the
30
+ * message and invocation of the envelope it carried, which Client raises unchanged.
31
+ */
32
+ export interface DeploymentRetired extends routes.DeploymentRetired {
33
+ readonly message: string;
34
+ readonly invocation: protocol.InvocationId;
35
+ }
36
+ export type TransportResult = Result | StaleControl | MissControl | DeploymentRetired;
29
37
  export interface Transport {
30
38
  /** Primary embedding-environment adapter for a complete invocation boundary. */
31
39
  dispatch(input: TransportRequest, options?: RequestOptions): Promise<TransportResult>;