@cotal-ai/core 0.48.2 → 0.49.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 (104) hide show
  1. package/dist/agent-file.d.ts +34 -0
  2. package/dist/agent-file.d.ts.map +1 -1
  3. package/dist/agent-file.js +94 -2
  4. package/dist/agent-file.js.map +1 -1
  5. package/dist/auth-provider.d.ts +25 -1
  6. package/dist/auth-provider.d.ts.map +1 -1
  7. package/dist/auth-provider.js.map +1 -1
  8. package/dist/connector.d.ts +4 -0
  9. package/dist/connector.d.ts.map +1 -1
  10. package/dist/connector.js.map +1 -1
  11. package/dist/endpoint-action.d.ts +1 -0
  12. package/dist/endpoint-action.d.ts.map +1 -1
  13. package/dist/endpoint-action.js +1 -1
  14. package/dist/endpoint-action.js.map +1 -1
  15. package/dist/endpoint-binding.d.ts +6 -0
  16. package/dist/endpoint-binding.d.ts.map +1 -1
  17. package/dist/endpoint-binding.js +17 -0
  18. package/dist/endpoint-binding.js.map +1 -1
  19. package/dist/endpoint-grants.d.ts.map +1 -1
  20. package/dist/endpoint-grants.js +34 -12
  21. package/dist/endpoint-grants.js.map +1 -1
  22. package/dist/endpoint-invoke.d.ts.map +1 -1
  23. package/dist/endpoint-invoke.js +9 -49
  24. package/dist/endpoint-invoke.js.map +1 -1
  25. package/dist/endpoint-publish-denial.d.ts +35 -0
  26. package/dist/endpoint-publish-denial.d.ts.map +1 -0
  27. package/dist/endpoint-publish-denial.js +56 -0
  28. package/dist/endpoint-publish-denial.js.map +1 -0
  29. package/dist/endpoint-serve-kv.d.ts +16 -0
  30. package/dist/endpoint-serve-kv.d.ts.map +1 -1
  31. package/dist/endpoint-serve-kv.js +29 -0
  32. package/dist/endpoint-serve-kv.js.map +1 -1
  33. package/dist/endpoint-serve.d.ts.map +1 -1
  34. package/dist/endpoint-serve.js +9 -4
  35. package/dist/endpoint-serve.js.map +1 -1
  36. package/dist/endpoint-service.d.ts +21 -0
  37. package/dist/endpoint-service.d.ts.map +1 -1
  38. package/dist/endpoint-service.js +150 -15
  39. package/dist/endpoint-service.js.map +1 -1
  40. package/dist/endpoint-subjects.d.ts +34 -0
  41. package/dist/endpoint-subjects.d.ts.map +1 -1
  42. package/dist/endpoint-subjects.js +87 -18
  43. package/dist/endpoint-subjects.js.map +1 -1
  44. package/dist/endpoint-verbs.d.ts +4 -2
  45. package/dist/endpoint-verbs.d.ts.map +1 -1
  46. package/dist/endpoint-verbs.js +22 -7
  47. package/dist/endpoint-verbs.js.map +1 -1
  48. package/dist/endpoint.d.ts +155 -5
  49. package/dist/endpoint.d.ts.map +1 -1
  50. package/dist/endpoint.js +528 -48
  51. package/dist/endpoint.js.map +1 -1
  52. package/dist/index.d.ts +4 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +8 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/issued-authority.d.ts +168 -0
  57. package/dist/issued-authority.d.ts.map +1 -0
  58. package/dist/issued-authority.js +497 -0
  59. package/dist/issued-authority.js.map +1 -0
  60. package/dist/issuer-session.d.ts +30 -0
  61. package/dist/issuer-session.d.ts.map +1 -0
  62. package/dist/issuer-session.js +51 -0
  63. package/dist/issuer-session.js.map +1 -0
  64. package/dist/lifecycle-state.d.ts +9 -2
  65. package/dist/lifecycle-state.d.ts.map +1 -1
  66. package/dist/lifecycle-state.js +11 -2
  67. package/dist/lifecycle-state.js.map +1 -1
  68. package/dist/provision.d.ts +24 -14
  69. package/dist/provision.d.ts.map +1 -1
  70. package/dist/provision.js +137 -14
  71. package/dist/provision.js.map +1 -1
  72. package/dist/remote-manager-authority.d.ts +126 -1
  73. package/dist/remote-manager-authority.d.ts.map +1 -1
  74. package/dist/remote-manager-authority.js +21 -0
  75. package/dist/remote-manager-authority.js.map +1 -1
  76. package/dist/run-admission.d.ts +88 -0
  77. package/dist/run-admission.d.ts.map +1 -0
  78. package/dist/run-admission.js +193 -0
  79. package/dist/run-admission.js.map +1 -0
  80. package/dist/run-driver-grants.d.ts.map +1 -1
  81. package/dist/run-driver-grants.js +7 -0
  82. package/dist/run-driver-grants.js.map +1 -1
  83. package/dist/run-host.d.ts +5 -0
  84. package/dist/run-host.d.ts.map +1 -1
  85. package/dist/run-host.js.map +1 -1
  86. package/dist/runtime.d.ts +31 -3
  87. package/dist/runtime.d.ts.map +1 -1
  88. package/dist/secret-fs.d.ts +25 -0
  89. package/dist/secret-fs.d.ts.map +1 -1
  90. package/dist/secret-fs.js +136 -3
  91. package/dist/secret-fs.js.map +1 -1
  92. package/dist/secret-store.d.ts +38 -0
  93. package/dist/secret-store.d.ts.map +1 -1
  94. package/dist/secret-store.js +53 -1
  95. package/dist/secret-store.js.map +1 -1
  96. package/dist/streams.d.ts +23 -75
  97. package/dist/streams.d.ts.map +1 -1
  98. package/dist/streams.js +65 -85
  99. package/dist/streams.js.map +1 -1
  100. package/dist/subjects.d.ts +3 -1
  101. package/dist/subjects.d.ts.map +1 -1
  102. package/dist/subjects.js +3 -1
  103. package/dist/subjects.js.map +1 -1
  104. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -5,14 +5,16 @@ import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, Author
5
5
  import { wsconnect } from "@nats-io/nats-core";
6
6
  import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
7
7
  import { inspectCredHealth } from "./provision.js";
8
+ import { parseSecretStoreIdentity, } from "./secret-store.js";
8
9
  import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
9
10
  import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
10
11
  import { isRepeatSafeCommand } from "./endpoint-grants.js";
11
- import { assertIdToken } from "./endpoint-subjects.js";
12
+ import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
13
+ import { readAcceptedRow } from "./issued-authority.js";
12
14
  import { liveKvEntries } from "./kv-scan.js";
13
15
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
14
16
  import { assertValidName } from "./resolve.js";
15
- import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
17
+ import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY } from "./streams.js";
16
18
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
17
19
  import {} from "@nats-io/jetstream";
18
20
  import { Kvm } from "@nats-io/kv";
@@ -112,6 +114,12 @@ export class CotalEndpoint extends EventEmitter {
112
114
  /** The freshest bearer — what every (re)connect attempt presents. */
113
115
  currentBearer;
114
116
  bearerTimer;
117
+ /** Arms against the credential authenticated on the current wire. nats-core 3.4.0 discards the
118
+ * promise returned by its async transport-close continuation; if that continuation enters the
119
+ * reconnect dial loop with an expired JWT, its terminal auth error becomes an unhandled rejection.
120
+ * Cotal knows the JWT expiry, so it disables the library reconnect before the broker closes the
121
+ * transport. The endpoint's observed `closed()` supervisor remains responsible for rebuilding. */
122
+ authExpiryReconnectTimer;
115
123
  sentinelCreds;
116
124
  tls;
117
125
  heartbeatMs;
@@ -202,6 +210,10 @@ export class CotalEndpoint extends EventEmitter {
202
210
  firstConnect = true;
203
211
  heartbeatTimer;
204
212
  sweepTimer;
213
+ /** #1356: when the presence bucket started refusing writes; undefined once one succeeds. */
214
+ presenceWriteFailingSince;
215
+ /** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
216
+ lastPresenceWriteError;
205
217
  roster = new Map();
206
218
  /** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
207
219
  presenceSnapshot = Promise.resolve();
@@ -216,6 +228,18 @@ export class CotalEndpoint extends EventEmitter {
216
228
  lastPresenceWatchAt = 0;
217
229
  /** Last emitted presence-view state. Suppresses duplicate `presence-view` events. */
218
230
  presenceViewState = "unpopulated";
231
+ /** A presence-watch rebind in flight (see {@link rebindStalePresenceWatch}); one at a time. */
232
+ presenceRebind;
233
+ /** Bumped by every connection-scoped teardown and by {@link stop}. A presence bind that was
234
+ * awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
235
+ * iterator it got and installs nothing (see {@link startPresenceWatch}). */
236
+ presenceEpoch = 0;
237
+ /** The current watch was bound onto a bucket with NO keys (see {@link markPresenceBucketEmpty}).
238
+ * Such a watch cannot deliver until someone writes, so its silence is not staleness. */
239
+ presenceWatchEmpty = false;
240
+ /** Wall-clock of the last rebind attempt, so a bucket that is silent because it is EMPTY (or a
241
+ * broker that keeps refusing the consumer create) is retried once per TTL, not per sweep tick. */
242
+ presenceRebindAt = 0;
219
243
  status = "idle";
220
244
  activity;
221
245
  /** Mirror of the connector's authoritative attention state, published in presence (advisory). The
@@ -256,6 +280,9 @@ export class CotalEndpoint extends EventEmitter {
256
280
  actorIsEphemeral;
257
281
  /** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
258
282
  ownLifecycleUid;
283
+ acceptedToken;
284
+ /** The issuer-bound generation, learned once per connection from the accepted row. */
285
+ issuedGeneration;
259
286
  /** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
260
287
  * `failed-precondition` currency refusal (the described incarnation was superseded). */
261
288
  resolvedServices = new Map();
@@ -376,6 +403,11 @@ export class CotalEndpoint extends EventEmitter {
376
403
  : this.authed
377
404
  ? undefined
378
405
  : mintLifecycleUid();
406
+ if (opts.acceptedToken !== undefined) {
407
+ if (!opts.creds)
408
+ throw new Error("EndpointOptions.acceptedToken names a static issuance and needs creds beside it (SPEC 13.15)");
409
+ this.acceptedToken = assertGeneration(opts.acceptedToken, "acceptedToken");
410
+ }
379
411
  // `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
380
412
  // principalKey validates both tokens.
381
413
  const principal = principalKey(this.owner, this.actor);
@@ -464,6 +496,48 @@ export class CotalEndpoint extends EventEmitter {
464
496
  /** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
465
497
  * (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
466
498
  static CREDS_RETRY_MS = 60_000;
499
+ /** THE ONE PLACE a credential is cleared for presentation to a broker.
500
+ *
501
+ * The property is unconditional — this endpoint never presents a credential it has already
502
+ * decoded as expired — so it is a property of the SUPPLY, not of any one dial site. It used to
503
+ * live inside {@link bindConnection}, which only `start()` and `doRebuild` reach; the
504
+ * authenticator nats.js re-evaluates on ITS OWN reconnects read the cache directly and so
505
+ * presented whatever was last fetched, expired included. Two such reconnects exist and neither
506
+ * passes through bindConnection: the one the broker forces at JWT `exp`, and an ALREADY RUNNING
507
+ * dial loop from an earlier drop that crosses `exp` while it retries (the pre-expiry
508
+ * reconnect fence flips a policy flag nats-core only reads when it observes a NEW drop, so it
509
+ * cannot stop a loop already in flight).
510
+ *
511
+ * Putting the refusal here instead means a future caller cannot miss it: the only way to reach a
512
+ * dial is through {@link credsForWire}, and the one presentation that does not read the cache
513
+ * (the adoption preflight, which presents a fresh CANDIDATE) calls this same function on it.
514
+ *
515
+ * An unbounded credential (no numeric `exp`) is presentable: bounded lifetimes are the renewal
516
+ * seam's concern, and a cred with no expiry has none to be past. */
517
+ static presentableCreds(creds, opts) {
518
+ const { exp } = credsClaims(creds); // throws on a structurally-unusable file (fail-loud)
519
+ if (typeof exp === "number" && exp * 1000 <= Date.now())
520
+ throw new Error(opts.renewable
521
+ ? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
522
+ : "this endpoint's creds have expired and it holds no creds source to renew them - replace the credential and rebuild the endpoint (pass a creds FUNCTION for standing renewal)");
523
+ return creds;
524
+ }
525
+ /** The cached credential, checked. Handed to nats.js as the authenticator's source on EVERY auth
526
+ * mode (renewed or static), so each (re)connect attempt — ours or the library's — re-reads a
527
+ * CHECKED value. A refusal throws out of the authenticator, which nats-core turns into a closed
528
+ * connection rather than a CONNECT carrying dead material; the endpoint's own supervisor then
529
+ * rebuilds on capped backoff, and {@link bindConnection} re-fetches from the source on each of
530
+ * those attempts, so a renewal that starts working recovers the endpoint without presenting
531
+ * anything expired in the meantime. Deliberately side-effect free: kicking the renewal timer
532
+ * from here would retry the source once per dial attempt, which is the flat load on a dead
533
+ * broker that {@link RETRY_BACKOFF_CAP_MS} exists to prevent. */
534
+ credsForWire() {
535
+ if (!this.currentCreds)
536
+ throw new Error(this.credsSource
537
+ ? "this endpoint has no credential to present yet (the creds source has not returned one) - not dialing without auth material"
538
+ : "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
539
+ return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
540
+ }
467
541
  /** The disposable-preflight connect bound for the EXPLICIT reload proof (D5 class-2 adoption). A
468
542
  * rogue or unreachable candidate must resolve well UNDER the manager's delivery-admin request
469
543
  * bound, so this stays a few seconds and never blocks the responder. */
@@ -497,13 +571,62 @@ export class CotalEndpoint extends EventEmitter {
497
571
  /** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
498
572
  * (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
499
573
  * broker to close the connection at `exp`, means the wire never carries a near-dead JWT and the
500
- * operator never sees a spurious "authentication expired" the broker's expiry-close remains the
501
- * BACKSTOP if a swap is missed, not the mechanism. Already-closed/draining rejections are the
502
- * supervise loop's to own (its rebuild re-fetches); an already-disconnected client is a no-op
503
- * (its own reconnect loop presents the fresh cred). */
574
+ * operator never sees a spurious "authentication expired". After the reconnect succeeds, re-arm
575
+ * the expiry fence for the credential now authenticated on the wire. */
504
576
  async swapConnectionOntoFreshCreds() {
505
- if (this.nc && !this.stopped)
506
- await this.nc.reconnect().catch(() => { });
577
+ const nc = this.nc;
578
+ if (nc && !this.stopped) {
579
+ await nc.reconnect().catch(() => { });
580
+ if (this.nc === nc && !this.stopped)
581
+ this.armAuthExpiryReconnectFence(nc);
582
+ }
583
+ }
584
+ /** nats-core keeps its reconnect switch on the protocol handler. This pinned internal shape is the
585
+ * same last-resort surface used by {@link closeFailedBind}; there is no public API for changing the
586
+ * reconnect policy of an existing connection. Disabling it does not disable Cotal self-heal: the
587
+ * `nc.closed()` supervisor below rebuilds the endpoint with freshly checked auth material. */
588
+ disableLibraryReconnect(nc) {
589
+ const protocol = nc.protocol;
590
+ if (protocol?.options)
591
+ protocol.options.reconnect = false;
592
+ }
593
+ /** Close a connection whose library reconnect has already been disabled. `drain()` flushes with a
594
+ * PING and waits for the matching PONG; nats-core only rejects that waiter inside reconnect
595
+ * `prepare()`, so a half-open socket with reconnect=false leaves drain pending until the 2-minute
596
+ * ping interval times out. `close()` tears the transport down without that round-trip, which is
597
+ * the same public path {@link closeFailedBind} already uses when there is no graceful delivery
598
+ * contract left. */
599
+ async closeWithoutLibraryReconnect(nc) {
600
+ if (!nc)
601
+ return;
602
+ this.disableLibraryReconnect(nc);
603
+ try {
604
+ await nc.close();
605
+ }
606
+ catch {
607
+ /* already closing */
608
+ }
609
+ }
610
+ /** Disable nats-core reconnect shortly before the JWT authenticated on this wire expires. The small
611
+ * lead makes the policy change precede the broker's expiry close even when both timers wake in the
612
+ * same event-loop turn. A credential adoption does not move this fence until the resident reconnect
613
+ * is requested, so the old wire remains protected during the prove-then-adopt window. */
614
+ armAuthExpiryReconnectFence(nc) {
615
+ clearTimeout(this.authExpiryReconnectTimer);
616
+ this.authExpiryReconnectTimer = undefined;
617
+ const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
618
+ const expiryMs = this.userMode && this.currentBearer
619
+ ? bearerExpiryMs(this.currentBearer)
620
+ : typeof credsExp === "number"
621
+ ? credsExp * 1000
622
+ : undefined;
623
+ if (expiryMs === undefined || this.stopped)
624
+ return;
625
+ this.authExpiryReconnectTimer = setTimeout(() => {
626
+ if (this.nc === nc)
627
+ this.disableLibraryReconnect(nc);
628
+ }, Math.max(0, expiryMs - Date.now() - 500));
629
+ this.authExpiryReconnectTimer.unref?.();
507
630
  }
508
631
  /** The connectAndBind PRE-CONNECT fetch: pull the freshest source cred and pin it into
509
632
  * {@link currentCreds} so the connect() that immediately follows presents it — that connect IS the
@@ -573,7 +696,13 @@ export class CotalEndpoint extends EventEmitter {
573
696
  throw new Error("reloadCreds: re-read credential generation did not match the expected re-signed generation (a different store, or a torn/stale read); nothing adopted");
574
697
  // PREFLIGHT = the proof. A disposable connection presenting exactly the candidate BEFORE the live
575
698
  // cache is touched; a refused cred throws here, leaving the resident connection untouched.
576
- const probe = await probeConnect(this.servers, { creds: candidate, tls: this.tls, timeoutMs: Math.max(500, Math.min(CotalEndpoint.PREFLIGHT_MS, deadline - Date.now())) });
699
+ // The candidate goes through the SAME checkpoint the resident getter uses: this is the one
700
+ // presentation that does not read the cache, so routing it here is what makes the refusal a
701
+ // property of every path rather than of the cached one. An already-dead re-signed generation is
702
+ // refused locally instead of spending a round trip to be told so, and — because this throws
703
+ // BEFORE the commit below — it can never become the resident connection's next-presented cred.
704
+ const proven = CotalEndpoint.presentableCreds(candidate, { renewable: true });
705
+ const probe = await probeConnect(this.servers, { creds: proven, tls: this.tls, timeoutMs: Math.max(500, Math.min(CotalEndpoint.PREFLIGHT_MS, deadline - Date.now())) });
577
706
  if (!probe.ok)
578
707
  throw new Error(`reloadCreds: the broker did not accept the re-signed credential (${probe.reason}); nothing adopted`);
579
708
  if (Date.now() > deadline)
@@ -694,11 +823,13 @@ export class CotalEndpoint extends EventEmitter {
694
823
  throw new Error(this.bearerSource
695
824
  ? "this endpoint's user bearer has expired and renewal through the auth exchange is failing - not presenting the expired token to the broker; retrying with backoff"
696
825
  : "this endpoint's user bearer has expired and it holds no bearer source to renew it - re-authenticate and rebuild the endpoint (construct it with a bearer FUNCTION for standing renewal)");
697
- const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
698
- if (typeof credsExp === "number" && credsExp * 1000 <= Date.now())
699
- throw new Error(this.credsSource
700
- ? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
701
- : "this endpoint's creds have expired and it holds no creds source to renew them - replace the credential and rebuild the endpoint (pass a creds FUNCTION for standing renewal)");
826
+ // The CREDS refusal is NOT repeated here. It lives on the supply itself ({@link credsForWire}),
827
+ // which the authenticator below re-reads per attempt, so it covers this dial AND the reconnects
828
+ // nats.js runs on its own. Raising it early here too would only duplicate it on the one path
829
+ // that was already covered, and a fourth dial site added later would silently miss the copy.
830
+ // The refusal still surfaces on this path: the authenticator throws during the CONNECT, the
831
+ // library closes that attempt, and the reestablish loop's capped backoff re-enters here — where
832
+ // the source re-fetch above is what recovers a renewable endpoint.
702
833
  this.nc = await dialerFor(this.servers)({
703
834
  servers: this.servers,
704
835
  // In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
@@ -714,12 +845,32 @@ export class CotalEndpoint extends EventEmitter {
714
845
  inboxPrefix: `_INBOX_${this.connId}`,
715
846
  // The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
716
847
  // attempt, so internal reconnects present whatever refreshBearer last fetched.
717
- // Creds likewise ride a GETTER when a source renews them, so internal reconnects (incl. the
718
- // one the broker forces at JWT `exp`) present whatever refreshCreds last fetched.
719
- ...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 }),
848
+ // Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt including the
849
+ // reconnects nats.js performs on its own (the one the broker forces at JWT `exp`, and a dial
850
+ // loop from an earlier drop that crosses `exp` mid-retry) re-reads a credential that has
851
+ // just been proven unexpired rather than whatever the cache happens to hold.
852
+ // The gate is `!== undefined`, NOT truthiness. An EMPTY creds string is a caller that meant to
853
+ // authenticate and supplied nothing; on a truthiness gate it fell through to `creds: undefined`
854
+ // and dialed ANONYMOUSLY, so the broker answered `Authorization Violation` and the real fault
855
+ // (an empty credential) was never named. Routing it into the checked getter fails it loud
856
+ // instead. Anonymous access stays reachable the only way it should be: by passing no creds.
857
+ ...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.currentCreds !== undefined || this.credsSource ? () => this.credsForWire() : undefined, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
720
858
  });
859
+ this.armAuthExpiryReconnectFence(this.nc);
721
860
  this.watchStatus();
722
861
  this.js = jetstream(this.nc);
862
+ // Discovery of the issued generation (SPEC 13.15): the ISSUER wrote the accepted reference
863
+ // under the token this launch was handed; the broker admits the read only under this
864
+ // connection's own per-key grant. What comes back must name this incarnation, and a fresh
865
+ // connection re-reads it: the generation is bound to the credential the transport presented,
866
+ // never to a file that may have been replaced under it.
867
+ if (this.acceptedToken !== undefined) {
868
+ const ref = await readAcceptedRow(this.nc, this.space, this.acceptedToken);
869
+ const uid = this.requireLifecycleUid("an issued endpoint");
870
+ if (ref.owner !== this.owner || ref.actor !== this.actor || ref.uid !== uid)
871
+ throw new Error(`the accepted row names ${ref.owner}.${ref.actor} (uid ${ref.uid}), not this endpoint ${this.owner}.${this.actor} (uid ${uid}); refusing to ride a foreign issuance (SPEC 13.15)`);
872
+ this.issuedGeneration = ref.generation;
873
+ }
723
874
  if (this.doWatch || this.doRegister) {
724
875
  const kvm = new Kvm(this.nc);
725
876
  // The presence bucket is a JetStream stream. Open mode lazily creates it; auth mode
@@ -783,7 +934,20 @@ export class CotalEndpoint extends EventEmitter {
783
934
  }
784
935
  }
785
936
  if (this.doRegister) {
786
- await this.publishPresence();
937
+ // #1356: this await is bounded already — a refused presence write returns no response at all,
938
+ // so it surfaces as the JetStream request TIMEOUT (~5s), once, with no retry here. What it did
939
+ // NOT do was say what failed: the bare `timeout` names no bucket, no space and no subsystem,
940
+ // and a launcher that catches it has nothing to act on. The lifecycle-proof failure a few lines
941
+ // above throws a fully explanatory message; this one is held to the same standard. Still fails
942
+ // rather than degrading — SPEC 13.1 fail-before-presence means a registering agent that cannot
943
+ // publish presence must not come up as though it had.
944
+ try {
945
+ await this.publishPresence();
946
+ }
947
+ catch (e) {
948
+ const detail = e?.message ?? String(e);
949
+ throw new Error(`presence registration failed for ${this.card.id}: the space's presence KV bucket "${presenceBucket(this.space)}" did not accept this endpoint's first write (${detail}). The bucket can be OPENED and WATCHED while refusing every write, so a healthy-looking connection does not rule this out; a broker whose store has latched refuses until it is restarted, and no client can clear it (#1356).`);
950
+ }
787
951
  this.heartbeatTimer = setInterval(() => {
788
952
  this.publishPresence().catch((e) => this.emitRecoverable(e));
789
953
  }, this.heartbeatMs);
@@ -817,6 +981,10 @@ export class CotalEndpoint extends EventEmitter {
817
981
  * second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
818
982
  * subs (tap/serve) are left alone — they aren't rebuilt here. */
819
983
  clearConnectionScoped() {
984
+ if (this.authExpiryReconnectTimer) {
985
+ clearTimeout(this.authExpiryReconnectTimer);
986
+ this.authExpiryReconnectTimer = undefined;
987
+ }
820
988
  if (this.heartbeatTimer) {
821
989
  clearInterval(this.heartbeatTimer);
822
990
  this.heartbeatTimer = undefined;
@@ -834,6 +1002,8 @@ export class CotalEndpoint extends EventEmitter {
834
1002
  }
835
1003
  }
836
1004
  this.streamMsgs.length = 0;
1005
+ this.presenceEpoch++;
1006
+ this.presenceRebind = undefined;
837
1007
  try {
838
1008
  this.presenceWatchIter?.stop();
839
1009
  }
@@ -860,7 +1030,15 @@ export class CotalEndpoint extends EventEmitter {
860
1030
  this.chatSubDenied.clear();
861
1031
  this.confirmingChatSubs.clear();
862
1032
  this.roster.clear();
1033
+ // #1356: the presence-refusal record is connection-scoped like everything else torn down here.
1034
+ // It says "the broker on THIS connection refuses writes to this bucket", so it cannot outlive the
1035
+ // connection that observed it. Left behind, a later failure on a different connection inherits it
1036
+ // and is reported as a bucket refusal — measured: a failed bind against an unreachable server
1037
+ // still printed the refusal sentence while `connectionIssue` already said "connection refused".
1038
+ this.clearPresenceWriteFailure();
863
1039
  this.lastPresenceWatchAt = 0;
1040
+ this.presenceRebindAt = 0;
1041
+ this.presenceWatchEmpty = false;
864
1042
  this.presenceSnapshotPopulated = false;
865
1043
  this.emitPresenceViewIfChanged();
866
1044
  this.joinSeq.clear();
@@ -894,6 +1072,8 @@ export class CotalEndpoint extends EventEmitter {
894
1072
  * retry starts with the same empty state as a first attempt. */
895
1073
  async closeFailedBind() {
896
1074
  const failedNc = this.nc;
1075
+ if (failedNc)
1076
+ this.disableLibraryReconnect(failedNc);
897
1077
  this.clearConnectionScoped();
898
1078
  this.nc = undefined;
899
1079
  this.js = undefined;
@@ -980,13 +1160,10 @@ export class CotalEndpoint extends EventEmitter {
980
1160
  if (!this.stopped)
981
1161
  return false;
982
1162
  const nc = this.nc;
1163
+ if (nc)
1164
+ this.disableLibraryReconnect(nc);
983
1165
  this.clearConnectionScoped();
984
- try {
985
- await nc?.drain();
986
- }
987
- catch {
988
- /* already closing */
989
- }
1166
+ await this.closeWithoutLibraryReconnect(nc);
990
1167
  this.nc = undefined;
991
1168
  return true;
992
1169
  }
@@ -1028,15 +1205,17 @@ export class CotalEndpoint extends EventEmitter {
1028
1205
  return p;
1029
1206
  }
1030
1207
  /** The transition: stop the connection-scoped timers FIRST (so nothing live touches
1031
- * this.nc during the null window), drop the connection refs, drain the old nc, then
1208
+ * this.nc during the null window), drop the connection refs, close the old nc, then
1032
1209
  * rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
1033
1210
  * idempotent, so connectAndBind's own call here is a noop. */
1034
1211
  async doRebuild() {
1035
1212
  const oldNc = this.nc;
1213
+ if (oldNc)
1214
+ this.disableLibraryReconnect(oldNc);
1036
1215
  this.reconnecting = true;
1037
1216
  try {
1038
1217
  this.clearConnectionScoped();
1039
- // Manual reconnect still has a live old epoch: complete broker-consumer cleanup before drain.
1218
+ // Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
1040
1219
  // Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
1041
1220
  if (oldNc && !oldNc.isClosed())
1042
1221
  await Promise.all([...this.membershipFeedWatches].map((watch) => watch.arm));
@@ -1060,12 +1239,7 @@ export class CotalEndpoint extends EventEmitter {
1060
1239
  // the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
1061
1240
  this.emit("transport", { connected: false });
1062
1241
  this.emit("connection", { connected: false });
1063
- try {
1064
- await oldNc?.drain();
1065
- }
1066
- catch {
1067
- /* already closing */
1068
- }
1242
+ await this.closeWithoutLibraryReconnect(oldNc);
1069
1243
  await this.connectAndBind();
1070
1244
  // stop() may have run during the await — don't leave a live connection + heartbeat +
1071
1245
  // supervisor on a stopped endpoint. (Reads this.nc in its own scope — a bare `this.nc`
@@ -1149,10 +1323,17 @@ export class CotalEndpoint extends EventEmitter {
1149
1323
  throw e;
1150
1324
  }
1151
1325
  }
1326
+ /** The presence epoch moves first: a bind still awaiting the broker must find it moved
1327
+ * before any await below gives it a window to install a watch on a stopped endpoint
1328
+ * (see {@link startPresenceWatch}). */
1152
1329
  async stop() {
1153
1330
  if (this.stopped)
1154
1331
  return;
1332
+ if (this.nc)
1333
+ this.disableLibraryReconnect(this.nc);
1155
1334
  this.stopped = true;
1335
+ this.presenceEpoch++;
1336
+ this.presenceRebind = undefined;
1156
1337
  // Wake a reestablishLoop sitting in backoff so it sees `stopped` and exits instead of
1157
1338
  // sleeping out retryMs; also clears the timer so it can't fire later.
1158
1339
  this.kickBackoff();
@@ -1164,6 +1345,8 @@ export class CotalEndpoint extends EventEmitter {
1164
1345
  clearTimeout(this.bearerTimer);
1165
1346
  if (this.credsTimer)
1166
1347
  clearTimeout(this.credsTimer);
1348
+ if (this.authExpiryReconnectTimer)
1349
+ clearTimeout(this.authExpiryReconnectTimer);
1167
1350
  for (const watch of this.membershipFeedWatches) {
1168
1351
  watch.stopped = true;
1169
1352
  watch.arm = watch.arm.catch(() => { }).then(async () => {
@@ -1215,11 +1398,17 @@ export class CotalEndpoint extends EventEmitter {
1215
1398
  /* best-effort graceful leave */
1216
1399
  }
1217
1400
  try {
1218
- await this.nc?.drain();
1401
+ await this.closeWithoutLibraryReconnect(this.nc);
1219
1402
  }
1220
1403
  catch {
1221
1404
  /* ignore */
1222
1405
  }
1406
+ // #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
1407
+ // AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
1408
+ // way out. A stopped endpoint has no live basis for "this bucket is refusing writes", and the
1409
+ // duration is computed from `Date.now()`, so a retained record does not merely go stale: it keeps
1410
+ // COUNTING UP for as long as the dead object is held.
1411
+ this.clearPresenceWriteFailure();
1223
1412
  }
1224
1413
  // ---- messaging -----------------------------------------------------------
1225
1414
  /** Multicast: broadcast to everyone on a channel. */
@@ -1550,7 +1739,12 @@ export class CotalEndpoint extends EventEmitter {
1550
1739
  * launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
1551
1740
  * lifecycle-proof refuses a divergent uid before any publish). */
1552
1741
  serviceCaller() {
1553
- return { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
1742
+ const triple = { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
1743
+ if (this.acceptedToken === undefined)
1744
+ return triple;
1745
+ if (this.issuedGeneration === undefined)
1746
+ throw new Error("invokeService: the issued generation is not yet discovered on this connection");
1747
+ return { ...triple, generation: this.issuedGeneration };
1554
1748
  }
1555
1749
  /** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
1556
1750
  * the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
@@ -1753,6 +1947,8 @@ export class CotalEndpoint extends EventEmitter {
1753
1947
  return { state: "current", fresh: true };
1754
1948
  if (!this.presenceSnapshotPopulated)
1755
1949
  return { state: "unpopulated", fresh: false };
1950
+ if (this.presenceWatchEmpty)
1951
+ return { state: "current", fresh: true };
1756
1952
  const staleSince = this.lastPresenceWatchAt + this.ttlMs;
1757
1953
  if (Date.now() < staleSince)
1758
1954
  return { state: "current", fresh: true };
@@ -2641,20 +2837,19 @@ export class CotalEndpoint extends EventEmitter {
2641
2837
  delivered++;
2642
2838
  if (m.seq >= ceiling) { // reached the page's upper bound
2643
2839
  if (m.seq === ceiling) {
2644
- try {
2645
- out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
2646
- }
2647
- catch { /* skip undecodable */ }
2840
+ const msg = historyMessageFromDelivery(m);
2841
+ if (msg)
2842
+ out.push({ seq: m.seq, subject: m.subject, msg });
2648
2843
  }
2649
2844
  complete = true;
2650
2845
  break;
2651
2846
  }
2652
- try {
2653
- out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
2847
+ const msg = historyMessageFromDelivery(m);
2848
+ if (msg) {
2849
+ out.push({ seq: m.seq, subject: m.subject, msg });
2654
2850
  if (out.length > limit)
2655
2851
  out.shift();
2656
2852
  }
2657
- catch { /* skip undecodable */ }
2658
2853
  if (delivered >= pending) {
2659
2854
  complete = true;
2660
2855
  break;
@@ -2709,6 +2904,7 @@ export class CotalEndpoint extends EventEmitter {
2709
2904
  continue;
2710
2905
  }
2711
2906
  if (s.type === "reconnect") {
2907
+ this.armAuthExpiryReconnectFence(nc);
2712
2908
  this.emit("transport", { connected: true, server: s.server });
2713
2909
  continue;
2714
2910
  }
@@ -3326,7 +3522,7 @@ export class CotalEndpoint extends EventEmitter {
3326
3522
  async startPlane3(aclFor, opts = {}) {
3327
3523
  if (!this.js)
3328
3524
  throw new Error("endpoint not started");
3329
- this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness };
3525
+ this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity };
3330
3526
  await this.armPlane3();
3331
3527
  }
3332
3528
  /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
@@ -3666,6 +3862,21 @@ export class CotalEndpoint extends EventEmitter {
3666
3862
  return { ok: false, error: e.message };
3667
3863
  }
3668
3864
  }
3865
+ if (req.op === "reloadStoreIdentity") {
3866
+ // Proof that this daemon reloads standing creds from ONE named store. The manager
3867
+ // compares it to its remint store before every renewal pass. Absent hook
3868
+ // is a daemon that cannot name its store, which is itself a divergent composition.
3869
+ if (!this.plane3?.reloadStoreIdentity)
3870
+ return { ok: false, error: "reloadStoreIdentity: this daemon did not name the SecretStore it reloads from" };
3871
+ try {
3872
+ const identity = this.plane3.reloadStoreIdentity();
3873
+ // Round-trip through the closed parser so a hook cannot smuggle extra fields onto the rail.
3874
+ return { ok: true, data: parseSecretStoreIdentity(identity) };
3875
+ }
3876
+ catch (e) {
3877
+ return { ok: false, error: e.message };
3878
+ }
3879
+ }
3669
3880
  return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
3670
3881
  }
3671
3882
  /** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
@@ -4605,18 +4816,99 @@ export class CotalEndpoint extends EventEmitter {
4605
4816
  // the publisher — this covers stop(), setStatus("offline"), and any future offline publish site, so
4606
4817
  // the raw KV record is compliant, not only the observer-side roster materialization.
4607
4818
  const record = this.status === "offline" ? this.toOffline(p) : p;
4608
- await this.kv.put(this.card.id, JSON.stringify(record));
4819
+ // #1356: this put can still be in flight when a teardown runs, because a rebind reaches
4820
+ // publishPresence through onPresenceBucketEmpty and no teardown awaits that flight. Take the same
4821
+ // epoch fence {@link startPresenceWatch} takes, so a put that outlives its epoch still throws to
4822
+ // its caller but no longer writes presence-refusal state belonging to a later connection. Without
4823
+ // it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
4824
+ // refusal on the connection that just published successfully.
4825
+ const epoch = this.presenceEpoch;
4826
+ try {
4827
+ await this.kv.put(this.card.id, JSON.stringify(record));
4828
+ }
4829
+ catch (e) {
4830
+ // #1356: a broker can put this bucket into a state where it refuses every write and never
4831
+ // recovers, and the write is the ONLY thing that fails — open and watch both still succeed, so
4832
+ // nothing else here notices. Record WHEN the refusals started, at the one site that knows the
4833
+ // failing write was a presence write; a caller cannot infer that from the generic `warning`
4834
+ // stream, which carries any recoverable error.
4835
+ if (epoch === this.presenceEpoch && !this.stopped) {
4836
+ this.presenceWriteFailingSince ??= Date.now();
4837
+ this.lastPresenceWriteError = e?.message ?? String(e);
4838
+ }
4839
+ throw e;
4840
+ }
4841
+ // A late SUCCESS is the same hazard mirrored, and this fence answers only the cross-epoch half
4842
+ // of it: a success belonging to a retired epoch cannot erase a refusal the current one
4843
+ // established from its own evidence. It does NOT order puts within a single epoch, because it
4844
+ // compares epoch identity rather than which put is the latest evidence, so an earlier put that
4845
+ // succeeds late still clears a later put's refusal. Heartbeats run at 2s against a ~5s put
4846
+ // timeout, so that overlap is routine rather than a corner, and the next failing put re-plants
4847
+ // the record with a fresh `since`. Tracked in #1461, not repaired here.
4848
+ if (epoch !== this.presenceEpoch || this.stopped)
4849
+ return;
4850
+ this.clearPresenceWriteFailure();
4609
4851
  }
4852
+ /** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
4853
+ * away. "This bucket is refusing writes" is a claim about a specific broker connection; once that
4854
+ * connection is torn down or rebuilt the claim has no remaining basis, and a later failure on a
4855
+ * fresh connection must establish it again from its own evidence.
4856
+ *
4857
+ * Cleared at the SOURCE rather than guarded at each reader, because a guard protects one consumer
4858
+ * and clearing protects every consumer, including ones not yet written. Measured: without this, a
4859
+ * failed bind against an unreachable server still reported the presence-refusal sentence while the
4860
+ * endpoint's own `connectionIssue` already said "connection refused". */
4861
+ clearPresenceWriteFailure() {
4862
+ this.presenceWriteFailingSince = undefined;
4863
+ this.lastPresenceWriteError = undefined;
4864
+ }
4865
+ /** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
4866
+ * last publish succeeded. Cleared by the first successful write, so a survived blip reads as
4867
+ * healthy and only a SUSTAINED failure carries a duration.
4868
+ *
4869
+ * Presence writes are the CANARY, not the scope: the broker can disable JetStream account-wide
4870
+ * while the NATS connection stays up, so a caller must not read this as "only presence is
4871
+ * affected". It reports what was observed, not how far the fault extends. */
4872
+ presenceWriteFailure() {
4873
+ if (this.presenceWriteFailingSince === undefined)
4874
+ return undefined;
4875
+ return {
4876
+ since: this.presenceWriteFailingSince,
4877
+ forMs: Date.now() - this.presenceWriteFailingSince,
4878
+ error: this.lastPresenceWriteError,
4879
+ bucket: presenceBucket(this.space),
4880
+ };
4881
+ }
4882
+ /** Bind a presence watch on the current connection. Resolves true when the watch was
4883
+ * installed, false when the endpoint stopped or rebuilt while the bind was in flight: that
4884
+ * bind's iterator is released here and nothing is installed, because the epoch that asked
4885
+ * for it is gone and the epoch that replaced it binds its own watch through
4886
+ * {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
4887
+ * resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
4888
+ * overwrite the fresh epoch's watch with a dead-connection iterator. */
4610
4889
  async startPresenceWatch() {
4611
4890
  if (!this.kv)
4612
- return;
4891
+ return false;
4892
+ const epoch = this.presenceEpoch;
4613
4893
  let hydrated;
4614
4894
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
4615
4895
  const iter = await this.kv.watch();
4896
+ if (epoch !== this.presenceEpoch) {
4897
+ try {
4898
+ iter.stop();
4899
+ }
4900
+ catch { /* its connection may already be gone */ }
4901
+ hydrated();
4902
+ return false;
4903
+ }
4616
4904
  this.presenceWatchIter = iter;
4617
4905
  void (async () => {
4618
4906
  let ready = false;
4619
4907
  for await (const e of iter) {
4908
+ // A rebind bound a newer watch: this one's remaining entries belong to a retired epoch.
4909
+ // Stop consuming them (the rebind stops the iterator too; this ends the loop first).
4910
+ if (this.presenceWatchIter !== iter)
4911
+ break;
4620
4912
  this.handleKvEntry(e);
4621
4913
  // @nats-io/kv marks the final initial replay entry isUpdate=true. Later updates stay true.
4622
4914
  if (!ready && e.isUpdate) {
@@ -4628,6 +4920,68 @@ export class CotalEndpoint extends EventEmitter {
4628
4920
  }
4629
4921
  hydrated();
4630
4922
  })().catch((e) => this.emit("error", e));
4923
+ return true;
4924
+ }
4925
+ /**
4926
+ * Replace a presence watch that has gone silent past TTL while the connection is up. The new
4927
+ * ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
4928
+ * heartbeating is re-observed within one replay and a peer that is gone is aged out by the
4929
+ * next sweep exactly as if the watch had never stalled. Rate-limited to one attempt per TTL
4930
+ * per observer, never overlapping, never on a stopped or rebuilding endpoint (those own their
4931
+ * watch through {@link connectAndBind}). A stop or rebuild that lands while the bind is in
4932
+ * flight retires it: {@link startPresenceWatch} releases the late iterator and reports
4933
+ * nothing, since the epoch that was silent no longer exists. A failed bind is reported and
4934
+ * the view stays stale.
4935
+ */
4936
+ rebindStalePresenceWatch(now) {
4937
+ if (this.stopped || this.reconnecting || !this.kv || !this.nc || this.nc.isClosed())
4938
+ return;
4939
+ if (this.presenceRebind || now - this.presenceRebindAt < this.ttlMs)
4940
+ return;
4941
+ this.presenceRebindAt = now;
4942
+ const old = this.presenceWatchIter;
4943
+ const epoch = this.presenceEpoch;
4944
+ this.presenceRebind = (async () => {
4945
+ try {
4946
+ const silentMs = now - this.lastPresenceWatchAt;
4947
+ // Bind the replacement FIRST. A rebind the broker refuses (stream gone, create denied) or
4948
+ // one a held link never answers must leave the old watch in place: on a plain stall that
4949
+ // watch is the one that recovers by itself, and its replay is still guarded against
4950
+ // expired PUTs. Only a successfully bound watch retires its predecessor.
4951
+ const installed = await this.startPresenceWatch();
4952
+ // Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
4953
+ // released and the old watch was torn down by whoever moved the epoch. Nothing to
4954
+ // retire, nothing to report.
4955
+ if (!installed)
4956
+ return;
4957
+ if (old && old !== this.presenceWatchIter) {
4958
+ try {
4959
+ old.stop();
4960
+ }
4961
+ catch { /* already closed with its consumer */ }
4962
+ }
4963
+ // A bucket with no keys replays nothing, so the new watch cannot refresh
4964
+ // `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. Read
4965
+ // the consumer's initial pending count for that one fact; nats.js's KV watch computed it
4966
+ // from the same `info(true)` it used to place the isUpdate marker.
4967
+ const pending = this.presenceWatchIter?._data?._info?.num_pending;
4968
+ if (pending === 0)
4969
+ await this.onPresenceBucketEmpty();
4970
+ this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
4971
+ }
4972
+ catch (e) {
4973
+ // A bind the epoch swap itself rejected (connection drained under it) is not a fault of
4974
+ // the epoch that replaced it; only a refusal on a still-current epoch is reported.
4975
+ if (epoch === this.presenceEpoch)
4976
+ this.emit("error", e);
4977
+ }
4978
+ finally {
4979
+ // An epoch swap already disowned this flight (and may own a successor's by now); only
4980
+ // a flight still in its own epoch clears the slot. Within one epoch there is one flight.
4981
+ if (epoch === this.presenceEpoch)
4982
+ this.presenceRebind = undefined;
4983
+ }
4984
+ })();
4631
4985
  }
4632
4986
  /** Watch the channel registry: replay existing keys, then stream updates, into the local
4633
4987
  * cache. Best-effort — a registry the endpoint can't read leaves the cache empty (effective
@@ -4667,8 +5021,65 @@ export class CotalEndpoint extends EventEmitter {
4667
5021
  /* keep last good */
4668
5022
  }
4669
5023
  }
5024
+ /** The watch was just bound onto a bucket with no keys.
5025
+ *
5026
+ * A NON-REGISTERING observer (a `cotal status` probe, a lease checker) has real knowledge:
5027
+ * nobody is present. Every peer still in its roster is known gone (its key is not there to
5028
+ * replay), so it is marked offline now rather than aged out against a delivery that cannot
5029
+ * come; the silence gate is disarmed and the view reads current until the first write lands.
5030
+ * Without that the empty-bucket view relapsed to stale one window later and rebound again on
5031
+ * every window, one consumer create and one warning per TTL for as long as the mesh was empty.
5032
+ *
5033
+ * A REGISTERING observer (the manager) is itself one of the keys that should be there. An
5034
+ * empty bucket under it means the bucket was wiped since its last heartbeat (the netcup
5035
+ * recreation), and the same wipe took every peer's record: their absence says the bucket is
5036
+ * new, not that they left. rev-1421-gpt reproduced the previous behaviour at default timing:
5037
+ * the rebind landed ~0.9s after the recreation, the observer marked every peer AND ITSELF
5038
+ * offline, and held the view current for up to one heartbeat, a false verdict `cotal ps`
5039
+ * would print as `mesh offline`. So a registering observer re-publishes its own record NOW,
5040
+ * which the new watch delivers, and lets the ordinary per-peer age-out run from that delivery:
5041
+ * a peer that is still heartbeating rewrites its key within its own heartbeat interval and is
5042
+ * re-observed live; one that is gone ages out exactly as after a plain rebind. The roster is
5043
+ * not touched here and the view is not held; the delivery is what makes it current.
5044
+ *
5045
+ * What neither branch covers: a consumer that dies again while the bucket is still empty is
5046
+ * not detectable by silence, so the first write after that is missed until the observer
5047
+ * restarts. */
5048
+ async onPresenceBucketEmpty() {
5049
+ if (this.doRegister) {
5050
+ // Our own key is missing from a bucket we write to: put it back. publishPresence throws on
5051
+ // a refused write; that surfaces through the rebind flight's error path with the epoch
5052
+ // fence intact, and the view stays stale, which is the honest state for an observer that
5053
+ // cannot even record itself.
5054
+ await this.publishPresence();
5055
+ return;
5056
+ }
5057
+ this.markPresenceBucketEmpty();
5058
+ }
5059
+ /** See {@link onPresenceBucketEmpty}: the non-registering branch. */
5060
+ markPresenceBucketEmpty() {
5061
+ this.presenceWatchEmpty = true;
5062
+ this.lastPresenceWatchAt = 0;
5063
+ let changed = false;
5064
+ for (const [id, p] of this.roster) {
5065
+ if (p.status === "offline")
5066
+ continue;
5067
+ const offline = this.toOffline(p);
5068
+ this.roster.set(id, offline);
5069
+ this.emit("presence", { type: "offline", presence: offline });
5070
+ changed = true;
5071
+ }
5072
+ if (changed)
5073
+ this.emit("roster", this.getRoster());
5074
+ this.emitPresenceViewIfChanged();
5075
+ }
4670
5076
  handleKvEntry(e) {
5077
+ // Provisioning uses this one reserved key to prove the bucket's max_age is enforced (#404).
5078
+ // It is maintenance traffic, never a peer identity, an offline transition, or view freshness.
5079
+ if (e.key === TTL_RECONCILE_CANARY_KEY)
5080
+ return;
4671
5081
  this.lastPresenceWatchAt = Date.now();
5082
+ this.presenceWatchEmpty = false;
4672
5083
  if (e.operation === "DEL" || e.operation === "PURGE") {
4673
5084
  this.markOffline(e.key);
4674
5085
  return;
@@ -4774,11 +5185,29 @@ export class CotalEndpoint extends EventEmitter {
4774
5185
  // watch freshness; surface the view as stale instead.
4775
5186
  if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
4776
5187
  this.emitPresenceViewIfChanged();
5188
+ // Staying stale is the right verdict for a held link (#1045), and the wrong END STATE when
5189
+ // the transport is up and the watch's own consumer is what died. Measured on netcup
5190
+ // 2026-09-09: the presence stream was deleted and recreated, its sequence restarted, and
5191
+ // every observer's ORDERED consumer re-created itself at the OLD start sequence (nats.js
5192
+ // 3.4.0 resets from its cursor). The broker kept sending idle heartbeats, so the client
5193
+ // never reset again, the iterator never closed, and the manager's roster stayed frozen at
5194
+ // the pre-recreation snapshot for hours: `cotal ps` read every older seat "mesh offline"
5195
+ // and every newer seat "not in roster" while all of them were heartbeating. The same
5196
+ // end state follows a plain consumer delete (an operator, or the 5-minute inactive
5197
+ // threshold after a long stall). Rebind the watch from the bucket's CURRENT state; a held
5198
+ // link's rebind fails or stays silent and the view simply stays stale, as before.
5199
+ this.rebindStalePresenceWatch(now);
4777
5200
  return;
4778
5201
  }
4779
5202
  let changed = false;
4780
5203
  for (const [id, p] of this.roster) {
4781
- if (p.status !== "offline" && now - p.ts > this.ttlMs) {
5204
+ // A peer's own `ts` always trails the observer's last delivery, so "older than TTL by the
5205
+ // wall clock" alone ages peers out on the tick just before the whole-bucket gate above
5206
+ // trips (#1311's flap; measured as 3 offline verdicts per silence in the rebind suite).
5207
+ // Require that the watch itself delivered for a full TTL after this peer's last heartbeat:
5208
+ // then other peers were heard and this one was not, which is the only silence that is the
5209
+ // peer's rather than the observer's.
5210
+ if (p.status !== "offline" && now - p.ts > this.ttlMs && this.lastPresenceWatchAt - p.ts > this.ttlMs) {
4782
5211
  const offline = this.toOffline(p);
4783
5212
  this.roster.set(id, offline);
4784
5213
  this.emit("presence", { type: "offline", presence: offline });
@@ -4817,10 +5246,18 @@ function kindFromParsed(kind) {
4817
5246
  throw new Error(`cannot derive a message kind from subject kind "${kind}"`);
4818
5247
  }
4819
5248
  }
4820
- /** Routing fields in the envelope are advisory. Surface a channel label only from the authenticated
4821
- * chat subject, so connector attention cannot be bypassed with a mismatched payload `channel`. */
5249
+ /** Routing fields in the envelope are advisory. The broker forge-locks sender (and for DMs,
5250
+ * recipient) into the subject. Callers MUST already have rejected a missing `from`, an
5251
+ * unparseable subject, or `from.id !== parsed.sender` (SPEC §5). This derives the remaining
5252
+ * routing tokens from the subject for rows that survived — it does not rewrite a mismatched
5253
+ * `from.id`. Live tails, channel backfill, and channel recall skip the mismatch; history
5254
+ * does the same (#388). */
4822
5255
  function authenticatedMessage(msg, parsed) {
4823
- return parsed.kind === "chat" ? authenticatedChannelMessage(msg, parsed.rest) : msg;
5256
+ if (parsed.kind === "chat")
5257
+ return authenticatedChannelMessage(msg, parsed.rest);
5258
+ if (parsed.kind === "inst")
5259
+ return authenticatedDmMessage(msg, parsed.rest);
5260
+ return msg;
4824
5261
  }
4825
5262
  function authenticatedChannelMessage(msg, channel) {
4826
5263
  if (msg.channel === channel && msg.to === undefined && msg.toService === undefined)
@@ -4828,6 +5265,49 @@ function authenticatedChannelMessage(msg, channel) {
4828
5265
  const { to: _to, toService: _toService, ...base } = msg;
4829
5266
  return { ...base, channel };
4830
5267
  }
5268
+ function authenticatedDmMessage(msg, to) {
5269
+ if (msg.to === to && msg.channel === undefined && msg.toService === undefined)
5270
+ return msg;
5271
+ const { channel: _channel, toService: _toService, ...base } = msg;
5272
+ return { ...base, to };
5273
+ }
5274
+ /** History drain keeps `m.json()` and used to throw the subject away. SPEC §5: on receive, verify
5275
+ * `from.id` equals the subject sender; on mismatch, a missing `from`, or an unparseable delivery
5276
+ * subject, reject and never surface. Fail closed on shape too: a stored JSON `null` or a truthy
5277
+ * non-object `from` must not throw mid-array. Do not echo-drop `from.id === this.card.id`:
5278
+ * god-view history must include the viewer's own sends. */
5279
+ function historyMessageFromDelivery(m) {
5280
+ let raw;
5281
+ try {
5282
+ raw = m.json();
5283
+ }
5284
+ catch {
5285
+ return undefined;
5286
+ }
5287
+ if (!isHistoryDrainEnvelope(raw))
5288
+ return undefined;
5289
+ const parsed = parseSubject(m.subject);
5290
+ if (!parsed || !isPrincipalOwnerToken(parsed.owner))
5291
+ return undefined;
5292
+ if (raw.from.id !== parsed.sender)
5293
+ return undefined;
5294
+ return authenticatedMessage(raw, parsed);
5295
+ }
5296
+ /**
5297
+ * Narrow enough for the type checker and for fail-closed history: object envelope, usable `id`,
5298
+ * object `from`. That is what lets `from.id !== parsed.sender` run without throwing, and what
5299
+ * lets `authenticatedMessage` take the row without a cast.
5300
+ *
5301
+ * Does NOT verify SPEC §5 message shape. It does not require a string `from.id` (the SPEC §5
5302
+ * comparison still rejects a mismatch), exactly one route key, a finite `ts`, a string
5303
+ * `space`, a full EndpointRef `from` (`name`/`role`), or well-formed `parts`. Those belong
5304
+ * to `isCotalMessage` (Plane-3). History must not use that guard: a public
5305
+ * `unicast(..., { parts: [{ kind: "data", data: undefined }] })` serializes to `{kind:"data"}`
5306
+ * and must still surface.
5307
+ */
5308
+ function isHistoryDrainEnvelope(value) {
5309
+ return isRecord(value) && isUsableMessageId(value.id) && isRecord(value.from);
5310
+ }
4831
5311
  function isPlane3DeliveryFrame(value) {
4832
5312
  if (!isRecord(value))
4833
5313
  return false;