@cortexkit/subc-client 0.8.0 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/subc-client",
3
- "version": "0.8.0",
3
+ "version": "0.11.0",
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
+ }
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
+ }
448
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
  }
@@ -971,13 +1188,35 @@ export class SubcClient {
971
1188
  * enough to delay the read loop delays this probe's own timer identically,
972
1189
  * and when both wake the buffered Pong dispatches before the check runs.
973
1190
  *
1191
+ * A PENDING CHANNEL-0 REQUEST SUSPENDS THE PROBE (both at launch and at the
1192
+ * conviction check). The daemon's connection loop handles frames one at a
1193
+ * time, and some channel-0 ops legally park it for seconds — route.open
1194
+ * awaits the module's bind ack inline for up to route_bind_relay_timeout
1195
+ * (~12s in production) — during which OUR Ping sits unread in the daemon's
1196
+ * socket buffer. On an otherwise-quiet connection that silence is fully
1197
+ * explained by our own in-flight control op, and convicting on it would
1198
+ * tear down a healthy connection mid-bind (BROCA's Athena panel flagged
1199
+ * this exact premise; the FIFO fact is verified in subc-core server.rs
1200
+ * connection_loop + control.rs handle_route_open). The client always knows
1201
+ * its own channel-0 pendings, so the gate is local and exact: no probe
1202
+ * while one is in flight, no conviction if one appeared during the window.
1203
+ *
974
1204
  * Scope: hooked from the managed-call path only. Control-plane requests
975
1205
  * (route.open, catalog.list) are short-lived and their failures already
976
1206
  * escalate through reconnect classification; the managed path is where a
977
1207
  * plugin lives for hours and where the pin was observed in production.
978
1208
  */
1209
+ /** Any in-flight channel-0 request (pendings keyed without a route handle). */
1210
+ private hasControlPending(): boolean {
1211
+ for (const pending of this.pending.values()) {
1212
+ if (pending.handle === null) return true;
1213
+ }
1214
+ return false;
1215
+ }
1216
+
979
1217
  private probeLivenessAfterDeadline(): void {
980
1218
  if (this.livenessProbe || this.closeStarted || this.closedErr) return;
1219
+ if (this.hasControlPending()) return; // silence would be self-explained; re-armed by the next settle
981
1220
  const sock = this.sock;
982
1221
  const generation = this.generation;
983
1222
  let corr: bigint;
@@ -1003,6 +1242,9 @@ export class SubcClient {
1003
1242
  await this.opts.sleep(this.opts.livenessProbeWindowMs);
1004
1243
  if (this.sock !== sock || this.generation !== generation || this.closeStarted) return;
1005
1244
  if (this.lastInboundAtMs >= t0) return; // link proven — keep the socket
1245
+ // A control op that started during the window explains the silence: the
1246
+ // daemon's read loop may be parked in its inline handler, not dead.
1247
+ if (this.hasControlPending()) return;
1006
1248
  this.fail(
1007
1249
  new SocketClosedError(
1008
1250
  `liveness probe convicted a half-open socket: no inbound frame for ${this.opts.livenessProbeWindowMs}ms after a channel-0 Ping (deadline-no-drop settles preceded this); closing so the next call reconnects`,
@@ -1151,6 +1393,13 @@ export class SubcClient {
1151
1393
  // post-deadline liveness probe reads this stamp. Stamped after readLoop's
1152
1394
  // generation gate, so a stale socket's late frames never vouch for the
1153
1395
  // live one.
1396
+ //
1397
+ // PLACEMENT IS LOAD-BEARING: every inbound frame passes this one point
1398
+ // before demux, which is the whole reason a single stamp works. A future
1399
+ // fast path or drain-and-dispatch refactor that routes frames around
1400
+ // dispatch() makes the stamp skippable, and the liveness watermark
1401
+ // quietly stops meaning "the link delivered bytes" -- the cheapest
1402
+ // correctness property here is also the easiest to lose in a refactor.
1154
1403
  this.lastInboundAtMs = Date.now();
1155
1404
  if (frame.header.channel === 0 && frame.header.ty === FrameType.Push) {
1156
1405
  // Daemon-originated control push (route.closing / route.closed / future
@@ -1295,8 +1544,9 @@ export class SubcClient {
1295
1544
  const parsed = JSON.parse(Buffer.from(frame.body).toString("utf8")) as {
1296
1545
  code?: string;
1297
1546
  message?: string;
1547
+ detail?: unknown;
1298
1548
  };
1299
- return new SubcError(parsed.message ?? "subc error", parsed.code);
1549
+ return new SubcError(parsed.message ?? "subc error", parsed.code, parsed.detail);
1300
1550
  } catch {
1301
1551
  return new SubcError(Buffer.from(frame.body).toString("utf8") || "subc error");
1302
1552
  }
@@ -1388,6 +1638,11 @@ export class SubcClient {
1388
1638
  return Buffer.from(JSON.stringify(value), "utf8");
1389
1639
  }
1390
1640
 
1641
+ /** Decode a terminal response according to the representation on the wire. */
1642
+ private decodeReply(frame: Frame): unknown {
1643
+ return hasBinary(frame.header.flags) ? frame.body : this.parseJson(frame);
1644
+ }
1645
+
1391
1646
  private parseJson(frame: Frame): unknown {
1392
1647
  const b = frame.body;
1393
1648
  return JSON.parse(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("utf8"));
@@ -1420,13 +1675,16 @@ export function isConsumerReconnectTransient(err: unknown): boolean {
1420
1675
  * is booting, mid-reload, transiently absent, or the bind relay timed out. A
1421
1676
  * daemon-rejected route.open is provably pre-send (no data frame ever left the
1422
1677
  * client), so these classify as not_sent; the managed path retries them in-place
1423
- * within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (bad_consumer_identity,
1424
- * config_divergence, unknown_target, ...) are excluded — they are pre-send but
1425
- * would never succeed, so retrying them would only storm the daemon. Kept
1426
- * byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
1678
+ * within ROUTE_OPEN_RETRY_DEADLINE_MS. Permanent rejections (module_removed,
1679
+ * bad_consumer_identity, config_divergence, unknown_target, ...) are excluded — they are pre-send but
1680
+ * would never succeed, so retrying them would only storm the daemon. In
1681
+ * particular, capability_forbidden is an explicit non-retryable policy refusal.
1682
+ * Kept byte-identical to subc-client-rs is_retryable_route_open_code for cross-client
1427
1683
  * classification parity.
1428
1684
  */
1429
1685
  export function isRetryableRouteOpenCode(code: string | undefined): boolean {
1686
+ // A capability deny is policy, never a transient target-availability failure.
1687
+ if (code === "capability_forbidden") return false;
1430
1688
  return (
1431
1689
  code === "unknown_module" ||
1432
1690
  code === "module_reloading" ||
@@ -1453,12 +1711,28 @@ function normalizeConnectOptions(opts: ConnectOptions): NormalizedConnectOptions
1453
1711
  targetKind: opts.targetKind ?? DEFAULT_MANAGED_TARGET_KIND,
1454
1712
  reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
1455
1713
  sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
1714
+ routeOpenRetryDeadlineMs: opts.routeOpenRetryDeadlineMs ?? ROUTE_OPEN_RETRY_DEADLINE_MS,
1456
1715
  timeoutArbitrationGraceMs: opts.timeoutArbitrationGraceMs ?? TIMEOUT_ARBITRATION_GRACE_MS,
1457
1716
  livenessProbeWindowMs: opts.livenessProbeWindowMs ?? LIVENESS_PROBE_WINDOW_MS,
1458
1717
  onControlPush: opts.onControlPush,
1459
1718
  };
1460
1719
  }
1461
1720
 
1721
+ /** Validate the exact capability-grammar identifier before catalog I/O. */
1722
+ export function isValidCapabilityIdentifier(identifier: string): boolean {
1723
+ if (/\s/u.test(identifier)) return false;
1724
+ const separator = identifier.indexOf("/v");
1725
+ if (separator <= 0 || identifier.indexOf("/v", separator + 1) !== -1) return false;
1726
+ const name = identifier.slice(0, separator);
1727
+ const version = identifier.slice(separator + 2);
1728
+ if (new TextEncoder().encode(name).byteLength > 64 || version.length === 0) return false;
1729
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$/u.test(name) && !/^[a-z]$/u.test(name)) return false;
1730
+ if (name.includes("--")) return false;
1731
+ if (!/^\d+$/u.test(version) || (version.length > 1 && version.startsWith("0"))) return false;
1732
+ const numericVersion = Number(version);
1733
+ return Number.isSafeInteger(numericVersion) && numericVersion >= 1 && numericVersion <= 0xffff_ffff;
1734
+ }
1735
+
1462
1736
  function routeCacheKey(
1463
1737
  target: Extract<RouteTarget, { kind: ManagedRouteKind }>,
1464
1738
  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");