@cortexkit/subc-client 0.8.1 → 0.11.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/subc-client",
3
- "version": "0.8.1",
3
+ "version": "0.11.1",
4
4
  "description": "TypeScript client for the subc daemon. Wire-compatible (byte-for-byte) with the Rust subc-transport handshake and subc-protocol envelope.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,4 +30,4 @@
30
30
  "bun-types": "^1.3.14",
31
31
  "typescript": "^5.6.0"
32
32
  }
33
- }
33
+ }
package/src/client.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  buildFlags,
21
21
  encodeFrame,
22
22
  FrameType,
23
+ hasBinary,
23
24
  Priority,
24
25
  type Frame,
25
26
  } from "./envelope.js";
@@ -112,16 +113,59 @@ export interface RouteOpenOptions {
112
113
  consumerCapabilities?: string[];
113
114
  }
114
115
 
116
+ export interface CatalogCapabilityRequirement {
117
+ capability: string;
118
+ need: "required" | "optional";
119
+ }
120
+
121
+ export interface CatalogCapabilities {
122
+ provides: string[];
123
+ requires: CatalogCapabilityRequirement[];
124
+ must_never_reach: string[];
125
+ }
126
+
127
+ /** A self-signal's anchor relative to the external surface it shapes. */
128
+ export type CatalogSignalAnchor =
129
+ | "fixed_interval"
130
+ | { event: { event: string } };
131
+
132
+ /** The source of a self-signal's effective cadence. */
133
+ export type CatalogSignalCadence =
134
+ | { literal: { interval_ms: number } }
135
+ | { derived: { source: string } };
136
+
137
+ /** A self-signal declaration mirrored from a registered module manifest. */
138
+ export interface CatalogSelfSignalDeclaration {
139
+ name: string;
140
+ /** Open string for forward-compatible self-signal kinds. */
141
+ kind: string;
142
+ effect: "observe" | "mutate";
143
+ anchored_to: CatalogSignalAnchor;
144
+ cadence?: CatalogSignalCadence | null;
145
+ domain?: string | null;
146
+ note?: string | null;
147
+ }
148
+
115
149
  export interface CatalogEntry {
116
150
  module_id: string;
117
151
  roles: unknown[];
118
152
  control_ops: string[];
153
+ /** Static capability claims mirrored from the registered module manifest. */
154
+ capabilities?: CatalogCapabilities | null;
155
+ /** Self-signal declarations mirrored verbatim from the registered manifest. */
156
+ self_signals?: CatalogSelfSignalDeclaration[] | null;
119
157
  }
120
158
 
121
159
  export interface RequestOptions {
122
160
  priority?: Priority;
123
161
  admissionClass?: AdmissionClass;
124
162
  timeoutMs?: number;
163
+ /**
164
+ * Set when the request body is opaque bytes rather than JSON. This flag is the
165
+ * receiver's only signal about the body representation; reply decoding follows
166
+ * the response frame's flag instead of this request option.
167
+ */
168
+ binary?: boolean;
125
169
  /** Called for each interim PUSH / StreamData frame before the terminal reply. */
126
170
  onProgress?: (body: Uint8Array) => void;
127
171
  }
@@ -208,6 +252,16 @@ export class SubcCallError extends Error {
208
252
  super(message);
209
253
  this.name = "SubcCallError";
210
254
  }
255
+
256
+ /**
257
+ * Machine-parsable payload from the wire `ErrorBody.detail`, surfaced from
258
+ * the underlying Error frame when one caused this failure. Typed refusal
259
+ * reasons (for example certification refusals) ride this field; consumers
260
+ * classify on it and must not have to reach into `cause`.
261
+ */
262
+ get detail(): unknown {
263
+ return this.cause instanceof SubcError ? this.cause.detail : undefined;
264
+ }
211
265
  }
212
266
 
213
267
  /**
@@ -228,11 +282,24 @@ export class SubcError extends Error {
228
282
  constructor(
229
283
  message: string,
230
284
  readonly code?: string,
285
+ /**
286
+ * Machine-parsable payload from the wire `ErrorBody.detail` field, carried
287
+ * verbatim. Providers use it for typed refusal reasons (for example
288
+ * synapse's certification-refusal reasons); dropping it here would strand
289
+ * those reasons at the transport boundary.
290
+ */
291
+ readonly detail?: unknown,
231
292
  ) {
232
293
  super(message);
233
294
  }
234
295
  }
235
296
 
297
+ function requireBinaryBody(body: unknown): Uint8Array {
298
+ if (body instanceof Uint8Array) return body;
299
+ const type = body === null ? "null" : Array.isArray(body) ? "array" : typeof body;
300
+ throw new SubcError(`binary request body must be a Uint8Array; got ${type}`, "binary_body_required");
301
+ }
302
+
236
303
  interface Pending {
237
304
  handle: RouteHandle | null;
238
305
  resolve: (frame: Frame) => void;
@@ -261,6 +328,14 @@ export interface ConnectOptions {
261
328
  reconnectBackoff?: ReconnectBackoff;
262
329
  /** Injectable sleep for timer-free reconnect tests. */
263
330
  sleep?: (ms: number) => Promise<void>;
331
+ /**
332
+ * How long managed calls keep retrying route.open in-place on retryable
333
+ * refusals (module_reloading, module_warming, target_unavailable, …) before
334
+ * settling not_sent. This deadline is the ONLY binder on those retries —
335
+ * module reloads legitimately take tens of seconds (the daemon's drain alone
336
+ * defaults to 30s), so the default matches the daemon's drain ceiling.
337
+ */
338
+ routeOpenRetryDeadlineMs?: number;
264
339
  /**
265
340
  * Window the post-deadline liveness probe waits for ANY inbound frame after
266
341
  * sending its channel-0 Ping before convicting the socket as half-open.
@@ -300,6 +375,59 @@ export interface ControlPush {
300
375
  body: Record<string, unknown>;
301
376
  }
302
377
 
378
+ /** A known route-close reason, excluding forward-compatible unknown values. */
379
+ export type KnownRouteCloseReason =
380
+ | "reload"
381
+ | "restart"
382
+ | "disable"
383
+ | "crash"
384
+ | "capability_denied";
385
+
386
+ /** A route-close reason decoded from a daemon control push. */
387
+ export type RouteCloseReason = KnownRouteCloseReason | "unknown";
388
+
389
+ /** Whether the close reason alone permits reopening a route automatically. */
390
+ export type RouteCloseDisposition = "may_reopen" | "must_not_reopen";
391
+
392
+ /**
393
+ * Decode a daemon close reason without rejecting a future wire value.
394
+ *
395
+ * An unknown reason remains observable as `unknown`; callers must use the
396
+ * strict disposition instead of treating an unrecognized policy change as safe.
397
+ */
398
+ export function parseRouteCloseReason(reason: unknown): RouteCloseReason {
399
+ switch (reason) {
400
+ case "reload":
401
+ case "restart":
402
+ case "disable":
403
+ case "crash":
404
+ case "capability_denied":
405
+ return reason;
406
+ default:
407
+ return "unknown";
408
+ }
409
+ }
410
+
411
+ /** Unknown close reasons take the strictest action and must not trigger a reopen. */
412
+ export function classifyRouteCloseReason(reason: unknown): RouteCloseDisposition {
413
+ switch (parseRouteCloseReason(reason)) {
414
+ case "reload":
415
+ case "restart":
416
+ return "may_reopen";
417
+ case "disable":
418
+ case "crash":
419
+ case "capability_denied":
420
+ case "unknown":
421
+ return "must_not_reopen";
422
+ }
423
+ }
424
+
425
+ /** Decode a close reason from a route lifecycle control push, if present. */
426
+ export function routeCloseReason(push: ControlPush): RouteCloseReason | undefined {
427
+ if (push.op !== "route.closing" && push.op !== "route.closed") return undefined;
428
+ return parseRouteCloseReason(push.body.reason);
429
+ }
430
+
303
431
  interface NormalizedConnectOptions {
304
432
  connectionFile: string;
305
433
  handshakeTimeoutMs?: number;
@@ -307,6 +435,7 @@ interface NormalizedConnectOptions {
307
435
  targetKind: ManagedRouteKind;
308
436
  reconnectBackoff: ReconnectBackoff;
309
437
  sleep: (ms: number) => Promise<void>;
438
+ routeOpenRetryDeadlineMs: number;
310
439
  timeoutArbitrationGraceMs: number;
311
440
  livenessProbeWindowMs: number;
312
441
  onControlPush?: (push: ControlPush) => void;
@@ -383,7 +512,42 @@ export class SubcClient {
383
512
  return parsed.modules ?? [];
384
513
  }
385
514
 
386
- /** Open a route and return its connection-bound immutable handle. */
515
+ /**
516
+ * Resolve the sole catalog claimant for a capability.
517
+ *
518
+ * This expresses singular intent even when the fleet permits plural claims;
519
+ * ambiguity is returned instead of selecting an arbitrary module.
520
+ */
521
+ async resolveProvider(capability: string): Promise<string> {
522
+ const claimants = await this.resolveProviders(capability);
523
+ if (claimants.length === 0) {
524
+ throw new SubcError(`no catalog claimant for capability ${capability}`, "capability_unprovided");
525
+ }
526
+ if (claimants.length > 1) {
527
+ throw new SubcError(
528
+ `multiple catalog claimants for capability ${capability}: ${claimants.join(", ")}`,
529
+ "capability_ambiguous",
530
+ );
531
+ }
532
+ return claimants[0]!;
533
+ }
534
+
535
+ /** Resolve every catalog claimant in deterministic module-id order. */
536
+ async resolveProviders(capability: string): Promise<string[]> {
537
+ if (!isValidCapabilityIdentifier(capability)) {
538
+ throw new SubcError(
539
+ `malformed capability identifier ${JSON.stringify(capability)}`,
540
+ "invalid_capability_identifier",
541
+ );
542
+ }
543
+ const modules = await this.catalogList();
544
+ return modules
545
+ .filter((module) => module.capabilities?.provides.includes(capability) ?? false)
546
+ .map((module) => module.module_id)
547
+ .sort();
548
+ }
549
+
550
+ /** Open a route and return its connection-bound immutable handle. */
387
551
  async routeOpen(target: RouteTarget, identity: BindIdentity, opts: RouteOpenOptions = {}): Promise<RouteHandle> {
388
552
  const consumerIdentity = routeOpenConsumerIdentity(opts);
389
553
  const consumerCapabilities = opts.consumerCapabilities;
@@ -424,14 +588,25 @@ export class SubcClient {
424
588
  return installed;
425
589
  }
426
590
 
427
- /** Send a data-plane request on exactly the supplied route generation. */
591
+ /** Send a data-plane request on exactly the supplied route generation. */
428
592
  async request(handle: RouteHandle, body: unknown, opts: RequestOptions = {}): Promise<unknown> {
429
593
  this.assertLiveHandle(handle);
430
- const bytes = body instanceof Uint8Array ? body : this.encode(body);
594
+ const binary = opts.binary ?? false;
595
+ const bytes = binary ? requireBinaryBody(body) : body instanceof Uint8Array ? body : this.encode(body);
431
596
  const priority = opts.priority ?? Priority.Interactive;
432
597
  const admission = opts.admissionClass ?? AdmissionClass.Normal;
433
- const reply = await this.send(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
434
- return this.parseJson(reply);
598
+ const reply = await this.send(
599
+ handle,
600
+ bytes,
601
+ priority,
602
+ admission,
603
+ opts.timeoutMs,
604
+ opts.onProgress,
605
+ undefined,
606
+ undefined,
607
+ binary,
608
+ );
609
+ return this.decodeReply(reply);
435
610
  }
436
611
 
437
612
  /**
@@ -444,13 +619,37 @@ export class SubcClient {
444
619
  params?: unknown,
445
620
  opts: ManagedCallOptions = {},
446
621
  ): Promise<Response> {
622
+ if (opts.binary) {
623
+ throw new SubcError(
624
+ "call() builds a JSON body and cannot send a binary request; use callBinary(moduleId, body, opts) with a Uint8Array",
625
+ "binary_call_requires_call_binary",
626
+ );
627
+ }
447
628
  const body = params === undefined ? { method } : { method, params };
629
+ return (await this.managedCall(moduleId, body, opts)) as Response;
630
+ }
448
631
 
632
+ /**
633
+ * Managed route + raw opaque-body request convenience. Unlike call(), this
634
+ * sends bytes without a JSON method envelope because the BINARY flag is the
635
+ * receiver's only signal that the body is not JSON. A BINARY reply resolves
636
+ * to Uint8Array; a non-binary reply is decoded as JSON.
637
+ */
638
+ async callBinary(
639
+ moduleId: string,
640
+ body: Uint8Array,
641
+ opts: ManagedCallOptions = {},
642
+ ): Promise<Uint8Array | unknown> {
643
+ const bytes = requireBinaryBody(body);
644
+ return this.managedCall(moduleId, bytes, { ...opts, binary: true });
645
+ }
646
+
647
+ private async managedCall(moduleId: string, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
449
648
  let retriedUnknownChannel = false;
450
649
  for (;;) {
451
650
  const routeHandle = await this.cachedRouteHandle(moduleId, opts);
452
651
  try {
453
- return (await this.managedRequest(routeHandle, body, opts)) as Response;
652
+ return await this.managedRequest(routeHandle, body, opts);
454
653
  } catch (err) {
455
654
  if (!(err instanceof SubcCallError)) throw this.terminalCallError("managed call failed", err);
456
655
  // unknown_channel is the daemon ROUTER refusing an unrouted channel — the
@@ -699,6 +898,7 @@ export class SubcClient {
699
898
  onProgress: ((body: Uint8Array) => void) | undefined,
700
899
  acceptFrame?: (frame: Frame) => boolean,
701
900
  onLateResponse?: (frame: Frame) => void,
901
+ binary = false,
702
902
  ): Promise<Frame> {
703
903
  if (handle) this.assertLiveHandle(handle);
704
904
  if (this.closedErr) return Promise.reject(this.closedErr);
@@ -713,7 +913,7 @@ export class SubcClient {
713
913
  const epoch = handle?.epoch ?? 0;
714
914
  const frame = buildFrame(
715
915
  FrameType.Request,
716
- buildFlags(false, priority, false, admission),
916
+ buildFlags(binary, priority, false, admission),
717
917
  channel,
718
918
  epoch,
719
919
  corr,
@@ -778,12 +978,21 @@ export class SubcClient {
778
978
  }
779
979
 
780
980
  private async managedRequest(handle: RouteHandle, body: unknown, opts: ManagedCallOptions): Promise<unknown> {
781
- const bytes = body instanceof Uint8Array ? body : this.encode(body);
981
+ const binary = opts.binary ?? false;
982
+ const bytes = binary ? requireBinaryBody(body) : body instanceof Uint8Array ? body : this.encode(body);
782
983
  const priority = opts.priority ?? Priority.Interactive;
783
984
  const admission = opts.admissionClass ?? AdmissionClass.Normal;
784
985
  try {
785
- const reply = await this.sendManaged(handle, bytes, priority, admission, opts.timeoutMs, opts.onProgress);
786
- return this.parseJson(reply);
986
+ const reply = await this.sendManaged(
987
+ handle,
988
+ bytes,
989
+ priority,
990
+ admission,
991
+ opts.timeoutMs,
992
+ opts.onProgress,
993
+ binary,
994
+ );
995
+ return this.decodeReply(reply);
787
996
  } catch (error) {
788
997
  if (error instanceof SubcCallError) throw error;
789
998
  throw this.terminalCallError("managed call failed", error);
@@ -797,6 +1006,7 @@ export class SubcClient {
797
1006
  admission: AdmissionClass,
798
1007
  timeoutMs: number | undefined,
799
1008
  onProgress: ((body: Uint8Array) => void) | undefined,
1009
+ binary = false,
800
1010
  ): Promise<Frame> {
801
1011
  try {
802
1012
  this.assertLiveHandle(handle);
@@ -818,7 +1028,7 @@ export class SubcClient {
818
1028
  const key = pendingKey(handle, corr);
819
1029
  const frame = buildFrame(
820
1030
  FrameType.Request,
821
- buildFlags(false, priority, false, admission),
1031
+ buildFlags(binary, priority, false, admission),
822
1032
  handle.channel,
823
1033
  handle.epoch,
824
1034
  corr,
@@ -898,7 +1108,7 @@ export class SubcClient {
898
1108
  }
899
1109
 
900
1110
  private async openCachedRoute(cached: CachedRoute): Promise<RouteHandle> {
901
- const routeRetryDeadline = Date.now() + ROUTE_OPEN_RETRY_DEADLINE_MS;
1111
+ const routeRetryDeadline = Date.now() + this.opts.routeOpenRetryDeadlineMs;
902
1112
  let routeRetryDelay = this.opts.reconnectBackoff.baseMs;
903
1113
  let routeRetryAttempt = 0;
904
1114
  for (;;) {
@@ -932,14 +1142,21 @@ export class SubcClient {
932
1142
  continue;
933
1143
  }
934
1144
  if (!this.closeStarted && error instanceof SubcError && isRetryableRouteOpenCode(error.code)) {
1145
+ // The deadline is the ONLY binder here. An attempt cap used to share
1146
+ // this condition, and because the capped backoff sums to ~3.1s it
1147
+ // strictly dominated the 30s deadline — the advertised reload
1148
+ // patience was never delivered, so every module restart whose reload
1149
+ // exceeded ~3s failed managed callers with module_reloading. Module
1150
+ // reloads legitimately take tens of seconds (drain alone defaults to
1151
+ // 30s); the backoff cap below bounds retry pressure instead.
935
1152
  routeRetryAttempt += 1;
936
- if (routeRetryAttempt < this.opts.reconnectBackoff.maxAttempts && Date.now() < routeRetryDeadline) {
1153
+ if (Date.now() < routeRetryDeadline) {
937
1154
  await this.opts.sleep(routeRetryDelay);
938
1155
  routeRetryDelay = Math.min(routeRetryDelay * 2, this.opts.reconnectBackoff.capMs);
939
1156
  continue;
940
1157
  }
941
1158
  throw this.notSentCallError(
942
- `route.open failed for module ${cached.moduleId}: ${error.code} (retry budget exhausted)`,
1159
+ `route.open failed for module ${cached.moduleId}: ${error.code} (retry deadline exhausted after ${routeRetryAttempt} attempts)`,
943
1160
  error,
944
1161
  );
945
1162
  }
@@ -1118,18 +1335,31 @@ export class SubcClient {
1118
1335
  }
1119
1336
 
1120
1337
  private async reopenCachedRoutes(): Promise<void> {
1121
- for (const cached of this.routes.values()) cached.handle = null;
1122
- for (const cached of this.routes.values()) {
1123
- if (cached.closed) continue;
1124
- const handle = await this.routeOpen(cached.target, cached.identity, {
1125
- consumerIdentity: cached.consumerIdentity ?? null,
1126
- });
1127
- if (cached.closed) {
1128
- this.liveRoutes.delete(handle.channel);
1129
- this.sendRouteGoodbye(handle);
1130
- continue;
1338
+ const routeKeys = [...this.routes.keys()];
1339
+ for (const key of routeKeys) {
1340
+ const cached = this.routes.get(key);
1341
+ if (cached) cached.handle = null;
1342
+ }
1343
+ for (const key of routeKeys) {
1344
+ const cached = this.routes.get(key);
1345
+ if (!cached || cached.closed) continue;
1346
+ try {
1347
+ const handle = await this.routeOpen(cached.target, cached.identity, {
1348
+ consumerIdentity: cached.consumerIdentity ?? null,
1349
+ });
1350
+ if (cached.closed) {
1351
+ this.liveRoutes.delete(handle.channel);
1352
+ this.sendRouteGoodbye(handle);
1353
+ continue;
1354
+ }
1355
+ cached.handle = handle;
1356
+ } catch (error) {
1357
+ if (isRouteOpenRefusal(error)) {
1358
+ this.routes.delete(key);
1359
+ continue;
1360
+ }
1361
+ throw error;
1131
1362
  }
1132
- cached.handle = handle;
1133
1363
  }
1134
1364
  }
1135
1365
 
@@ -1176,6 +1406,13 @@ export class SubcClient {
1176
1406
  // post-deadline liveness probe reads this stamp. Stamped after readLoop's
1177
1407
  // generation gate, so a stale socket's late frames never vouch for the
1178
1408
  // live one.
1409
+ //
1410
+ // PLACEMENT IS LOAD-BEARING: every inbound frame passes this one point
1411
+ // before demux, which is the whole reason a single stamp works. A future
1412
+ // fast path or drain-and-dispatch refactor that routes frames around
1413
+ // dispatch() makes the stamp skippable, and the liveness watermark
1414
+ // quietly stops meaning "the link delivered bytes" -- the cheapest
1415
+ // correctness property here is also the easiest to lose in a refactor.
1179
1416
  this.lastInboundAtMs = Date.now();
1180
1417
  if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
1181
1418
  // Daemon-originated control push (route.closing / route.closed / future
@@ -1320,8 +1557,9 @@ export class SubcClient {
1320
1557
  const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8")) as {
1321
1558
  code?: string;
1322
1559
  message?: string;
1560
+ detail?: unknown;
1323
1561
  };
1324
- return new SubcError(parsed.message ?? "subc error", parsed.code);
1562
+ return new SubcError(parsed.message ?? "subc error", parsed.code, parsed.detail);
1325
1563
  } catch {
1326
1564
  return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
1327
1565
  }
@@ -1413,6 +1651,11 @@ export class SubcClient {
1413
1651
  return Buffer.from(JSON.stringify(value), "utf8");
1414
1652
  }
1415
1653
 
1654
+ /** Decode a terminal response according to the representation on the wire. */
1655
+ private decodeReply(frame: Frame): unknown {
1656
+ return hasBinary(frame.header.flags) ? frame.body : this.parseJson(frame);
1657
+ }
1658
+
1416
1659
  private parseJson(frame: Frame): unknown {
1417
1660
  const b = frame.body;
1418
1661
  return JSON.parse(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("utf8"));
@@ -1439,19 +1682,31 @@ export function isConsumerReconnectTransient(err: unknown): boolean {
1439
1682
  return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOENT";
1440
1683
  }
1441
1684
 
1685
+ /**
1686
+ * A coded SubcError came from a complete wire Error frame, so the connection
1687
+ * survived long enough to refuse only this route. An uncoded SubcError can mean
1688
+ * the socket or protocol exchange failed mid-open and must still fail reconnect.
1689
+ */
1690
+ function isRouteOpenRefusal(err: unknown): err is SubcError & { code: string } {
1691
+ return err instanceof SubcError && typeof err.code === "string";
1692
+ }
1693
+
1442
1694
  /**
1443
1695
  * The closed set of route.open rejection codes that mean "the target is
1444
1696
  * momentarily unavailable but the request could succeed on retry" — the target
1445
1697
  * is booting, mid-reload, transiently absent, or the bind relay timed out. A
1446
1698
  * daemon-rejected route.open is provably pre-send (no data frame ever left the
1447
1699
  * client), so these classify as not_sent; the managed path retries them in-place
1448
- * within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (bad_consumer_identity,
1449
- * config_divergence, unknown_target, ...) are excluded — they are pre-send but
1450
- * would never succeed, so retrying them would only storm the daemon. Kept
1451
- * byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
1700
+ * within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (module_removed,
1701
+ * bad_consumer_identity, config_divergence, unknown_target, ...) are excluded — they are pre-send but
1702
+ * would never succeed, so retrying them would only storm the daemon. In
1703
+ * particular, capability_forbidden is an explicit non-retryable policy refusal.
1704
+ * Kept byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
1452
1705
  * classification parity.
1453
1706
  */
1454
1707
  export function isRetryableRouteOpenCode(code: string | undefined): boolean {
1708
+ // A capability deny is policy, never a transient target-availability failure.
1709
+ if (code === "capability_forbidden") return false;
1455
1710
  return (
1456
1711
  code === "unknown_module" ||
1457
1712
  code === "module_reloading" ||
@@ -1478,12 +1733,28 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
1478
1733
  targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
1479
1734
  reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
1480
1735
  sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1736
+ routeOpenRetryDeadlineMs: opts.routeOpenRetryDeadlineMs ?? ROUTE_OPEN_RETRY_DEADLINE_MS,
1481
1737
  timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
1482
1738
  livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
1483
1739
  onControlPush: opts.onControlPush,
1484
1740
  };
1485
1741
  }
1486
1742
 
1743
+ /** Validate the exact capability-grammar identifier before catalog I/O. */
1744
+ export function isValidCapabilityIdentifier(identifier: string): boolean {
1745
+ if (/\s/u.test(identifier)) return false;
1746
+ const separator = identifier.indexOf("/v");
1747
+ if (separator <= 0 || identifier.indexOf("/v", separator + 1) !== -1) return false;
1748
+ const name = identifier.slice(0, separator);
1749
+ const version = identifier.slice(separator + 2);
1750
+ if (new TextEncoder().encode(name).byteLength > 64 || version.length === 0) return false;
1751
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$/u.test(name) && !/^[a-z]$/u.test(name)) return false;
1752
+ if (name.includes("--")) return false;
1753
+ if (!/^\d+$/u.test(version) || (version.length > 1 && version.startsWith("0"))) return false;
1754
+ const numericVersion = Number(version);
1755
+ return Number.isSafeInteger(numericVersion) && numericVersion >= 1 && numericVersion <= 0xffff_ffff;
1756
+ }
1757
+
1487
1758
  function routeCacheKey(
1488
1759
  target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
1489
1760
  identity: BindIdentity,
package/src/envelope.ts CHANGED
@@ -50,7 +50,8 @@ const FLAG_PRIORITY_SHIFT = 1;
50
50
  const FLAG_LAST = 0b0000_1000;
51
51
  const FLAG_ADMISSION_MASK = 0b0011_0000;
52
52
  const FLAG_ADMISSION_SHIFT = 4;
53
- const FLAG_RESERVED_MASK = 0b1100_0000;
53
+ export const DAEMON_ORIGIN_FLAG = 0x40;
54
+ const FLAG_RESERVED_MASK = 0b1000_0000;
54
55
 
55
56
  /** Build flags from typed components. Admission defaults to NORMAL. */
56
57
  export function buildFlags(
@@ -71,6 +72,15 @@ export function admissionClass(flags: number): AdmissionClass {
71
72
  return ((flags & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT) as AdmissionClass;
72
73
  }
73
74
 
75
+ /** True when the body is opaque bytes rather than a JSON payload. */
76
+ export function hasBinary(flags: number): boolean {
77
+ return (flags & FLAG_BINARY) !== 0;
78
+ }
79
+
80
+ export function hasDaemonOrigin(flags: number): boolean {
81
+ return (flags & DAEMON_ORIGIN_FLAG) !== 0;
82
+ }
83
+
74
84
  export interface EnvelopeHeader {
75
85
  len: number;
76
86
  ver: number;
package/src/index.ts CHANGED
@@ -6,17 +6,26 @@ export {
6
6
  SUBC_MODULE_ID_ENV,
7
7
  SUBC_LAUNCH_NONCE_ENV,
8
8
  isConsumerReconnectTransient,
9
+ parseRouteCloseReason,
10
+ classifyRouteCloseReason,
11
+ routeCloseReason,
12
+ isValidCapabilityIdentifier,
9
13
  connectionFileExists,
10
14
  type BindIdentity,
11
15
  type RouteTarget,
12
16
  type ManagedRouteKind,
13
17
  type ConsumerIdentity,
14
18
  type RouteOpenOptions,
19
+ type CatalogCapabilities,
20
+ type CatalogCapabilityRequirement,
15
21
  type CatalogEntry,
16
22
  type RequestOptions,
17
23
  type ManagedCallOptions,
18
24
  type ConnectOptions,
19
25
  type ControlPush,
26
+ type KnownRouteCloseReason,
27
+ type RouteCloseReason,
28
+ type RouteCloseDisposition,
20
29
  type ReconnectBackoff,
21
30
  type SubcCallErrorKind,
22
31
  type SubscribeOptions,
@@ -37,6 +46,7 @@ export {
37
46
  FrameType,
38
47
  Priority,
39
48
  AdmissionClass,
49
+ DAEMON_ORIGIN_FLAG,
40
50
  PROTOCOL_VERSION,
41
51
  HEADER_LEN,
42
52
  buildFrame,
@@ -44,6 +54,8 @@ export {
44
54
  buildFlags,
45
55
  encodeFrame,
46
56
  decodeHeader,
57
+ hasBinary,
58
+ hasDaemonOrigin,
47
59
  encodeHeader,
48
60
  DecodeError,
49
61
  type DecodeErrorCode,
package/src/provider.ts CHANGED
@@ -352,6 +352,8 @@ export class SubcProviderError extends Error {
352
352
  message: string,
353
353
  readonly code?: string,
354
354
  readonly kind: SubcCallErrorKind = "terminal",
355
+ /** Wire `ErrorBody.detail` carried verbatim (typed refusal payloads). */
356
+ readonly detail?: unknown,
355
357
  ) {
356
358
  super(message);
357
359
  }
@@ -1222,11 +1224,12 @@ function routeKey(handle: RouteHandle, corr: bigint): string {
1222
1224
 
1223
1225
  function providerErrorFromFrame(frame: Frame): SubcProviderError {
1224
1226
  try {
1225
- const body = parseJson(frame.body) as { code?: string; message?: string };
1227
+ const body = parseJson(frame.body) as { code?: string; message?: string; detail?: unknown };
1226
1228
  return new SubcProviderError(
1227
1229
  body.message ?? "subc error",
1228
1230
  body.code,
1229
1231
  body.code === "stale_route_epoch" || body.code === "unknown_channel" ? "not_sent" : "terminal",
1232
+ body.detail,
1230
1233
  );
1231
1234
  } catch {
1232
1235
  return new SubcProviderError(Buffer.from(frame.body).toString("utf8") || "subc error");