@cotal-ai/core 0.10.1 → 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.
Files changed (64) hide show
  1. package/dist/agent-file.d.ts +10 -1
  2. package/dist/agent-file.d.ts.map +1 -1
  3. package/dist/agent-file.js +65 -80
  4. package/dist/agent-file.js.map +1 -1
  5. package/dist/auth-provider.d.ts +207 -0
  6. package/dist/auth-provider.d.ts.map +1 -0
  7. package/dist/auth-provider.js +13 -0
  8. package/dist/auth-provider.js.map +1 -0
  9. package/dist/channels.d.ts +10 -0
  10. package/dist/channels.d.ts.map +1 -1
  11. package/dist/channels.js +9 -27
  12. package/dist/channels.js.map +1 -1
  13. package/dist/command.d.ts +6 -1
  14. package/dist/command.d.ts.map +1 -1
  15. package/dist/command.js +5 -1
  16. package/dist/command.js.map +1 -1
  17. package/dist/connector-config.js +1 -1
  18. package/dist/connector.d.ts +16 -0
  19. package/dist/connector.d.ts.map +1 -1
  20. package/dist/endpoint.d.ts +148 -17
  21. package/dist/endpoint.d.ts.map +1 -1
  22. package/dist/endpoint.js +470 -97
  23. package/dist/endpoint.js.map +1 -1
  24. package/dist/evict.d.ts +87 -0
  25. package/dist/evict.d.ts.map +1 -0
  26. package/dist/evict.js +231 -0
  27. package/dist/evict.js.map +1 -0
  28. package/dist/identity.d.ts +15 -0
  29. package/dist/identity.d.ts.map +1 -1
  30. package/dist/identity.js +26 -2
  31. package/dist/identity.js.map +1 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/launch.d.ts +6 -0
  37. package/dist/launch.d.ts.map +1 -1
  38. package/dist/members.d.ts.map +1 -1
  39. package/dist/members.js +5 -2
  40. package/dist/members.js.map +1 -1
  41. package/dist/membership-feed.d.ts +17 -3
  42. package/dist/membership-feed.d.ts.map +1 -1
  43. package/dist/membership-feed.js +41 -13
  44. package/dist/membership-feed.js.map +1 -1
  45. package/dist/provision.d.ts +99 -14
  46. package/dist/provision.d.ts.map +1 -1
  47. package/dist/provision.js +267 -128
  48. package/dist/provision.js.map +1 -1
  49. package/dist/resolve.js +1 -1
  50. package/dist/secret-fs.d.ts +9 -0
  51. package/dist/secret-fs.d.ts.map +1 -1
  52. package/dist/secret-fs.js +26 -2
  53. package/dist/secret-fs.js.map +1 -1
  54. package/dist/streams.d.ts +33 -7
  55. package/dist/streams.d.ts.map +1 -1
  56. package/dist/streams.js +53 -30
  57. package/dist/streams.js.map +1 -1
  58. package/dist/subjects.d.ts +267 -75
  59. package/dist/subjects.d.ts.map +1 -1
  60. package/dist/subjects.js +401 -110
  61. package/dist/subjects.js.map +1 -1
  62. package/dist/types.d.ts +13 -2
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +3 -2
package/dist/endpoint.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { createConnection } from "node:net";
4
- import { connect, credsAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
5
- import { idFromCreds } from "./identity.js";
4
+ import { connect, credsAuthenticator, tokenAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
5
+ import { credsClaims, idFromCreds } from "./identity.js";
6
+ import { inspectCredHealth } from "./provision.js";
6
7
  import { assertValidName } from "./resolve.js";
7
8
  import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
8
9
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
@@ -11,7 +12,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
11
12
  import { openAclRegistry, readAcl, commitAcl as writeAclRecord } from "./acls.js";
12
13
  import { openDeliveryRegistry } from "./lease.js";
13
14
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
14
- import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_SELF_SERVICE, CONTROL_DELIVERY, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxOwner, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, } from "./subjects.js";
15
+ import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_SELF_SERVICE, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
15
16
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
16
17
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
17
18
  export const DEFAULT_SPACE = "main";
@@ -39,7 +40,20 @@ export class CotalEndpoint extends EventEmitter {
39
40
  token;
40
41
  user;
41
42
  pass;
42
- creds;
43
+ /** The creds source, when standing renewal is on (bounded supervisor/daemon creds); undefined for
44
+ * a static string. Mirrors {@link bearerSource} exactly — same fetch-ahead + pin + retry shape. */
45
+ credsSource;
46
+ /** The freshest creds — what every (re)connect attempt presents. Static callers set it once. */
47
+ currentCreds;
48
+ credsTimer;
49
+ /** True in user mode (bearer string OR source) — gates the callout-shaped connect. */
50
+ userMode = false;
51
+ /** The bearer source, when auth refreshes (spawned agents); undefined for a one-shot string. */
52
+ bearerSource;
53
+ /** The freshest bearer — what every (re)connect attempt presents. */
54
+ currentBearer;
55
+ bearerTimer;
56
+ sentinelCreds;
43
57
  tls;
44
58
  heartbeatMs;
45
59
  ttlMs;
@@ -60,10 +74,11 @@ export class CotalEndpoint extends EventEmitter {
60
74
  aclKv;
61
75
  deliveryKv;
62
76
  managerLeaseKv;
63
- membershipKv;
77
+ membershipFeedKv;
64
78
  /** The live `ctl.delivery` serve subscription (delivery daemon) — re-created on every (re)connect by
65
79
  * {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
66
80
  deliveryServeSub;
81
+ deliveryAdminServeSub;
67
82
  /** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
68
83
  * daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
69
84
  * re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
@@ -141,6 +156,15 @@ export class CotalEndpoint extends EventEmitter {
141
156
  backoffResolve;
142
157
  backoffTimer;
143
158
  retryMs = 3000;
159
+ /** The connection's authenticated nkey — dev: the creds' identity; user mode: the per-connection
160
+ * ephemeral. Distinct from the {@link owner}+{@link actor} principal: it names the CONNECTION (the
161
+ * broker-authenticated user), and scopes the private reply inbox (`_INBOX_<connId>`) + the credId
162
+ * equality check. The principal (owner+actor) is what the WIRE grammar and every per-agent key use. */
163
+ connId;
164
+ /** This endpoint's owner token (principal half 1) — `"local"` in the dev default. */
165
+ owner;
166
+ /** This endpoint's actor token (principal half 2) — the connection id in the dev default. */
167
+ actor;
144
168
  constructor(opts) {
145
169
  super();
146
170
  this.space = opts.space;
@@ -148,20 +172,74 @@ export class CotalEndpoint extends EventEmitter {
148
172
  // (the future owner/name separator) and surrounding whitespace at the one identity choke
149
173
  // point every join/spawn path flows through.
150
174
  assertValidName(opts.card.name);
151
- // Identity precedence: an explicit card.id, else the creds' identity, else a random
152
- // uuid. When both an id and creds are given they MUST name the same nkey — otherwise
153
- // the subject sender token wouldn't match the authenticated user and every publish
154
- // would be denied (a silent-failure class).
155
- const credId = opts.creds ? idFromCreds(opts.creds) : undefined;
156
- if (opts.card.id && credId && opts.card.id !== credId)
157
- throw new Error(`card.id ${opts.card.id} != creds identity ${credId} — they must be the same nkey`);
158
- const id = opts.card.id ?? credId ?? randomUUID();
159
- this.card = { ...opts.card, id };
175
+ // Auth mode is EITHER static creds (dev/no-login) OR a user bearer (login → callout) — never both.
176
+ if (opts.bearer) {
177
+ if (opts.creds || opts.token || opts.user || opts.pass)
178
+ throw new Error("bearer (user-mode auth) is mutually exclusive with creds/token/user/pass");
179
+ if (!opts.sentinelCreds)
180
+ throw new Error("user-mode bearer requires sentinelCreds (the shared auth-account creds presented alongside it)");
181
+ }
182
+ if (opts.bearer) {
183
+ // USER MODE. The owner+actor PRINCIPAL comes from the bearer (server-authored: owner is callout-
184
+ // derived, actor is the spawn-ledger actor) — never from the card. The connection nkey is minted
185
+ // per-connect by NATS and is unknown to the client pre-connect, so the client cannot key its inbox
186
+ // on it; instead it picks its OWN random inbox NONCE (the connId), passes it as the connect `name`,
187
+ // and the callout scopes `_INBOX_<connId>.>` on that. `card.id`, if given, must match the bearer.
188
+ this.userMode = true;
189
+ if (typeof opts.bearer === "function") {
190
+ // Bearer SOURCE: no token exists yet, so the principal must be declared up front; every
191
+ // fetched bearer is checked against it (refreshBearer), keeping the card honest for life.
192
+ if (!opts.card.owner || !opts.card.actor)
193
+ throw new Error("a bearer source requires explicit card.owner + card.actor (no bearer to derive them from at construction)");
194
+ this.owner = opts.card.owner;
195
+ this.actor = opts.card.actor;
196
+ this.bearerSource = opts.bearer;
197
+ }
198
+ else {
199
+ const claims = decodeBearerPrincipal(opts.bearer);
200
+ if (opts.card.owner && opts.card.owner !== claims.owner)
201
+ throw new Error(`card.owner ${opts.card.owner} != bearer owner ${claims.owner}`);
202
+ if (opts.card.actor && opts.card.actor !== claims.actor)
203
+ throw new Error(`card.actor ${opts.card.actor} != bearer actor ${claims.actor}`);
204
+ this.owner = claims.owner;
205
+ this.actor = claims.actor;
206
+ this.currentBearer = opts.bearer;
207
+ }
208
+ this.connId = assertInboxConnId(`ibx${randomUUID().replace(/-/g, "")}`);
209
+ this.sentinelCreds = opts.sentinelCreds;
210
+ }
211
+ else {
212
+ // DEV / STATIC. Connection identity precedence: an explicit card.id, else the creds' identity, else
213
+ // a random (dash-free, valid-actor-token) id. When both an id and creds are given they MUST name the
214
+ // same nkey — else the connection would authenticate as one user while its grants name another. The
215
+ // owner+actor PRINCIPAL defaults to owner = DEV_OWNER ("local"), actor = the connection id.
216
+ if (typeof opts.creds === "function") {
217
+ // Creds SOURCE (standing renewal): no cred exists yet, so the identity must be declared up
218
+ // front; every fetched cred is checked against it (refreshCreds), so a renewal can never
219
+ // silently swap the connection's nkey.
220
+ if (!opts.card.id)
221
+ throw new Error("a creds source requires an explicit card.id (no cred to derive the identity from at construction)");
222
+ this.credsSource = opts.creds;
223
+ this.connId = opts.card.id;
224
+ }
225
+ else {
226
+ const credId = opts.creds ? idFromCreds(opts.creds) : undefined;
227
+ if (opts.card.id && credId && opts.card.id !== credId)
228
+ throw new Error(`card.id ${opts.card.id} != creds identity ${credId} - they must be the same nkey`);
229
+ this.currentCreds = opts.creds;
230
+ this.connId = opts.card.id ?? credId ?? randomUUID().replace(/-/g, "");
231
+ }
232
+ this.owner = opts.card.owner ?? DEV_OWNER;
233
+ this.actor = opts.card.actor ?? this.connId;
234
+ }
235
+ // `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
236
+ // principalKey validates both tokens.
237
+ const principal = principalKey(this.owner, this.actor);
238
+ this.card = { ...opts.card, id: principal.key, owner: this.owner, actor: this.actor };
160
239
  this.servers = opts.servers ?? DEFAULT_SERVER;
161
240
  this.token = opts.token;
162
241
  this.user = opts.user;
163
242
  this.pass = opts.pass;
164
- this.creds = opts.creds;
165
243
  this.tls = opts.tls ?? false;
166
244
  this.channels = opts.channels ?? ["general"];
167
245
  this.heartbeatMs = opts.heartbeatMs ?? 2000;
@@ -179,6 +257,12 @@ export class CotalEndpoint extends EventEmitter {
179
257
  ref() {
180
258
  return { id: this.card.id, name: this.card.name, role: this.card.role };
181
259
  }
260
+ /** True on any AUTHED broker (static creds OR user-mode bearer) — the gate every open-vs-auth
261
+ * branch keys on: authed endpoints OPEN pre-created streams/KVs and BIND pre-provisioned
262
+ * durables (creates are denied to agents); only the open dev broker lazy-creates. */
263
+ get authed() {
264
+ return Boolean(this.currentCreds || this.credsSource) || this.userMode;
265
+ }
182
266
  async start() {
183
267
  await this.connectAndBind();
184
268
  // nats.js auto-reconnects transient drops; when it exhausts its attempts and the
@@ -187,22 +271,143 @@ export class CotalEndpoint extends EventEmitter {
187
271
  // connect — a first-connect failure throws to the caller's connect-retry loop instead.
188
272
  this.superviseConnection();
189
273
  }
274
+ /** How far ahead of the current bearer's `exp` a refresh fires, and how soon a FAILED refresh
275
+ * retries. The margin must clear a reconnect window (nats.js retries use the sync token getter,
276
+ * so whatever `currentBearer` holds is what every attempt presents). */
277
+ static BEARER_REFRESH_MARGIN_MS = 60_000;
278
+ static BEARER_RETRY_MS = 15_000;
279
+ /** Fetch a fresh bearer from the source, pin its principal to ours, arm the next refresh. On a
280
+ * fetch/principal failure: THROWS when `initial` (start() must fail loud before first connect);
281
+ * otherwise emits "error" and retries — the live connection keeps working until its current JWT
282
+ * expiry, so a dead auth service is loud without instantly dropping the mesh. */
283
+ async refreshBearer(initial = false) {
284
+ try {
285
+ const bearer = await this.bearerSource();
286
+ const claims = decodeBearerPrincipal(bearer);
287
+ if (claims.owner !== this.owner || claims.actor !== this.actor)
288
+ throw new Error(`bearer source returned principal ${claims.owner}.${claims.actor}, expected ${this.owner}.${this.actor}`);
289
+ this.currentBearer = bearer;
290
+ this.armBearerRefresh(bearerExpiryMs(bearer) - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
291
+ }
292
+ catch (e) {
293
+ if (initial)
294
+ throw e;
295
+ this.emit("error", new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current token's expiry if the auth service stays down`));
296
+ this.armBearerRefresh(CotalEndpoint.BEARER_RETRY_MS);
297
+ }
298
+ }
299
+ armBearerRefresh(delayMs) {
300
+ if (this.stopped)
301
+ return;
302
+ clearTimeout(this.bearerTimer);
303
+ this.bearerTimer = setTimeout(() => void this.refreshBearer(), Math.max(5_000, delayMs));
304
+ this.bearerTimer.unref?.();
305
+ }
306
+ /** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
307
+ * (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
308
+ static CREDS_RETRY_MS = 60_000;
309
+ /** Fetch fresh creds from the source, pin their identity to ours, cache them, and arm the next
310
+ * refresh at 75% of the new JWT's lifetime. THROWS on fetch/pin failure — the callers decide the
311
+ * failure posture (loud-and-retry for the timer, a structured error reply for an explicit
312
+ * {@link reloadCreds}). */
313
+ async fetchFreshCreds() {
314
+ const creds = await this.credsSource();
315
+ const id = idFromCreds(creds);
316
+ if (id !== this.connId)
317
+ throw new Error(`creds source returned identity ${id}, expected ${this.connId} - renewal may not swap the connection's nkey`);
318
+ this.currentCreds = creds;
319
+ this.armCredsRefresh(credsRenewalDelayMs(creds));
320
+ const { iat, exp } = credsClaims(creds);
321
+ return { iat, exp };
322
+ }
323
+ /** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
324
+ * (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
325
+ * broker to close the connection at `exp`, means the wire never carries a near-dead JWT and the
326
+ * operator never sees a spurious "authentication expired" — the broker's expiry-close remains the
327
+ * BACKSTOP if a swap is missed, not the mechanism. Already-closed/draining rejections are the
328
+ * supervise loop's to own (its rebuild re-fetches); an already-disconnected client is a no-op
329
+ * (its own reconnect loop presents the fresh cred). */
330
+ async swapConnectionOntoFreshCreds() {
331
+ if (this.nc && !this.stopped)
332
+ await this.nc.reconnect().catch(() => { });
333
+ }
334
+ /** The 75%-of-lifetime renewal timer tick (and rebuild re-fetch) — the passive BACKSTOP behind the
335
+ * explicit {@link reloadCreds}. Failure shape mirrors {@link refreshBearer}: THROWS when
336
+ * `initial`, else emits "error" and retries. */
337
+ async refreshCreds(initial = false) {
338
+ try {
339
+ await this.fetchFreshCreds();
340
+ if (!initial)
341
+ await this.swapConnectionOntoFreshCreds();
342
+ }
343
+ catch (e) {
344
+ if (initial)
345
+ throw e;
346
+ this.emit("error", new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
347
+ this.armCredsRefresh(CotalEndpoint.CREDS_RETRY_MS);
348
+ }
349
+ }
350
+ /** EXPLICIT credential reload — the auditable adoption step of D5 class-2 standing renewal (served
351
+ * to the renewal owner via the delivery-admin rail). Re-invokes the source NOW, pins + adopts the
352
+ * fresh cred on the live connection, and returns the adopted JWT's window so the caller can record
353
+ * proof of adoption. THROWS (structured for the reply) when the source fails — e.g. the creds file
354
+ * was not actually re-signed — so "file written" can never masquerade as "daemon adopted". */
355
+ async reloadCreds() {
356
+ if (!this.credsSource)
357
+ throw new Error("reloadCreds: this endpoint has no creds source (a static cred cannot be renewed in place)");
358
+ const { iat, exp } = await this.fetchFreshCreds();
359
+ await this.swapConnectionOntoFreshCreds();
360
+ return { identity: this.connId, iat, exp };
361
+ }
362
+ armCredsRefresh(delayMs) {
363
+ if (this.stopped)
364
+ return;
365
+ clearTimeout(this.credsTimer);
366
+ // 1s floor (vs the bearer's 5s): standing-renewal smokes exercise second-scale TTLs; production
367
+ // lifetimes are hours+ so the floor never engages there.
368
+ this.credsTimer = setTimeout(() => void this.refreshCreds(), Math.max(1_000, delayMs));
369
+ this.credsTimer.unref?.();
370
+ }
190
371
  /** Open the connection and bind everything that hangs off it: status watch, presence
191
372
  * watch + heartbeat, channel registry, and the durable consumers. Re-runnable — a
192
373
  * reconnect calls it again after {@link clearConnectionScoped}; every binding is
193
374
  * idempotent (durables bind by name, JetStream dedups by msgID, KV opens are idempotent). */
194
375
  async connectAndBind() {
195
376
  this.clearConnectionScoped();
377
+ // Bearer-source endpoints fetch before the FIRST connect, and re-fetch on a rebuild whose
378
+ // cached token is already inside the refresh margin (a rebuild after a long outage would
379
+ // otherwise present a dead bearer for its first attempts).
380
+ if (this.bearerSource) {
381
+ const stale = !this.currentBearer ||
382
+ bearerExpiryMs(this.currentBearer) - Date.now() < CotalEndpoint.BEARER_REFRESH_MARGIN_MS;
383
+ if (stale)
384
+ await this.refreshBearer(!this.currentBearer);
385
+ }
386
+ // Creds-source endpoints likewise fetch before the FIRST connect, and re-fetch on a rebuild
387
+ // whose cached cred is expired or inside its renewal window.
388
+ if (this.credsSource) {
389
+ const stale = !this.currentCreds || credsRenewalDelayMs(this.currentCreds) <= 0;
390
+ if (stale)
391
+ await this.refreshCreds(!this.currentCreds);
392
+ }
196
393
  this.nc = await connect({
197
394
  servers: this.servers,
198
- name: `cotal:${this.card.name}`,
199
- // Per-identity inbox namespace (the "Private Inbox" pattern). nats.js routes ALL
200
- // generated inboxes request replies, JetStream pull delivery, kv.watch ordered-
201
- // consumer delivery through this prefix. Paired with sub.allow=[_INBOX_<id>.>]
202
- // (auth mode) it stops a peer from subscribing the wildcard inbox to sniff others'
203
- // DM deliveries. Set unconditionally so the prefix can never drift from the ACL.
204
- inboxPrefix: `_INBOX_${this.card.id}`,
205
- ...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.creds, tls: this.tls }),
395
+ // In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
396
+ // scopes `_INBOX_<connId>.>` on (see EndpointOptions.bearer); otherwise it's the display handle.
397
+ name: this.userMode ? this.connId : `cotal:${this.card.name}`,
398
+ // Per-CONNECTION inbox namespace (the "Private Inbox" pattern), keyed on the connection nkey
399
+ // (NOT the owner+actor principal): the reply inbox is per-connection plumbing, and under the auth
400
+ // callout the principal is unknown to the client pre-connect (owner is derived server-side) while
401
+ // the connection id always is. nats.js routes ALL generated inboxes — request replies, JetStream
402
+ // pull delivery, kv.watch ordered-consumer delivery through this prefix. Paired with
403
+ // sub.allow=[_INBOX_<connId>.>] it stops a peer from subscribing the wildcard inbox to sniff
404
+ // others' DM deliveries. Set unconditionally so the prefix can never drift from the ACL.
405
+ inboxPrefix: `_INBOX_${this.connId}`,
406
+ // The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
407
+ // attempt, so internal reconnects present whatever refreshBearer last fetched.
408
+ // Creds likewise ride a GETTER when a source renews them, so internal reconnects (incl. the
409
+ // one the broker forces at JWT `exp`) present whatever refreshCreds last fetched.
410
+ ...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.credsSource ? () => this.currentCreds : this.currentCreds, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
206
411
  });
207
412
  this.watchStatus();
208
413
  this.js = jetstream(this.nc);
@@ -210,7 +415,7 @@ export class CotalEndpoint extends EventEmitter {
210
415
  const kvm = new Kvm(this.nc);
211
416
  // The presence bucket is a JetStream stream. Open mode lazily creates it; auth mode
212
417
  // OPENs it (it's pre-created at `cotal up`; KV stream-create is denied to agents).
213
- this.kv = this.creds
418
+ this.kv = this.authed
214
419
  ? await kvm.open(presenceBucket(this.space))
215
420
  : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs });
216
421
  }
@@ -225,7 +430,7 @@ export class CotalEndpoint extends EventEmitter {
225
430
  // pre-created at `cotal up`; open mode lazily creates it.
226
431
  const watchChannels = this.doWatch && this.doWatchChannels;
227
432
  if (watchChannels || this.doConsume) {
228
- this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.creds });
433
+ this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.authed });
229
434
  if (watchChannels)
230
435
  await this.startChannelWatch();
231
436
  }
@@ -239,7 +444,7 @@ export class CotalEndpoint extends EventEmitter {
239
444
  this.jsm = await jetstreamManager(this.nc);
240
445
  // Open mode: lazily create the streams on the first endpoint. Auth mode: they are
241
446
  // pre-created at `cotal up` and STREAM.CREATE is denied to agents, so skip.
242
- if (!this.creds)
447
+ if (!this.authed)
243
448
  await this.ensureStreams();
244
449
  await this.startConsumers();
245
450
  }
@@ -323,7 +528,7 @@ export class CotalEndpoint extends EventEmitter {
323
528
  if (this.nc !== nc)
324
529
  return; // epoch-stale — a rebuild already swapped this connection
325
530
  this.emit("connection", { connected: false }); // dropped — report it before the rebuild kicks in
326
- this.emit("error", new Error(`mesh connection closed${err ? `: ${err.message}` : ""} re-establishing`));
531
+ this.emit("error", new Error(`mesh connection closed${err ? `: ${err.message}` : ""} - re-establishing`));
327
532
  void this.reestablishLoop();
328
533
  });
329
534
  }
@@ -425,7 +630,7 @@ export class CotalEndpoint extends EventEmitter {
425
630
  * can report it. */
426
631
  async reconnect() {
427
632
  if (this.stopped)
428
- throw new Error("endpoint stopped cannot reconnect");
633
+ throw new Error("endpoint stopped - cannot reconnect");
429
634
  this.kickBackoff();
430
635
  try {
431
636
  await this.rebuild();
@@ -446,6 +651,10 @@ export class CotalEndpoint extends EventEmitter {
446
651
  clearInterval(this.heartbeatTimer);
447
652
  if (this.sweepTimer)
448
653
  clearInterval(this.sweepTimer);
654
+ if (this.bearerTimer)
655
+ clearTimeout(this.bearerTimer);
656
+ if (this.credsTimer)
657
+ clearTimeout(this.credsTimer);
449
658
  for (const msgs of this.streamMsgs) {
450
659
  try {
451
660
  msgs.stop();
@@ -478,7 +687,7 @@ export class CotalEndpoint extends EventEmitter {
478
687
  // itself be a wildcard subscription like `team.>`).
479
688
  const channel = opts?.channel ?? this.channels.find(isConcreteChannel) ?? "general";
480
689
  if (!isConcreteChannel(channel))
481
- throw new Error(`cannot publish to wildcard channel "${channel}" pick a concrete sub-channel`);
690
+ throw new Error(`cannot publish to wildcard channel "${channel}" - pick a concrete sub-channel`);
482
691
  const msg = {
483
692
  id: randomUUID(),
484
693
  ts: Date.now(),
@@ -492,7 +701,7 @@ export class CotalEndpoint extends EventEmitter {
492
701
  replyTo: opts?.replyTo,
493
702
  contextId: opts?.contextId,
494
703
  };
495
- await this.publishMsg(chatSubject(this.space, this.card.id, channel), msg);
704
+ await this.publishMsg(chatSubject(this.space, this.owner, this.actor, channel), msg);
496
705
  return msg;
497
706
  }
498
707
  /** Unicast: direct message to one specific instance. */
@@ -507,7 +716,12 @@ export class CotalEndpoint extends EventEmitter {
507
716
  replyTo: opts?.replyTo,
508
717
  contextId: opts?.contextId,
509
718
  };
510
- await this.publishMsg(unicastSubject(this.space, instanceId, this.card.id), msg);
719
+ // The recipient id is a principal dot-form `<owner>.<actor>` (owner+actor grammar); the 4-token DM
720
+ // subject forge-locks recipient AND sender, so both are split into their tokens here.
721
+ const recip = parsePrincipalKey(instanceId);
722
+ if (!recip)
723
+ throw new Error(`unicast: "${instanceId}" is not a valid recipient principal <owner>.<actor>`);
724
+ await this.publishMsg(unicastSubject(this.space, recip.owner, recip.actor, this.owner, this.actor), msg);
511
725
  return msg;
512
726
  }
513
727
  /** Anycast: deliver to ANY one instance of a service (role) — queue-group load balancing. */
@@ -522,7 +736,7 @@ export class CotalEndpoint extends EventEmitter {
522
736
  replyTo: opts?.replyTo,
523
737
  contextId: opts?.contextId,
524
738
  };
525
- await this.publishMsg(anycastSubject(this.space, service, this.card.id), msg);
739
+ await this.publishMsg(anycastSubject(this.space, service, this.owner, this.actor), msg);
526
740
  return msg;
527
741
  }
528
742
  /** Subscribe to a read-only observer feed. Defaults to the whole space; an observer under
@@ -561,7 +775,7 @@ export class CotalEndpoint extends EventEmitter {
561
775
  serveControl(service, handler, opts = {}) {
562
776
  if (!this.nc)
563
777
  throw new Error("endpoint not started");
564
- const sub = this.nc.subscribe(controlServiceSubject(this.space, service, "*"), {
778
+ const sub = this.nc.subscribe(controlServiceSubject(this.space, service, "*", "*"), {
565
779
  queue: service,
566
780
  });
567
781
  this.subs.push(sub);
@@ -581,10 +795,10 @@ export class CotalEndpoint extends EventEmitter {
581
795
  // the server policed who could publish; the payload `from` is advisory and must
582
796
  // match. Reject before the handler acts on a request claiming a forged sender.
583
797
  const parsed = parseSubject(m.subject);
584
- if (!parsed || req.from?.id !== parsed.sender) {
798
+ if (!parsed || req.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
585
799
  this.emit("error", new Error(`rejected control request on ${m.subject}: from ${req.from?.id ?? "(none)"} ` +
586
800
  `does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
587
- reply = { ok: false, error: "sender mismatch request rejected" };
801
+ reply = { ok: false, error: "sender mismatch - request rejected" };
588
802
  }
589
803
  else {
590
804
  reply = await handler(req);
@@ -618,7 +832,7 @@ export class CotalEndpoint extends EventEmitter {
618
832
  async requestControl(service, req, timeoutMs = 5000) {
619
833
  if (!this.nc)
620
834
  throw new Error(this.notLiveMsg());
621
- const reqSubject = controlServiceSubject(this.space, service, this.card.id);
835
+ const reqSubject = controlServiceSubject(this.space, service, this.owner, this.actor);
622
836
  const reply = `${reqSubject}.reply.${randomUUID()}`;
623
837
  const body = { ...req, from: req.from ?? this.ref() };
624
838
  const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
@@ -632,7 +846,7 @@ export class CotalEndpoint extends EventEmitter {
632
846
  async requestDelivery(op, args, timeoutMs = 5000) {
633
847
  if (!this.nc)
634
848
  throw new Error(this.notLiveMsg());
635
- const reqSubject = controlServiceSubject(this.space, CONTROL_DELIVERY, this.card.id); // ctl.delivery.<id>
849
+ const reqSubject = controlServiceSubject(this.space, CONTROL_DELIVERY, this.owner, this.actor); // ctl.delivery.<owner>.<actor>
636
850
  // Reply rides the sender's OWN subtree so the daemon's serveControl boundReply guard accepts it
637
851
  // (`${reqSubject}.reply.…`). The sender-bound guard is the COMPLETE confused-deputy closure. The
638
852
  // random suffix is genuine defense-in-depth (NOT cosmetic): `noMux` subscribes this SPECIFIC named
@@ -644,6 +858,21 @@ export class CotalEndpoint extends EventEmitter {
644
858
  const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
645
859
  return m.json();
646
860
  }
861
+ /** Send a PRIVILEGED delivery-admin request to the server-side delivery daemon and await its reply
862
+ * (the D5 rail-split: `reloadCreds` now, the eviction executor next). Same bounded-reply shape as
863
+ * {@link requestDelivery}; the cred layer is the real gate — only the manager's supervisor profile
864
+ * holds the request-publish grant, so an agent calling this gets a broker denial, not a handler
865
+ * refusal. NoResponders (no daemon) surfaces as the thrown request error — callers decide whether
866
+ * that degrades (renewal falls back to the daemon's 75% re-read backstop) or fails. */
867
+ async requestDeliveryAdmin(op, args, timeoutMs = 5000) {
868
+ if (!this.nc)
869
+ throw new Error(this.notLiveMsg());
870
+ const reqSubject = controlServiceSubject(this.space, CONTROL_DELIVERY_ADMIN, this.owner, this.actor);
871
+ const reply = `${reqSubject}.reply.${randomUUID()}`;
872
+ const body = { op, args, from: this.ref() };
873
+ const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
874
+ return m.json();
875
+ }
647
876
  // ---- presence ------------------------------------------------------------
648
877
  getRoster() {
649
878
  return [...this.roster.values()].sort((a, b) => a.card.name.localeCompare(b.card.name));
@@ -734,7 +963,7 @@ export class CotalEndpoint extends EventEmitter {
734
963
  this.joinSeq.delete(channel);
735
964
  throw new Error(`cannot join "${channel}": live subscription could not be confirmed (${e.message})`);
736
965
  }
737
- this.confirmingChatSubs.delete(chatSubject(this.space, "*", channel));
966
+ this.confirmingChatSubs.delete(chatSubject(this.space, "*", "*", channel));
738
967
  if (this.chatSubDenied.has(channel)) {
739
968
  this.unsubscribeChat(channel);
740
969
  this.joinSeq.delete(channel);
@@ -786,7 +1015,7 @@ export class CotalEndpoint extends EventEmitter {
786
1015
  // tombstone can't be confirmed propagates (live sub stays up, mirror intact) for the caller to retry
787
1016
  // — reporting `left` while the trusted reader keeps transferring to DLV is the fail-open leak. A
788
1017
  // genuine no-responder (open / no delivery daemon, no Plane-3) means there is no membership to tombstone.
789
- if (this.creds && effectiveDeliveryClass(this.channelConfigs.get(channel), this.channelDefaults) === "durable") {
1018
+ if (this.authed && effectiveDeliveryClass(this.channelConfigs.get(channel), this.channelDefaults) === "durable") {
790
1019
  let generation = this.plane3Channels.get(channel);
791
1020
  if (generation === undefined)
792
1021
  generation = (await this.fetchMemberships())?.find((m) => m.channel === channel)?.generation;
@@ -817,7 +1046,11 @@ export class CotalEndpoint extends EventEmitter {
817
1046
  if (info.state.subjects) {
818
1047
  for (const [subject, count] of Object.entries(info.state.subjects)) {
819
1048
  const p = parseSubject(subject);
820
- if (p?.kind === "chat")
1049
+ // Same surfacing-boundary defense as the message guards: parseSubject splits only, so an
1050
+ // old-shape alias (`chat.<nkey>.team.backend`) structurally parses with a raw-nkey owner and a
1051
+ // misattributed channel. Reject a non-principal owner token here too, or retained pre-flip
1052
+ // subjects would inflate this channel-count surface with phantom channels.
1053
+ if (p?.kind === "chat" && isPrincipalOwnerToken(p.owner))
821
1054
  counts.set(p.rest, (counts.get(p.rest) ?? 0) + count);
822
1055
  }
823
1056
  }
@@ -867,13 +1100,15 @@ export class CotalEndpoint extends EventEmitter {
867
1100
  arr.sort(byName);
868
1101
  return map;
869
1102
  }
870
- /** Lazily open the derived membership feed KV (admin/observer read; the delivery daemon writes it).
871
- * Read-only here the dashboard consumes it; agents hold no grant and never call this. */
872
- async membershipRegistry() {
1103
+ /** Lazily open the DERIVED membership FEED KV (`cotal_membership_<space>`; admin/observer read, the
1104
+ * delivery daemon writes it) the display-only who-is-subscribed view. Distinct from the authoritative
1105
+ * {@link membersRegistry} (`cotal_members_<space>`, the Plane-3 durable-membership source of truth): the
1106
+ * two names look alike, so this one is explicitly "feed". Read-only here; agents hold no grant. */
1107
+ async membershipFeedRegistry() {
873
1108
  if (!this.nc)
874
1109
  throw new Error("endpoint not started");
875
- this.membershipKv ??= await new Kvm(this.nc).open(membershipBucket(this.space));
876
- return this.membershipKv;
1110
+ this.membershipFeedKv ??= await new Kvm(this.nc).open(membershipBucket(this.space));
1111
+ return this.membershipFeedKv;
877
1112
  }
878
1113
  /**
879
1114
  * Snapshot the broker-sourced channel-membership feed (admin/observer read): every agent's
@@ -883,7 +1118,7 @@ export class CotalEndpoint extends EventEmitter {
883
1118
  * when the feed has never been written (no daemon → the dashboard degrades to traffic-only).
884
1119
  */
885
1120
  async readMembership() {
886
- const kv = await this.membershipRegistry();
1121
+ const kv = await this.membershipFeedRegistry();
887
1122
  const members = [];
888
1123
  let asOf;
889
1124
  for await (const key of await kv.keys()) {
@@ -910,7 +1145,7 @@ export class CotalEndpoint extends EventEmitter {
910
1145
  * stop handle. Best-effort: a feed the cred can't read (or absent) surfaces as an `error` event and
911
1146
  * the dashboard keeps its last snapshot. */
912
1147
  async watchMembership(onChange) {
913
- const kv = await this.membershipRegistry();
1148
+ const kv = await this.membershipFeedRegistry();
914
1149
  const iter = await kv.watch();
915
1150
  void (async () => {
916
1151
  for await (const _ of iter)
@@ -921,14 +1156,14 @@ export class CotalEndpoint extends EventEmitter {
921
1156
  /** Fetch recent messages from a channel's JetStream backlog. */
922
1157
  async channelHistory(channel, opts) {
923
1158
  // history from any sender
924
- return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", channel), opts?.limit ?? 100);
1159
+ return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
925
1160
  }
926
1161
  /** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
927
1162
  * a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
928
1163
  * skips for them — only an `admin`-profile cred can read it. */
929
1164
  async dmHistory(opts) {
930
- // every inst.<target>.<sender> DM
931
- return this.streamHistory(dmStream(this.space), unicastSubject(this.space, "*", "*"), opts?.limit ?? 100);
1165
+ // every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
1166
+ return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100);
932
1167
  }
933
1168
  /** Drain up to `limit` recent messages matching `subject` from a stream's backlog via a
934
1169
  * throwaway consumer. Fetches exactly the pending count (from consumer info) so it returns
@@ -992,7 +1227,7 @@ export class CotalEndpoint extends EventEmitter {
992
1227
  * else "endpoint not started" (genuine pre-start). */
993
1228
  notLiveMsg() {
994
1229
  return this.reconnecting || this.reestablishing
995
- ? "reconnecting try again shortly"
1230
+ ? "reconnecting - try again shortly"
996
1231
  : "endpoint not started";
997
1232
  }
998
1233
  async publishMsg(subject, msg) {
@@ -1019,9 +1254,9 @@ export class CotalEndpoint extends EventEmitter {
1019
1254
  * creating a durable filtered to someone else's inbox. Idempotent (byte-identical config),
1020
1255
  * safe to call again on manager restart. The caller must be permissive on DM_<space>.
1021
1256
  */
1022
- async provisionDmInbox(targetId) {
1257
+ async provisionDmInbox(owner, actor) {
1023
1258
  const jsm = await this.manager();
1024
- await jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, targetId));
1259
+ await jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, owner, actor));
1025
1260
  }
1026
1261
  /**
1027
1262
  * Privileged: pre-create an agent's bind-only Plane-3 DELIVER durable (`dlv_<id>`, filtered to
@@ -1030,9 +1265,9 @@ export class CotalEndpoint extends EventEmitter {
1030
1265
  * the agent never does. The trusted reader transfers re-authorized copies onto `dlv.<id>`; the agent
1031
1266
  * acks them via native JetStream (SPEC §8). Idempotent. The caller must be permissive on DLV.
1032
1267
  */
1033
- async provisionDlvInbox(targetId) {
1268
+ async provisionDlvInbox(owner, actor) {
1034
1269
  const jsm = await this.manager();
1035
- await jsm.consumers.add(dlvStream(this.space), dlvDurableConfig(this.space, targetId));
1270
+ await jsm.consumers.add(dlvStream(this.space), dlvDurableConfig(this.space, owner, actor));
1036
1271
  }
1037
1272
  /**
1038
1273
  * Privileged: pre-create a role's shared TASK work-queue durable (auth mode), so agents
@@ -1149,7 +1384,7 @@ export class CotalEndpoint extends EventEmitter {
1149
1384
  if (this.managerLeaseKv)
1150
1385
  return this.managerLeaseKv;
1151
1386
  const kvm = new Kvm(this.nc);
1152
- if (this.creds) {
1387
+ if (this.authed) {
1153
1388
  this.managerLeaseKv = await kvm.open(managerBucket(this.space));
1154
1389
  }
1155
1390
  else {
@@ -1235,13 +1470,17 @@ export class CotalEndpoint extends EventEmitter {
1235
1470
  const matches = [...this.roster.values()].filter((p) => p.card.name.toLowerCase() === name.toLowerCase());
1236
1471
  return matches.length === 1 ? matches[0].card.id : undefined;
1237
1472
  }
1238
- /** Publish one fan-out entry into an owner's mixed inbox, idempotent via `Nats-Msg-Id`
1239
- * (`<msgId>:<owner>:<generation>`) so a catch-up copy and a racing fan-out copy collapse. */
1240
- async publishDinbox(owner, entry) {
1473
+ /** Publish one fan-out entry into a member principal's mixed inbox, idempotent via `Nats-Msg-Id`
1474
+ * (`<msgId>:<principal>:<generation>`) so a catch-up copy and a racing fan-out copy collapse. The
1475
+ * `principal` is the member's owner+actor dot-form (dinbox is per-agent); split for the subject. */
1476
+ async publishDinbox(principal, entry) {
1241
1477
  if (!this.js)
1242
1478
  return;
1243
- await this.js.publish(dinboxSubject(this.space, owner), JSON.stringify(entry), {
1244
- msgID: `${entry.msg.id}:${owner}:${entry.generation}`,
1479
+ const p = parsePrincipalKey(principal);
1480
+ if (!p)
1481
+ throw new Error(`publishDinbox: "${principal}" is not a valid member principal <owner>.<actor>`);
1482
+ await this.js.publish(dinboxSubject(this.space, p.owner, p.actor), JSON.stringify(entry), {
1483
+ msgID: `${entry.msg.id}:${principal}:${entry.generation}`,
1245
1484
  });
1246
1485
  }
1247
1486
  /** The fan-out consumer's delivered stream-seq — the activation-fence upper bound (red-team
@@ -1330,14 +1569,19 @@ export class CotalEndpoint extends EventEmitter {
1330
1569
  async catchupCopy(owner, channel, fromSeqExcl, toSeqIncl, generation) {
1331
1570
  if (!this.js || !this.jsm || toSeqIncl <= fromSeqExcl)
1332
1571
  return { copied: 0, evicted: false };
1333
- const subject = chatSubject(this.space, "*", channel);
1572
+ const subject = chatSubject(this.space, "*", "*", channel);
1334
1573
  // Eviction = a message in `(joinCursor, …]` on THIS channel's subject aged out under discard=Old.
1335
1574
  // Judged PER-SUBJECT (reuse channelDropped: oldest-retained-for-subject vs the watermark, only at
1336
1575
  // the per-subject cap), NOT against the stream-global joinCursor+1 — other channels' traffic
1337
1576
  // inflates the global seq, so a naive "first delivered seq > joinCursor+1" false-positives on any
1338
1577
  // busy multi-channel space (impl-review HIGH-2). A true eviction → durableJoin reports durable:false.
1339
1578
  const evicted = await this.channelDropped(subject, fromSeqExcl);
1340
- const name = `cu_${token(owner)}_${generation}`;
1579
+ // Consumer NAME must be JetStream-safe (no `.`) AND collision-free: use the principal DASH-form
1580
+ // (`<owner>-<actor>`, `-` reserved as the sole separator), NOT `token(owner)` — `token()` maps the
1581
+ // dot-form `.`→`_`, which is NOT collision-free (`_` is legal inside a token, so `a.b_c` and `a_b.c`
1582
+ // would both underscore to `a_b_c`).
1583
+ const cuP = parsePrincipalKey(owner);
1584
+ const name = `cu_${cuP ? principalKey(cuP.owner, cuP.actor).name : token(owner)}_${generation}`;
1341
1585
  try {
1342
1586
  await this.jsm.consumers.delete(chatStream(this.space), name);
1343
1587
  }
@@ -1366,7 +1610,7 @@ export class CotalEndpoint extends EventEmitter {
1366
1610
  continue;
1367
1611
  }
1368
1612
  const parsed = parseSubject(m.subject);
1369
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === owner)
1613
+ if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
1370
1614
  continue;
1371
1615
  await this.publishDinbox(owner, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
1372
1616
  copied++;
@@ -1391,10 +1635,10 @@ export class CotalEndpoint extends EventEmitter {
1391
1635
  * resume on a daemon restart. Both the JS loops AND the `ctl.delivery` subscription are (re)bound by
1392
1636
  * {@link armPlane3} on EVERY (re)connect — a reconnect drains the old connection, so re-binding both
1393
1637
  * is required, not optional (the responder would otherwise be lost on a broker blip). */
1394
- async startPlane3(aclFor) {
1638
+ async startPlane3(aclFor, opts = {}) {
1395
1639
  if (!this.js)
1396
1640
  throw new Error("endpoint not started");
1397
- this.plane3 = { aclFor };
1641
+ this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal };
1398
1642
  await this.armPlane3();
1399
1643
  }
1400
1644
  /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
@@ -1499,6 +1743,51 @@ export class CotalEndpoint extends EventEmitter {
1499
1743
  this.subs.splice(i, 1);
1500
1744
  }
1501
1745
  this.deliveryServeSub = this.serveControl(CONTROL_DELIVERY, (req) => this.handleDeliveryControl(req), { boundReply: true });
1746
+ if (this.deliveryAdminServeSub) {
1747
+ try {
1748
+ this.deliveryAdminServeSub.unsubscribe();
1749
+ }
1750
+ catch { /* dead with the old connection */ }
1751
+ const i = this.subs.indexOf(this.deliveryAdminServeSub);
1752
+ if (i >= 0)
1753
+ this.subs.splice(i, 1);
1754
+ }
1755
+ this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
1756
+ }
1757
+ /** Serve one PRIVILEGED delivery-admin request (the D5 rail-split). The cred layer is the caller
1758
+ * boundary — only the supervisor profile can publish here — and `serveControl`'s sender check +
1759
+ * bounded reply still apply on top. `reloadCreds` is the class-2 renewal ADOPTION step: re-read
1760
+ * the renewal-owner-re-signed creds file, pin, swap the live connection, reconnect the membership
1761
+ * feed's rw connection, and reply with proof (identities + the adopted JWT windows) — or a
1762
+ * structured failure (e.g. the file was never re-signed), never a silent partial. */
1763
+ async handleDeliveryAdmin(req) {
1764
+ if (req.op === "reloadCreds") {
1765
+ try {
1766
+ const delivery = await this.reloadCreds();
1767
+ const membership = this.plane3?.reloadMembershipCreds ? await this.plane3.reloadMembershipCreds() : undefined;
1768
+ return { ok: true, data: { delivery, ...(membership !== undefined ? { membership } : {}) } };
1769
+ }
1770
+ catch (e) {
1771
+ return { ok: false, error: e.message };
1772
+ }
1773
+ }
1774
+ if (req.op === "evictPrincipal") {
1775
+ // The LIVE-EVICTION executor (D5 slice 6): force-drop a denied principal's connections.
1776
+ // Composition-root hook because the $SYS observer/evictor creds live outside this endpoint's
1777
+ // trust boundary; absent hook = a daemon build without the executor, refused loudly.
1778
+ if (!this.plane3?.evictPrincipal)
1779
+ return { ok: false, error: "evictPrincipal: no eviction executor wired on this daemon" };
1780
+ const principal = typeof req.args?.principal === "string" ? req.args.principal.trim() : "";
1781
+ if (!principal)
1782
+ return { ok: false, error: "evictPrincipal: a principal (owner.actor dot-form) is required" };
1783
+ try {
1784
+ return { ok: true, data: await this.plane3.evictPrincipal(principal) };
1785
+ }
1786
+ catch (e) {
1787
+ return { ok: false, error: e.message };
1788
+ }
1789
+ }
1790
+ return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
1502
1791
  }
1503
1792
  /** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
1504
1793
  * the trusted reader is the auth gate). */
@@ -1547,10 +1836,10 @@ export class CotalEndpoint extends EventEmitter {
1547
1836
  m.ack();
1548
1837
  return;
1549
1838
  }
1550
- if (!msg.from || msg.from.id !== parsed.sender) {
1839
+ if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
1551
1840
  m.ack();
1552
1841
  return;
1553
- } // authenticity
1842
+ } // authenticity (owner must be a real principal, not an old-shape alias)
1554
1843
  const seq = m.seq;
1555
1844
  if ((await this.deliveryClassFresh(channel)) === "durable") {
1556
1845
  for (const rec of await listMembers(await this.membersRegistry(), { channel })) {
@@ -1607,11 +1896,12 @@ export class CotalEndpoint extends EventEmitter {
1607
1896
  * revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
1608
1897
  * has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
1609
1898
  async readerHandle(m) {
1610
- const owner = parseDinboxOwner(m.subject);
1611
- if (!owner) {
1899
+ const pr = parseDinboxPrincipal(m.subject);
1900
+ if (!pr) {
1612
1901
  m.ack();
1613
1902
  return;
1614
1903
  } // unparseable subject — not a real entry
1904
+ const owner = `${pr.owner}.${pr.actor}`; // the member principal dot-form (acl/member keys, msgID)
1615
1905
  let entry;
1616
1906
  try {
1617
1907
  entry = m.json();
@@ -1653,7 +1943,7 @@ export class CotalEndpoint extends EventEmitter {
1653
1943
  }
1654
1944
  }
1655
1945
  try {
1656
- await this.js.publish(dlvSubject(this.space, owner), JSON.stringify(entry.msg), {
1946
+ await this.js.publish(dlvSubject(this.space, pr.owner, pr.actor), JSON.stringify(entry.msg), {
1657
1947
  msgID: `${entry.msg.id}:${owner}:${entry.generation}`,
1658
1948
  });
1659
1949
  }
@@ -1680,7 +1970,7 @@ export class CotalEndpoint extends EventEmitter {
1680
1970
  return;
1681
1971
  let consumer;
1682
1972
  try {
1683
- consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.card.id));
1973
+ consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor));
1684
1974
  }
1685
1975
  catch {
1686
1976
  return;
@@ -1747,7 +2037,7 @@ export class CotalEndpoint extends EventEmitter {
1747
2037
  }
1748
2038
  catch (e) {
1749
2039
  if (attempt === 0)
1750
- this.emit("error", new Error(`channel "${channel}": Plane-3 durable membership (generation ${generation}) not yet tombstoned after a refused live sub retrying; §7 boundary may be open until it succeeds (${e.message})`));
2040
+ this.emit("error", new Error(`channel "${channel}": Plane-3 durable membership (generation ${generation}) not yet tombstoned after a refused live sub - retrying; §7 boundary may be open until it succeeds (${e.message})`));
1751
2041
  await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt)));
1752
2042
  }
1753
2043
  }
@@ -1845,7 +2135,7 @@ export class CotalEndpoint extends EventEmitter {
1845
2135
  }
1846
2136
  catch (e) {
1847
2137
  if (attempt === 0 && !this.isNoResponders(e))
1848
- this.emit("error", new Error(`channel "${channel}": boot durable self-join not yet established retrying until the delivery daemon is reachable (${e.message})`));
2138
+ this.emit("error", new Error(`channel "${channel}": boot durable self-join not yet established - retrying until the delivery daemon is reachable (${e.message})`));
1849
2139
  }
1850
2140
  }
1851
2141
  }
@@ -1867,17 +2157,16 @@ export class CotalEndpoint extends EventEmitter {
1867
2157
  async startConsumers() {
1868
2158
  if (!this.jsm)
1869
2159
  throw new Error("endpoint not started");
1870
- const id = this.card.id;
1871
- // Unicast: this instance's private DM inbox. Open mode self-creates; auth mode BINDS a
1872
- // durable the provisioner pre-created (agents are denied CONSUMER.CREATE on DM_<space>,
1873
- // since the create-time filter_subject is the attack surface — see provisionDmInbox).
1874
- if (!this.creds) {
1875
- await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, id, {
2160
+ // Unicast: this instance's private DM inbox, keyed on this endpoint's owner+actor principal. Open
2161
+ // mode self-creates; auth mode BINDS a durable the provisioner pre-created (agents are denied
2162
+ // CONSUMER.CREATE on DM_<space>, since the create-time filter_subject is the attack surface).
2163
+ if (!this.authed) {
2164
+ await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, this.owner, this.actor, {
1876
2165
  ackWaitMs: this.ackWaitMs,
1877
2166
  inactiveThresholdMs: this.inactiveThresholdMs,
1878
2167
  }));
1879
2168
  }
1880
- await this.pump(dmStream(this.space), dmDurable(id));
2169
+ await this.pump(dmStream(this.space), dmDurable(this.owner, this.actor));
1881
2170
  // Plane-3 (SPEC §8): bind + pump our per-member DELIVER durable (`dlv_<id>`) — the re-authorized
1882
2171
  // durable-backstop channel copies the trusted reader transfers to us. No-op when it isn't present
1883
2172
  // (open mode / un-provisioned). Auth-only feature; the pump self-guards on the durable's existence.
@@ -1899,14 +2188,14 @@ export class CotalEndpoint extends EventEmitter {
1899
2188
  this.subscribeChat(ch);
1900
2189
  await this.confirmChatSub();
1901
2190
  for (const ch of this.channels)
1902
- this.confirmingChatSubs.delete(chatSubject(this.space, "*", ch));
2191
+ this.confirmingChatSubs.delete(chatSubject(this.space, "*", "*", ch));
1903
2192
  if (armed)
1904
2193
  await this.backfillArmed(armed);
1905
2194
  }
1906
2195
  // First connect, auth mode: self-join BOOT durable channels via the server-side delivery daemon
1907
2196
  // (it owns membership now — there is no manager-written boot membership). Seeds plane3Channels so a
1908
2197
  // later leave can tombstone the §7 boundary; idempotent on relaunch. Open mode has no Plane-3.
1909
- if (this.firstConnect && this.creds && this.channels.length)
2198
+ if (this.firstConnect && this.authed && this.channels.length)
1910
2199
  await this.armBootDurableMemberships();
1911
2200
  this.firstConnect = false;
1912
2201
  // Anycast: a shared work-queue consumer for our role — one instance grabs each task.
@@ -1914,7 +2203,7 @@ export class CotalEndpoint extends EventEmitter {
1914
2203
  // durable (agents are denied CONSUMER.CREATE on TASK_<space>, since the create-time
1915
2204
  // filter is the cross-role-drain attack surface — see provisionTaskQueue).
1916
2205
  if (this.card.role) {
1917
- if (!this.creds) {
2206
+ if (!this.authed) {
1918
2207
  await this.jsm.consumers.add(taskStream(this.space), taskDurableConfig(this.space, this.card.role, { ackWaitMs: this.ackWaitMs }));
1919
2208
  }
1920
2209
  await this.pump(taskStream(this.space), taskDurable(this.card.role));
@@ -1943,7 +2232,7 @@ export class CotalEndpoint extends EventEmitter {
1943
2232
  // and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
1944
2233
  // Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
1945
2234
  const parsed = parseSubject(m.subject);
1946
- if (!parsed || !msg.from || msg.from.id !== parsed.sender) {
2235
+ if (!parsed || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
1947
2236
  m.term();
1948
2237
  this.emit("error", new Error(`dropped message on ${m.subject}: payload from ${msg.from?.id ?? "(none)"} ` +
1949
2238
  `does not match subject sender ${parsed?.sender ?? "(unparseable)"}`));
@@ -1990,7 +2279,7 @@ export class CotalEndpoint extends EventEmitter {
1990
2279
  if (!this.nc || this.chatSubs.has(channel))
1991
2280
  return;
1992
2281
  this.chatSubDenied.delete(channel);
1993
- const subject = chatSubject(this.space, "*", channel);
2282
+ const subject = chatSubject(this.space, "*", "*", channel);
1994
2283
  this.confirmingChatSubs.add(subject);
1995
2284
  const sub = this.nc.subscribe(subject, {
1996
2285
  callback: (err, m) => {
@@ -2032,8 +2321,8 @@ export class CotalEndpoint extends EventEmitter {
2032
2321
  this.emit("error", e);
2033
2322
  return;
2034
2323
  }
2035
- if (!msg.from || msg.from.id !== parsed.sender)
2036
- return; // spoof/malformed — drop (at-most-once)
2324
+ if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner))
2325
+ return; // spoof/malformed/old-shape-alias — drop (at-most-once)
2037
2326
  if (msg.from.id === this.card.id)
2038
2327
  return; // our own echo
2039
2328
  const delivery = { ack: () => { }, nak: () => { }, durable: false }; // live = at-most-once, not acked
@@ -2047,7 +2336,7 @@ export class CotalEndpoint extends EventEmitter {
2047
2336
  }
2048
2337
  /** Close a channel's core subscription (manager-free leave). */
2049
2338
  unsubscribeChat(channel) {
2050
- this.confirmingChatSubs.delete(chatSubject(this.space, "*", channel));
2339
+ this.confirmingChatSubs.delete(chatSubject(this.space, "*", "*", channel));
2051
2340
  const sub = this.chatSubs.get(channel);
2052
2341
  if (sub) {
2053
2342
  try {
@@ -2164,7 +2453,7 @@ export class CotalEndpoint extends EventEmitter {
2164
2453
  if (!this.jsm || !this.js)
2165
2454
  throw new Error("endpoint not started");
2166
2455
  const stream = chatStream(this.space);
2167
- const name = chatHistDurable(this.card.id);
2456
+ const name = chatHistDurable(this.owner, this.actor);
2168
2457
  const out = [];
2169
2458
  // Clear any consumer leaked by a crashed prior read before re-creating it with THIS read's
2170
2459
  // single filter (the read ACL is enforced at create — see the doc above).
@@ -2219,7 +2508,7 @@ export class CotalEndpoint extends EventEmitter {
2219
2508
  * `start_time` (now − window); unset ⇒ the full retained window. New messages (`seq > upToSeq`)
2220
2509
  * are skipped — the live tail owns them. Reads through the contained {@link collectHistory}. */
2221
2510
  async backfillChannel(channel, upToSeq, sinceMs) {
2222
- const subject = chatSubject(this.space, "*", channel);
2511
+ const subject = chatSubject(this.space, "*", "*", channel);
2223
2512
  const start = sinceMs === undefined ? { seq: 1 } : { time: new Date(Date.now() - sinceMs) };
2224
2513
  let msgs;
2225
2514
  try {
@@ -2241,7 +2530,7 @@ export class CotalEndpoint extends EventEmitter {
2241
2530
  }
2242
2531
  // Same authenticity guard as the tail; skip our own echoes in history.
2243
2532
  const parsed = parseSubject(sm.subject);
2244
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
2533
+ if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
2245
2534
  continue;
2246
2535
  // Backfill only ever reads the chat stream, so the authenticated class is always "channel".
2247
2536
  this.emit("message", msg, noop, { historical: true, kind: "channel" });
@@ -2269,7 +2558,7 @@ export class CotalEndpoint extends EventEmitter {
2269
2558
  const policy = await this.joinPolicyFresh(channel);
2270
2559
  if (!policy.replay)
2271
2560
  return { messages: [], dropped: false };
2272
- const subject = chatSubject(this.space, "*", channel);
2561
+ const subject = chatSubject(this.space, "*", "*", channel);
2273
2562
  let raw;
2274
2563
  try {
2275
2564
  raw = await this.collectHistory(subject, { seq: sinceSeq + 1 });
@@ -2289,7 +2578,7 @@ export class CotalEndpoint extends EventEmitter {
2289
2578
  }
2290
2579
  // Same authenticity guard as the tail/backfill; skip our own echoes.
2291
2580
  const parsed = parseSubject(sm.subject);
2292
- if (!parsed || msg.from?.id !== parsed.sender || msg.from.id === this.card.id)
2581
+ if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
2293
2582
  continue;
2294
2583
  collected.push(msg);
2295
2584
  }
@@ -2511,20 +2800,90 @@ function sameChannelModes(a, b) {
2511
2800
  }
2512
2801
  function authOpts(a) {
2513
2802
  const tls = a.tls ? {} : undefined;
2803
+ // USER MODE: present the shared auth-account sentinel creds AND the user bearer as `auth_token` (an
2804
+ // authenticator ARRAY — nats.js merges them into one CONNECT). The connect lands in the callout
2805
+ // account, which validates the bearer and re-binds the client into the data account with a scoped JWT.
2806
+ if (a.bearer) {
2807
+ if (a.creds || a.token || a.user || a.pass)
2808
+ throw new Error("bearer (user-mode auth) is mutually exclusive with creds/token/user/pass");
2809
+ if (!a.sentinelCreds)
2810
+ throw new Error("user-mode bearer requires sentinelCreds");
2811
+ return {
2812
+ authenticator: [credsAuthenticator(new TextEncoder().encode(a.sentinelCreds)), tokenAuthenticator(a.bearer)],
2813
+ tls,
2814
+ };
2815
+ }
2514
2816
  // creds (JWT/nkey) are mutually exclusive with token/user/pass — reject rather than
2515
2817
  // silently pick one, so a misconfigured caller fails loud.
2516
2818
  if (a.creds) {
2517
2819
  if (a.token || a.user || a.pass)
2518
2820
  throw new Error("creds are mutually exclusive with token/user/pass auth");
2519
- return { authenticator: credsAuthenticator(new TextEncoder().encode(a.creds)), tls };
2821
+ const creds = a.creds;
2822
+ // A getter re-wraps per (re)connect attempt so each attempt signs with the freshest cred;
2823
+ // nats.js invokes the authenticator function on every attempt, including internal reconnects.
2824
+ const authenticator = typeof creds === "function"
2825
+ ? (nonce) => credsAuthenticator(new TextEncoder().encode(creds()))(nonce)
2826
+ : credsAuthenticator(new TextEncoder().encode(creds));
2827
+ return { authenticator, tls };
2520
2828
  }
2521
2829
  return { token: a.token, user: a.user, pass: a.pass, tls };
2522
2830
  }
2831
+ /** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
2832
+ * bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
2833
+ * bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
2834
+ * claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
2835
+ * `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
2836
+ /** Ms until a source-fed cred's RENEWAL point — 75% of its iat→exp lifetime (the cert-manager-style
2837
+ * renew-early convention: the remaining 25% is the loud-failure window, wide for day-scale standing
2838
+ * creds). Negative when already past it. A source-fed cred WITHOUT a numeric `exp` is fail-loud:
2839
+ * the renewal seam exists precisely for bounded creds, so an unbounded one signals a matrix/caller
2840
+ * mismatch, not a cred to keep silently forever. */
2841
+ function credsRenewalDelayMs(creds) {
2842
+ const claims = credsClaims(creds); // throws on a structurally-unusable file (fail-loud)
2843
+ if (typeof claims.exp !== "number")
2844
+ throw new Error("creds source returned a cred without a numeric exp - a standing-renewal endpoint requires bounded creds (mint with a lifetime, or pass a static string instead of a source)");
2845
+ const iatMs = (typeof claims.iat === "number" ? claims.iat : Date.now() / 1000) * 1000;
2846
+ const expMs = claims.exp * 1000;
2847
+ return iatMs + 0.75 * (expMs - iatMs) - Date.now();
2848
+ }
2849
+ /** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
2850
+ * `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
2851
+ function bearerExpiryMs(bearer) {
2852
+ const payload = bearer.split(".")[1];
2853
+ if (!payload)
2854
+ throw new Error("user-mode bearer is not a JWT (no payload segment)");
2855
+ let claims;
2856
+ try {
2857
+ claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2858
+ }
2859
+ catch {
2860
+ throw new Error("user-mode bearer payload is not valid base64url JSON");
2861
+ }
2862
+ if (typeof claims.exp !== "number")
2863
+ throw new Error("user-mode bearer is missing a numeric exp claim");
2864
+ return claims.exp * 1000;
2865
+ }
2866
+ function decodeBearerPrincipal(bearer) {
2867
+ const payload = bearer.split(".")[1];
2868
+ if (!payload)
2869
+ throw new Error("user-mode bearer is not a JWT (no payload segment)");
2870
+ let claims;
2871
+ try {
2872
+ claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2873
+ }
2874
+ catch {
2875
+ throw new Error("user-mode bearer payload is not valid base64url JSON");
2876
+ }
2877
+ const owner = claims.sub, actor = claims.act?.actor;
2878
+ if (typeof owner !== "string" || typeof actor !== "string")
2879
+ throw new Error("user-mode bearer is missing a string sub (owner) / act.actor claim");
2880
+ return { owner, actor };
2881
+ }
2523
2882
  /** Turn a raw async-status error into one whose message says *why* — a permission
2524
2883
  * violation looks like absence unless it's named as a denial. */
2525
2884
  function describeStatusError(err) {
2526
2885
  if (err instanceof PermissionViolationError) {
2527
- return new Error(`NATS permission denied: cannot ${err.operation} "${err.subject}" check this ` +
2886
+ return new Error(`NATS permission denied: cannot ${err.operation} "${err.subject}" - check this ` +
2528
2887
  `endpoint's ACLs (a denied peer looks "absent" rather than blocked)`, { cause: err });
2529
2888
  }
2530
2889
  return err;
@@ -2653,8 +3012,22 @@ export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
2653
3012
  return { ok: true };
2654
3013
  }
2655
3014
  catch (e) {
2656
- if (e instanceof AuthorizationError || e instanceof UserAuthenticationExpiredError)
3015
+ if (e instanceof UserAuthenticationExpiredError)
3016
+ return { ok: false, reason: "stale-auth" };
3017
+ if (e instanceof AuthorizationError) {
3018
+ // The broker's denial is generic; local knowledge isn't. A presented cred that is PROVABLY
3019
+ // expired by its own JWT is stale-auth (credential death), not "wrong mesh/creds" — the
3020
+ // repair differs (doctor auth vs re-target), so the classification must too. Unreadable
3021
+ // content stays a plain rejection (no false stale diagnosis from garbage).
3022
+ if (typeof opts.creds === "string") {
3023
+ try {
3024
+ if (inspectCredHealth(opts.creds).state === "expired")
3025
+ return { ok: false, reason: "stale-auth" };
3026
+ }
3027
+ catch { /* not introspectable — keep the wire truth */ }
3028
+ }
2657
3029
  return { ok: false, reason: "auth-required" };
3030
+ }
2658
3031
  return { ok: false, reason: "unreachable" };
2659
3032
  }
2660
3033
  }