@cotal-ai/core 0.48.2 → 0.50.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 (125) 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-envelope.d.ts +1 -1
  20. package/dist/endpoint-envelope.d.ts.map +1 -1
  21. package/dist/endpoint-envelope.js +1 -1
  22. package/dist/endpoint-envelope.js.map +1 -1
  23. package/dist/endpoint-error.d.ts +10 -1
  24. package/dist/endpoint-error.d.ts.map +1 -1
  25. package/dist/endpoint-error.js +12 -0
  26. package/dist/endpoint-error.js.map +1 -1
  27. package/dist/endpoint-grants.d.ts.map +1 -1
  28. package/dist/endpoint-grants.js +34 -12
  29. package/dist/endpoint-grants.js.map +1 -1
  30. package/dist/endpoint-invoke.d.ts.map +1 -1
  31. package/dist/endpoint-invoke.js +16 -51
  32. package/dist/endpoint-invoke.js.map +1 -1
  33. package/dist/endpoint-publish-denial.d.ts +35 -0
  34. package/dist/endpoint-publish-denial.d.ts.map +1 -0
  35. package/dist/endpoint-publish-denial.js +56 -0
  36. package/dist/endpoint-publish-denial.js.map +1 -0
  37. package/dist/endpoint-serve-kv.d.ts +16 -0
  38. package/dist/endpoint-serve-kv.d.ts.map +1 -1
  39. package/dist/endpoint-serve-kv.js +29 -0
  40. package/dist/endpoint-serve-kv.js.map +1 -1
  41. package/dist/endpoint-serve.d.ts.map +1 -1
  42. package/dist/endpoint-serve.js +9 -4
  43. package/dist/endpoint-serve.js.map +1 -1
  44. package/dist/endpoint-service.d.ts +21 -0
  45. package/dist/endpoint-service.d.ts.map +1 -1
  46. package/dist/endpoint-service.js +150 -15
  47. package/dist/endpoint-service.js.map +1 -1
  48. package/dist/endpoint-subjects.d.ts +34 -0
  49. package/dist/endpoint-subjects.d.ts.map +1 -1
  50. package/dist/endpoint-subjects.js +87 -18
  51. package/dist/endpoint-subjects.js.map +1 -1
  52. package/dist/endpoint-verbs.d.ts +4 -2
  53. package/dist/endpoint-verbs.d.ts.map +1 -1
  54. package/dist/endpoint-verbs.js +23 -8
  55. package/dist/endpoint-verbs.js.map +1 -1
  56. package/dist/endpoint.d.ts +309 -8
  57. package/dist/endpoint.d.ts.map +1 -1
  58. package/dist/endpoint.js +888 -61
  59. package/dist/endpoint.js.map +1 -1
  60. package/dist/index.d.ts +4 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +8 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/issued-authority.d.ts +168 -0
  65. package/dist/issued-authority.d.ts.map +1 -0
  66. package/dist/issued-authority.js +497 -0
  67. package/dist/issued-authority.js.map +1 -0
  68. package/dist/issuer-session.d.ts +30 -0
  69. package/dist/issuer-session.d.ts.map +1 -0
  70. package/dist/issuer-session.js +51 -0
  71. package/dist/issuer-session.js.map +1 -0
  72. package/dist/lease.d.ts +22 -0
  73. package/dist/lease.d.ts.map +1 -1
  74. package/dist/lease.js +14 -1
  75. package/dist/lease.js.map +1 -1
  76. package/dist/lifecycle-state.d.ts +9 -2
  77. package/dist/lifecycle-state.d.ts.map +1 -1
  78. package/dist/lifecycle-state.js +11 -2
  79. package/dist/lifecycle-state.js.map +1 -1
  80. package/dist/membership-feed.d.ts +24 -1
  81. package/dist/membership-feed.d.ts.map +1 -1
  82. package/dist/membership-feed.js +61 -3
  83. package/dist/membership-feed.js.map +1 -1
  84. package/dist/provision.d.ts +24 -14
  85. package/dist/provision.d.ts.map +1 -1
  86. package/dist/provision.js +149 -15
  87. package/dist/provision.js.map +1 -1
  88. package/dist/remote-manager-authority.d.ts +126 -1
  89. package/dist/remote-manager-authority.d.ts.map +1 -1
  90. package/dist/remote-manager-authority.js +21 -0
  91. package/dist/remote-manager-authority.js.map +1 -1
  92. package/dist/run-admission.d.ts +88 -0
  93. package/dist/run-admission.d.ts.map +1 -0
  94. package/dist/run-admission.js +193 -0
  95. package/dist/run-admission.js.map +1 -0
  96. package/dist/run-driver-grants.d.ts +10 -0
  97. package/dist/run-driver-grants.d.ts.map +1 -1
  98. package/dist/run-driver-grants.js +21 -0
  99. package/dist/run-driver-grants.js.map +1 -1
  100. package/dist/run-host.d.ts +7 -1
  101. package/dist/run-host.d.ts.map +1 -1
  102. package/dist/run-host.js.map +1 -1
  103. package/dist/run-journal.d.ts +4 -0
  104. package/dist/run-journal.d.ts.map +1 -1
  105. package/dist/run-journal.js +80 -17
  106. package/dist/run-journal.js.map +1 -1
  107. package/dist/runtime.d.ts +31 -3
  108. package/dist/runtime.d.ts.map +1 -1
  109. package/dist/secret-fs.d.ts +25 -0
  110. package/dist/secret-fs.d.ts.map +1 -1
  111. package/dist/secret-fs.js +136 -3
  112. package/dist/secret-fs.js.map +1 -1
  113. package/dist/secret-store.d.ts +44 -0
  114. package/dist/secret-store.d.ts.map +1 -1
  115. package/dist/secret-store.js +60 -1
  116. package/dist/secret-store.js.map +1 -1
  117. package/dist/streams.d.ts +23 -75
  118. package/dist/streams.d.ts.map +1 -1
  119. package/dist/streams.js +65 -85
  120. package/dist/streams.js.map +1 -1
  121. package/dist/subjects.d.ts +11 -1
  122. package/dist/subjects.d.ts.map +1 -1
  123. package/dist/subjects.js +11 -1
  124. package/dist/subjects.js.map +1 -1
  125. 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";
@@ -21,7 +23,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
21
23
  import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
22
24
  import { openDeliveryRegistry } from "./lease.js";
23
25
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
24
- import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
26
+ import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
25
27
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
26
28
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
27
29
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -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;
@@ -139,6 +147,10 @@ export class CotalEndpoint extends EventEmitter {
139
147
  aclKv;
140
148
  deliveryKv;
141
149
  managerLeaseKv;
150
+ /** Our revision of the per-space daemon-credential renewal lease (#1634), or undefined when we do
151
+ * not hold it. Cleared with the bound KV handle: a revision from a dead connection is not a lease
152
+ * we can prove we still hold. */
153
+ daemonRenewalLeaseRevision;
142
154
  membershipFeedKv;
143
155
  /** Caller-owned membership watches survive a connection rebuild as INTENT. Their iterators are
144
156
  * connection-scoped and are stopped/re-created around the epoch swap. */
@@ -150,6 +162,9 @@ export class CotalEndpoint extends EventEmitter {
150
162
  /** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
151
163
  * daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
152
164
  * re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
165
+ /** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
166
+ * Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
167
+ plane3Quiesced = false;
153
168
  plane3;
154
169
  /** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
155
170
  channelConfigs = new Map();
@@ -202,6 +217,10 @@ export class CotalEndpoint extends EventEmitter {
202
217
  firstConnect = true;
203
218
  heartbeatTimer;
204
219
  sweepTimer;
220
+ /** #1356: when the presence bucket started refusing writes; undefined once one succeeds. */
221
+ presenceWriteFailingSince;
222
+ /** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
223
+ lastPresenceWriteError;
205
224
  roster = new Map();
206
225
  /** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
207
226
  presenceSnapshot = Promise.resolve();
@@ -216,6 +235,18 @@ export class CotalEndpoint extends EventEmitter {
216
235
  lastPresenceWatchAt = 0;
217
236
  /** Last emitted presence-view state. Suppresses duplicate `presence-view` events. */
218
237
  presenceViewState = "unpopulated";
238
+ /** A presence-watch rebind in flight (see {@link rebindStalePresenceWatch}); one at a time. */
239
+ presenceRebind;
240
+ /** Bumped by every connection-scoped teardown and by {@link stop}. A presence bind that was
241
+ * awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
242
+ * iterator it got and installs nothing (see {@link startPresenceWatch}). */
243
+ presenceEpoch = 0;
244
+ /** The current watch was bound onto a bucket with NO keys (see {@link markPresenceBucketEmpty}).
245
+ * Such a watch cannot deliver until someone writes, so its silence is not staleness. */
246
+ presenceWatchEmpty = false;
247
+ /** Wall-clock of the last rebind attempt, so a bucket that is silent because it is EMPTY (or a
248
+ * broker that keeps refusing the consumer create) is retried once per TTL, not per sweep tick. */
249
+ presenceRebindAt = 0;
219
250
  status = "idle";
220
251
  activity;
221
252
  /** Mirror of the connector's authoritative attention state, published in presence (advisory). The
@@ -256,6 +287,9 @@ export class CotalEndpoint extends EventEmitter {
256
287
  actorIsEphemeral;
257
288
  /** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
258
289
  ownLifecycleUid;
290
+ acceptedToken;
291
+ /** The issuer-bound generation, learned once per connection from the accepted row. */
292
+ issuedGeneration;
259
293
  /** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
260
294
  * `failed-precondition` currency refusal (the described incarnation was superseded). */
261
295
  resolvedServices = new Map();
@@ -376,6 +410,11 @@ export class CotalEndpoint extends EventEmitter {
376
410
  : this.authed
377
411
  ? undefined
378
412
  : mintLifecycleUid();
413
+ if (opts.acceptedToken !== undefined) {
414
+ if (!opts.creds)
415
+ throw new Error("EndpointOptions.acceptedToken names a static issuance and needs creds beside it (SPEC 13.15)");
416
+ this.acceptedToken = assertGeneration(opts.acceptedToken, "acceptedToken");
417
+ }
379
418
  // `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
380
419
  // principalKey validates both tokens.
381
420
  const principal = principalKey(this.owner, this.actor);
@@ -444,8 +483,44 @@ export class CotalEndpoint extends EventEmitter {
444
483
  const claims = decodeBearerPrincipal(bearer);
445
484
  if (claims.owner !== this.owner || claims.actor !== this.actor)
446
485
  throw new Error(`bearer source returned principal ${claims.owner}.${claims.actor}, expected ${this.owner}.${this.actor}`);
486
+ // Two refusals, answering different questions, and both of them are about protecting what is
487
+ // ALREADY held. So both are scoped to there BEING something held, which is the same set as
488
+ // `!initial` but names the thing the rules actually depend on: on the first fetch there is no
489
+ // cache to lose, `start()` has to come up on whatever the source has, and the pre-dial guard
490
+ // in {@link bindConnection} is what speaks for a dead first token.
491
+ const expiryMs = bearerExpiryMs(bearer);
492
+ const held = this.currentBearer;
493
+ if (held !== undefined) {
494
+ // ALREADY DEAD, whatever else is true of it. Advancement is deliberately not part of this
495
+ // test: a candidate expiring at now-5s does advance a held one that died at now-60s, so an
496
+ // advance-only rule adopts it - overwriting the cache with material nothing can dial,
497
+ // skipping the recoverable warning because the fetch did not throw, and arming the next
498
+ // read off a non-positive delay. The creds path draws its line on expiry alone too, in
499
+ // {@link presentableCreds} (#1572).
500
+ if (expiryMs <= Date.now())
501
+ throw new Error("the bearer source returned a token that has already expired - nothing adopted");
502
+ // THE SAME BYTES BACK AGAIN from a source whose token cannot carry the next cycle: the
503
+ // delay it arms is non-positive, `armBearerRefresh` floors that to 5s, and the next read
504
+ // returns that same token - a 5s loop against the auth service for the rest of its life,
505
+ // with BEARER_RETRY_MS bypassed because the fetch did not FAIL. It succeeded and returned
506
+ // nothing new (#1561).
507
+ //
508
+ // The test is byte identity, not expiry, and not advancement. `exp` carries one second of
509
+ // resolution, so a key rotation re-signing the same claims under a new key - and a healthy
510
+ // short-TTL source read twice inside one wall-clock second - both hand back a token whose
511
+ // `exp` has not moved. That is genuinely re-issued material, which an advancement test
512
+ // refuses and this one adopts. It is the question the creds path asks with
513
+ // `credsFingerprint`: did the source re-issue ANYTHING (#1572).
514
+ if (bearer === held && expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS <= 0)
515
+ throw new Error("the bearer source re-served the token already held and it cannot carry another cycle (the auth service has not issued a fresh one) - nothing adopted");
516
+ }
447
517
  this.currentBearer = bearer;
448
- this.armBearerRefresh(bearerExpiryMs(bearer) - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
518
+ // A non-positive delay is NOT clamped to BEARER_RETRY_MS. A deployment whose whole token TTL
519
+ // sits inside the margin is legitimate - `expiry-renewal` mints 5s bearers against the 60s
520
+ // margin - and for it the 5s floor IS the renewal cadence. Backing that source off to 15s
521
+ // leaves a 5s token dead for two thirds of every cycle. What made #1561 a pointless loop was
522
+ // the source returning nothing new, which is refused above, not the cadence itself.
523
+ this.armBearerRefresh(expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
449
524
  }
450
525
  catch (e) {
451
526
  if (initial)
@@ -464,6 +539,77 @@ export class CotalEndpoint extends EventEmitter {
464
539
  /** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
465
540
  * (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
466
541
  static CREDS_RETRY_MS = 60_000;
542
+ /** THE ONE PLACE a credential is cleared for presentation to a broker.
543
+ *
544
+ * The property is unconditional — this endpoint never presents a credential it has already
545
+ * decoded as expired — so it is a property of the SUPPLY, not of any one dial site. It used to
546
+ * live inside {@link bindConnection}, which only `start()` and `doRebuild` reach; the
547
+ * authenticator nats.js re-evaluates on ITS OWN reconnects read the cache directly and so
548
+ * presented whatever was last fetched, expired included. Two such reconnects exist and neither
549
+ * passes through bindConnection: the one the broker forces at JWT `exp`, and an ALREADY RUNNING
550
+ * dial loop from an earlier drop that crosses `exp` while it retries (the pre-expiry
551
+ * reconnect fence flips a policy flag nats-core only reads when it observes a NEW drop, so it
552
+ * cannot stop a loop already in flight).
553
+ *
554
+ * Putting the refusal here instead means a future caller cannot miss it: the only way to reach a
555
+ * dial is through {@link credsForWire}, and the one presentation that does not read the cache
556
+ * (the adoption preflight, which presents a fresh CANDIDATE) calls this same function on it.
557
+ *
558
+ * An unbounded credential (no numeric `exp`) is presentable: bounded lifetimes are the renewal
559
+ * seam's concern, and a cred with no expiry has none to be past. */
560
+ static presentableCreds(creds, opts) {
561
+ const { exp } = credsClaims(creds); // throws on a structurally-unusable file (fail-loud)
562
+ if (typeof exp === "number" && exp * 1000 <= Date.now())
563
+ throw new Error(opts.renewable
564
+ ? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
565
+ : "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)");
566
+ return creds;
567
+ }
568
+ /** The cached credential, checked. Handed to nats.js as the authenticator's source on EVERY auth
569
+ * mode (renewed or static), so each (re)connect attempt — ours or the library's — re-reads a
570
+ * CHECKED value. A refusal throws out of the authenticator, which nats-core turns into a closed
571
+ * connection rather than a CONNECT carrying dead material; the endpoint's own supervisor then
572
+ * rebuilds on capped backoff, and {@link bindConnection} re-fetches from the source on each of
573
+ * those attempts, so a renewal that starts working recovers the endpoint without presenting
574
+ * anything expired in the meantime. Deliberately side-effect free: kicking the renewal timer
575
+ * from here would retry the source once per dial attempt, which is the flat load on a dead
576
+ * broker that {@link RETRY_BACKOFF_CAP_MS} exists to prevent. */
577
+ credsForWire() {
578
+ if (!this.currentCreds)
579
+ throw new Error(this.credsSource
580
+ ? "this endpoint has no credential to present yet (the creds source has not returned one) - not dialing without auth material"
581
+ : "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
582
+ return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
583
+ }
584
+ /** Refuses a fetched generation there is nothing to renew FROM, so the caller's failure posture
585
+ * ({@link CREDS_RETRY_MS}) applies instead of the renewal schedule (issue #1523).
586
+ *
587
+ * A source that hands back the SAME generation past its own renewal point has not re-signed yet:
588
+ * that is a missed remint, not a candidate. Adopting it re-arms from a non-positive delay, which
589
+ * {@link armCredsRefresh} floors to 1s, which fetches again, which is the same generation — a 1s
590
+ * read loop against a store that is already having a bad day, for the JWT's remaining 25% of life,
591
+ * with the 60s backoff that exists for exactly this bypassed because the fetch did not FAIL.
592
+ *
593
+ * The membership feed's rw-cred renewal already refuses it this way (`membership-feed.ts`,
594
+ * `adoptRwCreds`: "the rw source still holds the previous generation past its renewal point");
595
+ * this is the same rule on the endpoint's `delivery.creds` seam, so the two renewal paths answer
596
+ * a dead source identically.
597
+ *
598
+ * An ALREADY-EXPIRED generation is refused whatever it is: the delay is non-positive for it too,
599
+ * but it is dead rather than merely due, so it is named separately and refused even when the
600
+ * source keeps returning a different one. */
601
+ static assertRenewableGeneration(candidate, current, delayMs) {
602
+ const { exp } = credsClaims(candidate);
603
+ if (typeof exp === "number" && exp * 1000 <= Date.now())
604
+ throw new Error("the creds source returned an already-expired credential (its `exp` is in the past) - nothing adopted; the renewal owner has not re-signed it, or the store is serving a stale generation");
605
+ // Compared by GENERATION, not by envelope. `EndpointOptions.creds` takes opaque file content and
606
+ // promises no canonical whitespace: `jwtFromCreds` pads with `\s*` and trims, so a store, editor or
607
+ // filesystem round trip that adds a newline re-serves the SAME credential in bytes `===` calls
608
+ // different - and the refusal would be skipped for the one case it exists to catch. The envelope
609
+ // also carries the nkey seed, which is why `credsFingerprint` hashes the JWT instead.
610
+ if (current !== undefined && credsFingerprint(candidate) === credsFingerprint(current) && delayMs <= 0)
611
+ throw new Error("the creds source still holds the previous generation past its renewal point (the renewal owner has not re-signed it) - nothing adopted");
612
+ }
467
613
  /** The disposable-preflight connect bound for the EXPLICIT reload proof (D5 class-2 adoption). A
468
614
  * rogue or unreachable candidate must resolve well UNDER the manager's delivery-admin request
469
615
  * bound, so this stays a few seconds and never blocks the responder. */
@@ -489,21 +635,74 @@ export class CotalEndpoint extends EventEmitter {
489
635
  const id = idFromCreds(creds);
490
636
  if (id !== this.connId)
491
637
  throw new Error(`creds source returned identity ${id}, expected ${this.connId} - renewal may not swap the connection's nkey`);
638
+ // Before the commit, so a refusal leaves the cache holding the last generation that was renewable
639
+ // from (and, on the initial fetch, fails loud rather than dialling with dead material).
640
+ const delay = credsRenewalDelayMs(creds);
641
+ CotalEndpoint.assertRenewableGeneration(creds, this.currentCreds, delay);
492
642
  this.currentCreds = creds;
493
- this.armCredsRefresh(credsRenewalDelayMs(creds));
643
+ this.armCredsRefresh(delay);
494
644
  const { iat, exp } = credsClaims(creds);
495
645
  return { iat, exp };
496
646
  }
497
647
  /** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
498
648
  * (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
499
649
  * 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). */
650
+ * operator never sees a spurious "authentication expired". After the reconnect succeeds, re-arm
651
+ * the expiry fence for the credential now authenticated on the wire. */
504
652
  async swapConnectionOntoFreshCreds() {
505
- if (this.nc && !this.stopped)
506
- await this.nc.reconnect().catch(() => { });
653
+ const nc = this.nc;
654
+ if (nc && !this.stopped) {
655
+ await nc.reconnect().catch(() => { });
656
+ if (this.nc === nc && !this.stopped)
657
+ this.armAuthExpiryReconnectFence(nc);
658
+ }
659
+ }
660
+ /** nats-core keeps its reconnect switch on the protocol handler. This pinned internal shape is the
661
+ * same last-resort surface used by {@link closeFailedBind}; there is no public API for changing the
662
+ * reconnect policy of an existing connection. Disabling it does not disable Cotal self-heal: the
663
+ * `nc.closed()` supervisor below rebuilds the endpoint with freshly checked auth material. */
664
+ disableLibraryReconnect(nc) {
665
+ const protocol = nc.protocol;
666
+ if (protocol?.options)
667
+ protocol.options.reconnect = false;
668
+ }
669
+ /** Close a connection whose library reconnect has already been disabled. `drain()` flushes with a
670
+ * PING and waits for the matching PONG; nats-core only rejects that waiter inside reconnect
671
+ * `prepare()`, so a half-open socket with reconnect=false leaves drain pending until the 2-minute
672
+ * ping interval times out. `close()` tears the transport down without that round-trip, which is
673
+ * the same public path {@link closeFailedBind} already uses when there is no graceful delivery
674
+ * contract left. */
675
+ async closeWithoutLibraryReconnect(nc) {
676
+ if (!nc)
677
+ return;
678
+ this.disableLibraryReconnect(nc);
679
+ try {
680
+ await nc.close();
681
+ }
682
+ catch {
683
+ /* already closing */
684
+ }
685
+ }
686
+ /** Disable nats-core reconnect shortly before the JWT authenticated on this wire expires. The small
687
+ * lead makes the policy change precede the broker's expiry close even when both timers wake in the
688
+ * same event-loop turn. A credential adoption does not move this fence until the resident reconnect
689
+ * is requested, so the old wire remains protected during the prove-then-adopt window. */
690
+ armAuthExpiryReconnectFence(nc) {
691
+ clearTimeout(this.authExpiryReconnectTimer);
692
+ this.authExpiryReconnectTimer = undefined;
693
+ const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
694
+ const expiryMs = this.userMode && this.currentBearer
695
+ ? bearerExpiryMs(this.currentBearer)
696
+ : typeof credsExp === "number"
697
+ ? credsExp * 1000
698
+ : undefined;
699
+ if (expiryMs === undefined || this.stopped)
700
+ return;
701
+ this.authExpiryReconnectTimer = setTimeout(() => {
702
+ if (this.nc === nc)
703
+ this.disableLibraryReconnect(nc);
704
+ }, Math.max(0, expiryMs - Date.now() - 500));
705
+ this.authExpiryReconnectTimer.unref?.();
507
706
  }
508
707
  /** The connectAndBind PRE-CONNECT fetch: pull the freshest source cred and pin it into
509
708
  * {@link currentCreds} so the connect() that immediately follows presents it — that connect IS the
@@ -573,7 +772,13 @@ export class CotalEndpoint extends EventEmitter {
573
772
  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
773
  // PREFLIGHT = the proof. A disposable connection presenting exactly the candidate BEFORE the live
575
774
  // 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())) });
775
+ // The candidate goes through the SAME checkpoint the resident getter uses: this is the one
776
+ // presentation that does not read the cache, so routing it here is what makes the refusal a
777
+ // property of every path rather than of the cached one. An already-dead re-signed generation is
778
+ // refused locally instead of spending a round trip to be told so, and — because this throws
779
+ // BEFORE the commit below — it can never become the resident connection's next-presented cred.
780
+ const proven = CotalEndpoint.presentableCreds(candidate, { renewable: true });
781
+ const probe = await probeConnect(this.servers, { creds: proven, tls: this.tls, timeoutMs: Math.max(500, Math.min(CotalEndpoint.PREFLIGHT_MS, deadline - Date.now())) });
577
782
  if (!probe.ok)
578
783
  throw new Error(`reloadCreds: the broker did not accept the re-signed credential (${probe.reason}); nothing adopted`);
579
784
  if (Date.now() > deadline)
@@ -582,6 +787,7 @@ export class CotalEndpoint extends EventEmitter {
582
787
  // lacking a numeric `exp`, and that throw must not leave currentCreds flipped to a candidate the
583
788
  // authenticator would present on the next reconnect (a post-preflight validation failure is a no-op).
584
789
  const delay = credsRenewalDelayMs(candidate);
790
+ CotalEndpoint.assertRenewableGeneration(candidate, this.currentCreds, delay);
585
791
  this.currentCreds = candidate;
586
792
  this.armCredsRefresh(delay);
587
793
  return credsClaims(candidate);
@@ -694,11 +900,13 @@ export class CotalEndpoint extends EventEmitter {
694
900
  throw new Error(this.bearerSource
695
901
  ? "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
902
  : "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)");
903
+ // The CREDS refusal is NOT repeated here. It lives on the supply itself ({@link credsForWire}),
904
+ // which the authenticator below re-reads per attempt, so it covers this dial AND the reconnects
905
+ // nats.js runs on its own. Raising it early here too would only duplicate it on the one path
906
+ // that was already covered, and a fourth dial site added later would silently miss the copy.
907
+ // The refusal still surfaces on this path: the authenticator throws during the CONNECT, the
908
+ // library closes that attempt, and the reestablish loop's capped backoff re-enters here — where
909
+ // the source re-fetch above is what recovers a renewable endpoint.
702
910
  this.nc = await dialerFor(this.servers)({
703
911
  servers: this.servers,
704
912
  // In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
@@ -714,12 +922,32 @@ export class CotalEndpoint extends EventEmitter {
714
922
  inboxPrefix: `_INBOX_${this.connId}`,
715
923
  // The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
716
924
  // 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 }),
925
+ // Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt including the
926
+ // reconnects nats.js performs on its own (the one the broker forces at JWT `exp`, and a dial
927
+ // loop from an earlier drop that crosses `exp` mid-retry) re-reads a credential that has
928
+ // just been proven unexpired rather than whatever the cache happens to hold.
929
+ // The gate is `!== undefined`, NOT truthiness. An EMPTY creds string is a caller that meant to
930
+ // authenticate and supplied nothing; on a truthiness gate it fell through to `creds: undefined`
931
+ // and dialed ANONYMOUSLY, so the broker answered `Authorization Violation` and the real fault
932
+ // (an empty credential) was never named. Routing it into the checked getter fails it loud
933
+ // instead. Anonymous access stays reachable the only way it should be: by passing no creds.
934
+ ...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
935
  });
936
+ this.armAuthExpiryReconnectFence(this.nc);
721
937
  this.watchStatus();
722
938
  this.js = jetstream(this.nc);
939
+ // Discovery of the issued generation (SPEC 13.15): the ISSUER wrote the accepted reference
940
+ // under the token this launch was handed; the broker admits the read only under this
941
+ // connection's own per-key grant. What comes back must name this incarnation, and a fresh
942
+ // connection re-reads it: the generation is bound to the credential the transport presented,
943
+ // never to a file that may have been replaced under it.
944
+ if (this.acceptedToken !== undefined) {
945
+ const ref = await readAcceptedRow(this.nc, this.space, this.acceptedToken);
946
+ const uid = this.requireLifecycleUid("an issued endpoint");
947
+ if (ref.owner !== this.owner || ref.actor !== this.actor || ref.uid !== uid)
948
+ 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)`);
949
+ this.issuedGeneration = ref.generation;
950
+ }
723
951
  if (this.doWatch || this.doRegister) {
724
952
  const kvm = new Kvm(this.nc);
725
953
  // The presence bucket is a JetStream stream. Open mode lazily creates it; auth mode
@@ -783,7 +1011,20 @@ export class CotalEndpoint extends EventEmitter {
783
1011
  }
784
1012
  }
785
1013
  if (this.doRegister) {
786
- await this.publishPresence();
1014
+ // #1356: this await is bounded already — a refused presence write returns no response at all,
1015
+ // so it surfaces as the JetStream request TIMEOUT (~5s), once, with no retry here. What it did
1016
+ // NOT do was say what failed: the bare `timeout` names no bucket, no space and no subsystem,
1017
+ // and a launcher that catches it has nothing to act on. The lifecycle-proof failure a few lines
1018
+ // above throws a fully explanatory message; this one is held to the same standard. Still fails
1019
+ // rather than degrading — SPEC 13.1 fail-before-presence means a registering agent that cannot
1020
+ // publish presence must not come up as though it had.
1021
+ try {
1022
+ await this.publishPresence();
1023
+ }
1024
+ catch (e) {
1025
+ const detail = e?.message ?? String(e);
1026
+ 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).`);
1027
+ }
787
1028
  this.heartbeatTimer = setInterval(() => {
788
1029
  this.publishPresence().catch((e) => this.emitRecoverable(e));
789
1030
  }, this.heartbeatMs);
@@ -817,6 +1058,10 @@ export class CotalEndpoint extends EventEmitter {
817
1058
  * second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
818
1059
  * subs (tap/serve) are left alone — they aren't rebuilt here. */
819
1060
  clearConnectionScoped() {
1061
+ if (this.authExpiryReconnectTimer) {
1062
+ clearTimeout(this.authExpiryReconnectTimer);
1063
+ this.authExpiryReconnectTimer = undefined;
1064
+ }
820
1065
  if (this.heartbeatTimer) {
821
1066
  clearInterval(this.heartbeatTimer);
822
1067
  this.heartbeatTimer = undefined;
@@ -834,6 +1079,8 @@ export class CotalEndpoint extends EventEmitter {
834
1079
  }
835
1080
  }
836
1081
  this.streamMsgs.length = 0;
1082
+ this.presenceEpoch++;
1083
+ this.presenceRebind = undefined;
837
1084
  try {
838
1085
  this.presenceWatchIter?.stop();
839
1086
  }
@@ -860,7 +1107,15 @@ export class CotalEndpoint extends EventEmitter {
860
1107
  this.chatSubDenied.clear();
861
1108
  this.confirmingChatSubs.clear();
862
1109
  this.roster.clear();
1110
+ // #1356: the presence-refusal record is connection-scoped like everything else torn down here.
1111
+ // It says "the broker on THIS connection refuses writes to this bucket", so it cannot outlive the
1112
+ // connection that observed it. Left behind, a later failure on a different connection inherits it
1113
+ // and is reported as a bucket refusal — measured: a failed bind against an unreachable server
1114
+ // still printed the refusal sentence while `connectionIssue` already said "connection refused".
1115
+ this.clearPresenceWriteFailure();
863
1116
  this.lastPresenceWatchAt = 0;
1117
+ this.presenceRebindAt = 0;
1118
+ this.presenceWatchEmpty = false;
864
1119
  this.presenceSnapshotPopulated = false;
865
1120
  this.emitPresenceViewIfChanged();
866
1121
  this.joinSeq.clear();
@@ -894,6 +1149,8 @@ export class CotalEndpoint extends EventEmitter {
894
1149
  * retry starts with the same empty state as a first attempt. */
895
1150
  async closeFailedBind() {
896
1151
  const failedNc = this.nc;
1152
+ if (failedNc)
1153
+ this.disableLibraryReconnect(failedNc);
897
1154
  this.clearConnectionScoped();
898
1155
  this.nc = undefined;
899
1156
  this.js = undefined;
@@ -905,6 +1162,7 @@ export class CotalEndpoint extends EventEmitter {
905
1162
  this.membershipFeedKv = undefined;
906
1163
  this.deliveryKv = undefined;
907
1164
  this.managerLeaseKv = undefined;
1165
+ this.daemonRenewalLeaseRevision = undefined;
908
1166
  // Handles that armDeliveryControl created on a previous connection: null them so a rebind
909
1167
  // does not carry a dead protocol's subs forward. Best-effort unsubscribe first (the sub is
910
1168
  // dead with its connection either way; unsubscribing an already-dead sub is a noop).
@@ -980,13 +1238,10 @@ export class CotalEndpoint extends EventEmitter {
980
1238
  if (!this.stopped)
981
1239
  return false;
982
1240
  const nc = this.nc;
1241
+ if (nc)
1242
+ this.disableLibraryReconnect(nc);
983
1243
  this.clearConnectionScoped();
984
- try {
985
- await nc?.drain();
986
- }
987
- catch {
988
- /* already closing */
989
- }
1244
+ await this.closeWithoutLibraryReconnect(nc);
990
1245
  this.nc = undefined;
991
1246
  return true;
992
1247
  }
@@ -1028,15 +1283,17 @@ export class CotalEndpoint extends EventEmitter {
1028
1283
  return p;
1029
1284
  }
1030
1285
  /** 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
1286
+ * this.nc during the null window), drop the connection refs, close the old nc, then
1032
1287
  * rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
1033
1288
  * idempotent, so connectAndBind's own call here is a noop. */
1034
1289
  async doRebuild() {
1035
1290
  const oldNc = this.nc;
1291
+ if (oldNc)
1292
+ this.disableLibraryReconnect(oldNc);
1036
1293
  this.reconnecting = true;
1037
1294
  try {
1038
1295
  this.clearConnectionScoped();
1039
- // Manual reconnect still has a live old epoch: complete broker-consumer cleanup before drain.
1296
+ // Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
1040
1297
  // Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
1041
1298
  if (oldNc && !oldNc.isClosed())
1042
1299
  await Promise.all([...this.membershipFeedWatches].map((watch) => watch.arm));
@@ -1055,17 +1312,13 @@ export class CotalEndpoint extends EventEmitter {
1055
1312
  // The manager's liveness-lease handle too: left bound to the old connection, every renew and
1056
1313
  // re-read after a reconnect times out, and the manager reports its lease unknown for good.
1057
1314
  this.managerLeaseKv = undefined;
1315
+ this.daemonRenewalLeaseRevision = undefined;
1058
1316
  // This is an application-requested epoch teardown, not a transient nats.js blip. The old
1059
1317
  // status iterator is now stale by construction and its close is epoch-dropped, so this line is
1060
1318
  // the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
1061
1319
  this.emit("transport", { connected: false });
1062
1320
  this.emit("connection", { connected: false });
1063
- try {
1064
- await oldNc?.drain();
1065
- }
1066
- catch {
1067
- /* already closing */
1068
- }
1321
+ await this.closeWithoutLibraryReconnect(oldNc);
1069
1322
  await this.connectAndBind();
1070
1323
  // stop() may have run during the await — don't leave a live connection + heartbeat +
1071
1324
  // supervisor on a stopped endpoint. (Reads this.nc in its own scope — a bare `this.nc`
@@ -1149,10 +1402,17 @@ export class CotalEndpoint extends EventEmitter {
1149
1402
  throw e;
1150
1403
  }
1151
1404
  }
1405
+ /** The presence epoch moves first: a bind still awaiting the broker must find it moved
1406
+ * before any await below gives it a window to install a watch on a stopped endpoint
1407
+ * (see {@link startPresenceWatch}). */
1152
1408
  async stop() {
1153
1409
  if (this.stopped)
1154
1410
  return;
1411
+ if (this.nc)
1412
+ this.disableLibraryReconnect(this.nc);
1155
1413
  this.stopped = true;
1414
+ this.presenceEpoch++;
1415
+ this.presenceRebind = undefined;
1156
1416
  // Wake a reestablishLoop sitting in backoff so it sees `stopped` and exits instead of
1157
1417
  // sleeping out retryMs; also clears the timer so it can't fire later.
1158
1418
  this.kickBackoff();
@@ -1164,6 +1424,8 @@ export class CotalEndpoint extends EventEmitter {
1164
1424
  clearTimeout(this.bearerTimer);
1165
1425
  if (this.credsTimer)
1166
1426
  clearTimeout(this.credsTimer);
1427
+ if (this.authExpiryReconnectTimer)
1428
+ clearTimeout(this.authExpiryReconnectTimer);
1167
1429
  for (const watch of this.membershipFeedWatches) {
1168
1430
  watch.stopped = true;
1169
1431
  watch.arm = watch.arm.catch(() => { }).then(async () => {
@@ -1215,11 +1477,17 @@ export class CotalEndpoint extends EventEmitter {
1215
1477
  /* best-effort graceful leave */
1216
1478
  }
1217
1479
  try {
1218
- await this.nc?.drain();
1480
+ await this.closeWithoutLibraryReconnect(this.nc);
1219
1481
  }
1220
1482
  catch {
1221
1483
  /* ignore */
1222
1484
  }
1485
+ // #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
1486
+ // AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
1487
+ // way out. A stopped endpoint has no live basis for "this bucket is refusing writes", and the
1488
+ // duration is computed from `Date.now()`, so a retained record does not merely go stale: it keeps
1489
+ // COUNTING UP for as long as the dead object is held.
1490
+ this.clearPresenceWriteFailure();
1223
1491
  }
1224
1492
  // ---- messaging -----------------------------------------------------------
1225
1493
  /** Multicast: broadcast to everyone on a channel. */
@@ -1550,7 +1818,12 @@ export class CotalEndpoint extends EventEmitter {
1550
1818
  * launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
1551
1819
  * lifecycle-proof refuses a divergent uid before any publish). */
1552
1820
  serviceCaller() {
1553
- return { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
1821
+ const triple = { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
1822
+ if (this.acceptedToken === undefined)
1823
+ return triple;
1824
+ if (this.issuedGeneration === undefined)
1825
+ throw new Error("invokeService: the issued generation is not yet discovered on this connection");
1826
+ return { ...triple, generation: this.issuedGeneration };
1554
1827
  }
1555
1828
  /** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
1556
1829
  * the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
@@ -1753,6 +2026,8 @@ export class CotalEndpoint extends EventEmitter {
1753
2026
  return { state: "current", fresh: true };
1754
2027
  if (!this.presenceSnapshotPopulated)
1755
2028
  return { state: "unpopulated", fresh: false };
2029
+ if (this.presenceWatchEmpty)
2030
+ return { state: "current", fresh: true };
1756
2031
  const staleSince = this.lastPresenceWatchAt + this.ttlMs;
1757
2032
  if (Date.now() < staleSince)
1758
2033
  return { state: "current", fresh: true };
@@ -2641,20 +2916,19 @@ export class CotalEndpoint extends EventEmitter {
2641
2916
  delivered++;
2642
2917
  if (m.seq >= ceiling) { // reached the page's upper bound
2643
2918
  if (m.seq === ceiling) {
2644
- try {
2645
- out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
2646
- }
2647
- catch { /* skip undecodable */ }
2919
+ const msg = historyMessageFromDelivery(m);
2920
+ if (msg)
2921
+ out.push({ seq: m.seq, subject: m.subject, msg });
2648
2922
  }
2649
2923
  complete = true;
2650
2924
  break;
2651
2925
  }
2652
- try {
2653
- out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
2926
+ const msg = historyMessageFromDelivery(m);
2927
+ if (msg) {
2928
+ out.push({ seq: m.seq, subject: m.subject, msg });
2654
2929
  if (out.length > limit)
2655
2930
  out.shift();
2656
2931
  }
2657
- catch { /* skip undecodable */ }
2658
2932
  if (delivered >= pending) {
2659
2933
  complete = true;
2660
2934
  break;
@@ -2709,6 +2983,7 @@ export class CotalEndpoint extends EventEmitter {
2709
2983
  continue;
2710
2984
  }
2711
2985
  if (s.type === "reconnect") {
2986
+ this.armAuthExpiryReconnectFence(nc);
2712
2987
  this.emit("transport", { connected: true, server: s.server });
2713
2988
  continue;
2714
2989
  }
@@ -2919,8 +3194,25 @@ export class CotalEndpoint extends EventEmitter {
2919
3194
  this.deliveryKv ??= await openDeliveryRegistry(this.nc, this.space);
2920
3195
  return this.deliveryKv;
2921
3196
  }
3197
+ /** This endpoint instance's LEASE INCARNATION: which run of this principal a lease row was written
3198
+ * by. Minted per construction and never re-derived, because the credential cannot supply it, the
3199
+ * daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
3200
+ * processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
3201
+ leaseIncarnation = randomUUID();
2922
3202
  encodeLease(ready) {
2923
- return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, since: Date.now(), ready }));
3203
+ return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, incarnation: this.leaseIncarnation, since: Date.now(), ready }));
3204
+ }
3205
+ /** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
3206
+ * just failed has to answer before it decides whether it still owns the shard.
3207
+ *
3208
+ * Both halves are required. `holder` alone is not sufficient (a successor daemon re-reading the
3209
+ * same creds file presents the same principal, so its row would read as ours), and `incarnation`
3210
+ * alone is not sufficient either, it is a bare uuid with no claim to the principal, so a row
3211
+ * bearing ours but a foreign holder is not something we should ever adopt. A row with NO
3212
+ * incarnation predates the field and cannot be proven ours, which is the safe reading: it leads
3213
+ * to the takeover path rather than to serving on someone else's claim. */
3214
+ ownsDeliveryLease(info) {
3215
+ return info.holder === this.card.id && info.incarnation !== undefined && info.incarnation === this.leaseIncarnation;
2924
3216
  }
2925
3217
  /** Acquire the single-flight delivery lease for a shard via an ATOMIC CAS create, marked NOT-ready.
2926
3218
  * THROWS if a live lease exists — a loud refusal-to-bind (the daemon exits), never a retry, so two
@@ -2936,6 +3228,18 @@ export class CotalEndpoint extends EventEmitter {
2936
3228
  async markDeliveryLeaseReady(shardIndex, revision) {
2937
3229
  return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
2938
3230
  }
3231
+ /** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
3232
+ * holder that has UNBOUND its loops and control responder but has not given up the shard.
3233
+ *
3234
+ * `ready` is a claim about the RESPONDER, not about the row's existence: `ensureDelivery` waits on
3235
+ * it and the channel-health surface reports it. A daemon that goes quiet to re-check its ownership
3236
+ * still holds the key, so without this the space would be told a responder is up while nothing is
3237
+ * bound, a readiness lie of exactly the kind #1318 is about, just pointed the other way. Keeping
3238
+ * the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
3239
+ * should be invited in; what is being withdrawn is only the claim to be answering. */
3240
+ async markDeliveryLeaseNotReady(shardIndex, revision) {
3241
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
3242
+ }
2939
3243
  /** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
2940
3244
  * the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
2941
3245
  * the daemon should exit). */
@@ -2943,25 +3247,54 @@ export class CotalEndpoint extends EventEmitter {
2943
3247
  return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
2944
3248
  }
2945
3249
  /** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
2946
- * effort a crash just lets the bucket TTL expire it). */
2947
- async releaseDeliveryLease(shardIndex) {
3250
+ * effort, a crash just lets the bucket TTL expire it).
3251
+ *
3252
+ * THE REVISION IS WHAT MAKES THIS A RELEASE RATHER THAN A DELETE. An unconditional delete removes
3253
+ * whatever row is there, and by shutdown time the row is not necessarily still ours: the exit
3254
+ * paths that matter most are precisely the ones where another daemon has taken the shard, so the
3255
+ * departing process would delete the REPLACEMENT's lease on its way out and leave the shard with
3256
+ * no holder at all. Passing the revision this endpoint last owned makes the delete a compare-and-
3257
+ * swap (`previousSeq` becomes JetStream's `ExpectedLastSubjectSequence`), so a row that has moved
3258
+ * on is left alone.
3259
+ *
3260
+ * THE ARGUMENT IS REQUIRED, AND EXPLICITLY NULLABLE RATHER THAN OPTIONAL. `undefined` means "this
3261
+ * process no longer holds a revision it can argue for", which is the takeover paths' honest
3262
+ * answer and correctly releases nothing, the bucket TTL is the crash-safe authority and expires
3263
+ * a genuinely stale row. But if that were the DEFAULT, every existing `releaseDeliveryLease(0)`
3264
+ * call site would keep compiling and silently stop releasing: the same omission hole
3265
+ * `standaloneConnectOpts` closed by deleting its `= {}`. Measured, not theorised, this landed as
3266
+ * a red in `smoke:delivery-lease`, where a caller that genuinely held the lease released nothing
3267
+ * and the next acquire was refused. A caller must now say which it means. */
3268
+ async releaseDeliveryLease(shardIndex, revision) {
3269
+ if (revision === undefined)
3270
+ return;
2948
3271
  try {
2949
- await (await this.deliveryRegistry()).delete(leaseKey(shardIndex));
3272
+ await (await this.deliveryRegistry()).delete(leaseKey(shardIndex), { previousSeq: revision });
2950
3273
  }
2951
3274
  catch {
2952
3275
  // Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
2953
- // and clean shutdown must continue even when the broker is already gone or draining.
3276
+ // and clean shutdown must continue even when the broker is already gone or draining. A refused
3277
+ // CAS lands here too, which is the correct outcome, someone else owns the row.
2954
3278
  }
2955
3279
  }
2956
3280
  /** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
2957
3281
  * READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
2958
3282
  * under its own cred, which holds lease-bucket read but no write). */
2959
3283
  async readDeliveryLease(shardIndex) {
3284
+ return (await this.readDeliveryLeaseEntry(shardIndex))?.info;
3285
+ }
3286
+ /** The lease row AND the KV revision it is at. The revision is the CAS token every renew and the
3287
+ * CAS release are argued against, so a caller re-establishing ownership after a failed renew
3288
+ * needs the BROKER's sequence, not the one it last cached: a renew can fail with its write
3289
+ * already applied (a lost reply, a reconnect mid-request), which leaves the cached revision one
3290
+ * behind forever and every subsequent CAS refused over a sequence this process itself moved ,
3291
+ * read as somebody else's takeover, which is the #1318 misreading in a second costume. */
3292
+ async readDeliveryLeaseEntry(shardIndex) {
2960
3293
  const e = await (await this.deliveryRegistry()).get(leaseKey(shardIndex));
2961
3294
  if (!e || e.operation === "DEL" || e.operation === "PURGE")
2962
3295
  return undefined;
2963
3296
  try {
2964
- return e.json();
3297
+ return { info: e.json(), revision: e.revision };
2965
3298
  }
2966
3299
  catch {
2967
3300
  return undefined;
@@ -3002,6 +3335,53 @@ export class CotalEndpoint extends EventEmitter {
3002
3335
  }
3003
3336
  return this.managerLeaseKv;
3004
3337
  }
3338
+ /** Take or keep the per-SPACE daemon-credential renewal lease (#1634), returning whether THIS
3339
+ * instance now holds it. One atomic CAS `create` per pass: it succeeds for whoever arrives first
3340
+ * and throws for everyone else, so exactly one manager remints even when several share the
3341
+ * daemon's store. The holder re-`update`s its own key by revision, which both keeps it and proves
3342
+ * it never lost it. Losing the CAS is an ordinary outcome (a peer holds it) and returns false; it
3343
+ * never fails a start. A crashed holder's key TTL-expires with the bucket, so the next pass hands
3344
+ * the lease to a survivor with no operator step. */
3345
+ async holdDaemonRenewalLease(instanceId) {
3346
+ const kv = await this.managerLeaseRegistry();
3347
+ const held = this.daemonRenewalLeaseRevision;
3348
+ if (held !== undefined) {
3349
+ try {
3350
+ this.daemonRenewalLeaseRevision = await kv.update(MANAGER_RENEWAL_LEASE_KEY, this.encodeDaemonRenewalLease(instanceId), held);
3351
+ return true;
3352
+ }
3353
+ catch {
3354
+ // The revision moved (our key TTL-expired and a peer took it). Re-contend below rather than
3355
+ // keep reminting on a lease we no longer hold.
3356
+ this.daemonRenewalLeaseRevision = undefined;
3357
+ }
3358
+ }
3359
+ try {
3360
+ this.daemonRenewalLeaseRevision = await kv.create(MANAGER_RENEWAL_LEASE_KEY, this.encodeDaemonRenewalLease(instanceId));
3361
+ return true;
3362
+ }
3363
+ catch {
3364
+ return false; // another manager holds it: exactly one owner is the point
3365
+ }
3366
+ }
3367
+ /** Release the renewal lease on a clean stop so a peer takes over at once rather than at the TTL.
3368
+ * CAS-guarded, so a lease we already lost is never deleted out from under its new holder. */
3369
+ async releaseDaemonRenewalLease() {
3370
+ const held = this.daemonRenewalLeaseRevision;
3371
+ this.daemonRenewalLeaseRevision = undefined;
3372
+ if (held === undefined)
3373
+ return;
3374
+ try {
3375
+ await (await this.managerLeaseRegistry()).delete(MANAGER_RENEWAL_LEASE_KEY, { previousSeq: held });
3376
+ }
3377
+ catch {
3378
+ // Best-effort, like releaseManagerLease: a moved revision means it is not ours, and a broker
3379
+ // failure is recovered by the bucket TTL. Shutdown must not claim deletion.
3380
+ }
3381
+ }
3382
+ encodeDaemonRenewalLease(instanceId) {
3383
+ return new TextEncoder().encode(JSON.stringify({ instanceId, since: Date.now() }));
3384
+ }
3005
3385
  encodeManagerLease(info) {
3006
3386
  return new TextEncoder().encode(JSON.stringify(info));
3007
3387
  }
@@ -3326,14 +3706,35 @@ export class CotalEndpoint extends EventEmitter {
3326
3706
  async startPlane3(aclFor, opts = {}) {
3327
3707
  if (!this.js)
3328
3708
  throw new Error("endpoint not started");
3329
- this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness };
3709
+ this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity };
3330
3710
  await this.armPlane3();
3331
3711
  }
3332
3712
  /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
3333
3713
  * id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
3334
3714
  * is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
3335
3715
  * truth, no in-memory ledger to drift). */
3716
+ /** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
3717
+ *
3718
+ * Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
3719
+ * before {@link quiescePlane3} and awaited broker I/O inside it resumes AFTER the freeze, and a
3720
+ * reviewer traced the ordering that makes that a split rather than a latency blip: the loser
3721
+ * accepts a unit and awaits, the loser is descheduled, the successor acquires the shard and flips
3722
+ * its lease READY, then the loser resumes and answers or acks for a shard it no longer holds.
3723
+ * Subscription counts and parked-pull readings cannot see it, because the effect is the reply and
3724
+ * the ack rather than the binding.
3725
+ *
3726
+ * So each work path re-asks HERE, at the point of effect, after its awaits and before it acts.
3727
+ * The lease keeps one ROW, not one SERVER; this is the half that keeps one server. An unacked
3728
+ * message is not lost by refusing: a stopped consumer redelivers it to whoever holds the shard
3729
+ * next, which is the same property quiescing already relies on. */
3730
+ plane3MayAct() {
3731
+ return !this.plane3Quiesced;
3732
+ }
3336
3733
  async handleDeliveryControl(req) {
3734
+ // FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
3735
+ // this shard's control rail while the winner is already READY.
3736
+ if (!this.plane3MayAct())
3737
+ return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
3337
3738
  const caller = req.from.id;
3338
3739
  const args = req.args ?? {};
3339
3740
  if (req.op === "durableJoin")
@@ -3352,11 +3753,18 @@ export class CotalEndpoint extends EventEmitter {
3352
3753
  catch (e) {
3353
3754
  return { ok: false, error: e.message };
3354
3755
  }
3355
- return { ok: true, data: { memberships: await this.ownerMemberships(caller, uid) } };
3756
+ const memberships = await this.ownerMemberships(caller, uid);
3757
+ // FENCE AFTER THE AWAIT, which is the one that matters. The entry check above cannot catch the
3758
+ // case this exists for: a request admitted WHILE SERVING, then descheduled inside its broker
3759
+ // read, resuming after a successor has taken the shard and reached READY. Re-ask at the point
3760
+ // of effect, so the answer is never served by a daemon that has stood down.
3761
+ if (!this.plane3MayAct())
3762
+ return { ok: false, error: "delivery: this daemon stopped serving this shard while the request was in flight; retry" };
3763
+ return { ok: true, data: { memberships } };
3356
3764
  }
3357
3765
  return { ok: false, error: `op "${req.op}" not supported on the delivery control service` };
3358
3766
  }
3359
- /** Validate the channel ARG shape only non-blank, valid, concrete (NO ACL check, that is op-specific).
3767
+ /** Validate the channel ARG shape only: non-blank, valid, concrete (NO ACL check, that is op-specific).
3360
3768
  * Returns the channel on success or a ControlReply error to short-circuit. */
3361
3769
  checkDurableChannelArg(args, op) {
3362
3770
  const channel = typeof args.channel === "string" ? args.channel.trim() : "";
@@ -3539,6 +3947,85 @@ export class CotalEndpoint extends EventEmitter {
3539
3947
  const max = this.nc?.info?.max_payload ?? 1_048_576;
3540
3948
  return Math.max(1, Math.floor(max * 0.9));
3541
3949
  }
3950
+ /** Stop serving Plane-3 WITHOUT tearing down the connection, so a daemon that has just learned its
3951
+ * lease may no longer be its own can stop acting on the shard while it finds out for certain.
3952
+ *
3953
+ * A COMPARE-AND-SWAP KEEPS ONE LEASE ROW; IT DOES NOT KEEP ONE SERVER. That distinction is the
3954
+ * reason this exists, and it was a review finding. When a renew fails, the daemon re-reads the
3955
+ * key and may then re-acquire it, and across that read-then-create it was still consuming the
3956
+ * fan-out durable, still running the inbox reader, and still answering ctl.delivery. If a
3957
+ * replacement acquired the shard in that window, both processes served the same durables until
3958
+ * the loser's create was refused and its teardown finished. The old code did not have this
3959
+ * window, because it began shutting down on the first renew failure; treating that failure as a
3960
+ * question instead of a verdict is right, but asking the question while still serving is not.
3961
+ *
3962
+ * So the daemon goes quiet FIRST and re-arms only once it has proof: `held` on a re-read, or a
3963
+ * won atomic create. `unknown` stays quiet, the whole point is that not being able to ask is not
3964
+ * permission to keep acting. Quiescing costs delivery latency for a few seconds; the alternative
3965
+ * costs a SPLIT durable, which is a correctness failure rather than an availability one.
3966
+ *
3967
+ * Deliberately not `stop()`: the connection, the lease KV handles and the control rails must stay
3968
+ * up, because the daemon still has to ask the broker who owns the shard. */
3969
+ async quiescePlane3() {
3970
+ this.plane3Quiesced = true;
3971
+ if (this.deliveryServeSub) {
3972
+ try {
3973
+ this.deliveryServeSub.unsubscribe();
3974
+ }
3975
+ catch { /* already dead */ }
3976
+ this.deliveryServeSub = undefined;
3977
+ }
3978
+ if (this.deliveryAdminServeSub) {
3979
+ try {
3980
+ this.deliveryAdminServeSub.unsubscribe();
3981
+ }
3982
+ catch { /* already dead */ }
3983
+ this.deliveryAdminServeSub = undefined;
3984
+ }
3985
+ // Stopping the consumers ends the `for await` loops that drive fan-out and the reader. In-flight
3986
+ // messages are NOT acked by a stopped consumer, so they redeliver to whoever holds the shard
3987
+ // next: quiescing loses no message, it only stops this process from claiming them.
3988
+ for (const msgs of this.streamMsgs.splice(0)) {
3989
+ try {
3990
+ msgs.stop();
3991
+ }
3992
+ catch { /* already draining */ }
3993
+ }
3994
+ }
3995
+ /** Whether Plane-3 is currently quiesced: unbound, and refusing to re-bind until ownership is proven.
3996
+ * Read-only. The flag is the daemon's own answer to "am I serving this shard", so a cell that asserts
3997
+ * a recovery worked can check the endpoint's state rather than inferring it from a log line. */
3998
+ plane3IsQuiesced() {
3999
+ return this.plane3Quiesced;
4000
+ }
4001
+ /** Resume serving Plane-3 after {@link quiescePlane3}, once ownership has been re-established.
4002
+ * Idempotent, and a no-op when the daemon was never quiesced.
4003
+ *
4004
+ * The flag is cleared ONLY after every binding is up, and restored if any of them throws. Clearing
4005
+ * it first looks equivalent and is not: `armPlane3` binds in four stages (`manager`, the two control
4006
+ * responders, the fan-out consumer, the reader), so a failure at any one leaves the endpoint recorded
4007
+ * as un-quiesced while some of those are missing. From there every later `rearmPlane3` returns at the
4008
+ * `!plane3Quiesced` guard WITHOUT attempting to bind, and the caller goes on to flip the lease READY.
4009
+ * That is a readiness lie surviving a transient broker error, the daemon claims a responder it does
4010
+ * not have, which is the #1318 outage wearing the readiness flag instead of the exit path. */
4011
+ async rearmPlane3() {
4012
+ if (!this.plane3Quiesced)
4013
+ return;
4014
+ // `armPlane3` refuses while quiesced (a reconnect must not resume mid-question), so the flag is
4015
+ // lowered for the attempt and RAISED AGAIN on failure. A partial bind leaves the endpoint quiesced,
4016
+ // which is the honest state: not serving, and a later retry will attempt the whole sequence again.
4017
+ this.plane3Quiesced = false;
4018
+ try {
4019
+ await this.armPlane3();
4020
+ }
4021
+ catch (e) {
4022
+ this.plane3Quiesced = true;
4023
+ // Drop whatever DID bind. Leaving half-bound responders up would answer control requests for a
4024
+ // shard this process has just declared itself not to be serving.
4025
+ this.quiescePlane3();
4026
+ throw e;
4027
+ }
4028
+ }
3542
4029
  /** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
3543
4030
  * cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
3544
4031
  * the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
@@ -3547,6 +4034,11 @@ export class CotalEndpoint extends EventEmitter {
3547
4034
  async armPlane3() {
3548
4035
  if (!this.plane3 || !this.js)
3549
4036
  return;
4037
+ // A quiesced endpoint must not be re-armed by a RECONNECT: the reconnect path calls this too,
4038
+ // and silently resuming there would restore exactly the double-serving this guards against
4039
+ // while the ownership question is still open.
4040
+ if (this.plane3Quiesced)
4041
+ return;
3550
4042
  await this.manager(); // the manager runs consume:false, so this.jsm is lazy — ensure it
3551
4043
  this.armDeliveryControl();
3552
4044
  await this.runFanout();
@@ -3587,6 +4079,9 @@ export class CotalEndpoint extends EventEmitter {
3587
4079
  * feed's rw connection, and reply with proof (identities + the adopted JWT windows) — or a
3588
4080
  * structured failure (e.g. the file was never re-signed), never a silent partial. */
3589
4081
  async handleDeliveryAdmin(req) {
4082
+ // FENCE: same reason as the runtime rail above.
4083
+ if (!this.plane3MayAct())
4084
+ return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
3590
4085
  if (req.op === "reloadCreds") {
3591
4086
  // The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
3592
4087
  // component. A missing entry means "no expectation" (the passive backstop still adopts).
@@ -3666,6 +4161,21 @@ export class CotalEndpoint extends EventEmitter {
3666
4161
  return { ok: false, error: e.message };
3667
4162
  }
3668
4163
  }
4164
+ if (req.op === "reloadStoreIdentity") {
4165
+ // Proof that this daemon reloads standing creds from ONE named store. The manager
4166
+ // compares it to its remint store before every renewal pass. Absent hook
4167
+ // is a daemon that cannot name its store, which is itself a divergent composition.
4168
+ if (!this.plane3?.reloadStoreIdentity)
4169
+ return { ok: false, error: "reloadStoreIdentity: this daemon did not name the SecretStore it reloads from" };
4170
+ try {
4171
+ const identity = this.plane3.reloadStoreIdentity();
4172
+ // Round-trip through the closed parser so a hook cannot smuggle extra fields onto the rail.
4173
+ return { ok: true, data: parseSecretStoreIdentity(identity) };
4174
+ }
4175
+ catch (e) {
4176
+ return { ok: false, error: e.message };
4177
+ }
4178
+ }
3669
4179
  return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
3670
4180
  }
3671
4181
  /** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
@@ -3700,6 +4210,13 @@ export class CotalEndpoint extends EventEmitter {
3700
4210
  * members within interval; `live` channel → `@mention` targets authorized to read it (ACL only).
3701
4211
  * Members KV is scanned FRESH per message (no cache — red-team BLOCKER-1 catch-up correctness). */
3702
4212
  async fanOutMessage(m) {
4213
+ // FENCE, and NOT followed by an ack: this unit was dispatched before the quiesce and is resuming
4214
+ // after it, so writing the fan-out now would put entries in member inboxes on behalf of a shard
4215
+ // this daemon no longer serves. Returning WITHOUT acking is the whole point - a stopped consumer
4216
+ // redelivers to whoever holds the shard next, so refusing here loses no message, while acking it
4217
+ // would consume the successor's work.
4218
+ if (!this.plane3MayAct())
4219
+ return;
3703
4220
  const parsed = parseSubject(m.subject);
3704
4221
  if (!parsed || parsed.kind !== "chat") {
3705
4222
  m.ack();
@@ -3720,6 +4237,12 @@ export class CotalEndpoint extends EventEmitter {
3720
4237
  } // authenticity (owner must be a real principal, not an old-shape alias)
3721
4238
  const seq = m.seq;
3722
4239
  const normalizedMsg = authenticatedChannelMessage(msg, channel);
4240
+ // SECOND FENCE, AT THE COMMIT POINT. The entry check above cannot cover this unit's own awaits:
4241
+ // the class read and the member scan below both hit the broker, and a daemon descheduled inside
4242
+ // them can resume after a successor owns the shard. Re-ask immediately before the first WRITE,
4243
+ // and again return without acking so the entry redelivers rather than being consumed here.
4244
+ if (!this.plane3MayAct())
4245
+ return;
3723
4246
  if ((await this.deliveryClassFresh(channel)) === "durable") {
3724
4247
  for (const rec of await listMembers(await this.membersRegistry(), { channel })) {
3725
4248
  if (rec.owner === msg.from.id)
@@ -3730,6 +4253,12 @@ export class CotalEndpoint extends EventEmitter {
3730
4253
  // retired lifecycle's inbox, never the alias's new occupant (SPEC 13.1 cross-plane scoping).
3731
4254
  // Store the AUTHENTICATED-channel copy (main's normalization): the durable frame validates
3732
4255
  // msg.channel === frame.channel, and payload to/toService are stripped.
4256
+ // Per-copy, because the scan above and every publish below is a broker round trip: a fan-out
4257
+ // to fifty members can straddle a quiesce in its middle. Stopping part-way is safe where
4258
+ // stopping late is not - the entry is never acked here, so the successor redelivers it and
4259
+ // re-sends the whole set; publishDinbox is idempotent per (owner, lifecycle, msgID).
4260
+ if (!this.plane3MayAct())
4261
+ return;
3733
4262
  await this.publishDinbox(rec.owner, rec.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "durable-channel", generation: rec.generation });
3734
4263
  }
3735
4264
  }
@@ -3751,9 +4280,16 @@ export class CotalEndpoint extends EventEmitter {
3751
4280
  }
3752
4281
  if (!row || !channelInAllow(row.allowSubscribe, channel))
3753
4282
  continue; // @mention can't bypass the read ACL
4283
+ if (!this.plane3MayAct())
4284
+ return; // same rule on the live-mention path
3754
4285
  await this.publishDinbox(owner, row.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "live-mention", generation: 0 });
3755
4286
  }
3756
4287
  }
4288
+ // FINAL FENCE, BEFORE THE ACK ITSELF, which is the real commit: acking tells the stream this
4289
+ // shard's work is done. A daemon that stopped serving mid-fan-out must not make that claim, or
4290
+ // the successor never sees the entry and the message is lost outright rather than duplicated.
4291
+ if (!this.plane3MayAct())
4292
+ return;
3757
4293
  m.ack();
3758
4294
  }
3759
4295
  /** Trusted-reader loop: bind the single privileged `reader` durable over `dinbox.>` and re-authorize
@@ -3787,6 +4323,10 @@ export class CotalEndpoint extends EventEmitter {
3787
4323
  * revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
3788
4324
  * has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
3789
4325
  async readerHandle(m) {
4326
+ // FENCE, same rule as the fan-out writer: no effect and no ack once this daemon has stopped
4327
+ // serving the shard. The entry redelivers to the holder rather than being consumed here.
4328
+ if (!this.plane3MayAct())
4329
+ return;
3790
4330
  const pr = parseDinboxPrincipal(m.subject);
3791
4331
  if (!pr) {
3792
4332
  m.ack();
@@ -3836,6 +4376,12 @@ export class CotalEndpoint extends EventEmitter {
3836
4376
  return;
3837
4377
  }
3838
4378
  }
4379
+ // COMMIT-POINT FENCE. Everything above this line is broker I/O - the ACL read and the member
4380
+ // re-read - so the entry check at the top of the handler is stale by now if this unit was
4381
+ // descheduled inside either. The transfer below is the effect that must not happen on behalf of
4382
+ // a shard we no longer serve; returning without acking leaves it for the holder.
4383
+ if (!this.plane3MayAct())
4384
+ return;
3839
4385
  try {
3840
4386
  // DLV has no original chat subject, so preserve the channel the trusted fan-out reader derived
3841
4387
  // from CHAT. Never let the publisher-controlled payload label choose connector attention.
@@ -3866,6 +4412,12 @@ export class CotalEndpoint extends EventEmitter {
3866
4412
  m.nak(2000);
3867
4413
  return;
3868
4414
  }
4415
+ // The ack is the last commit: the transfer above already happened, so the only question left is
4416
+ // whether THIS daemon may retire the source entry. If it stopped serving mid-transfer, leave the
4417
+ // entry pending - the successor re-transfers and stream-wide dedupe on the msgID collapses the
4418
+ // duplicate, which is the recoverable direction. Acking here is the unrecoverable one.
4419
+ if (!this.plane3MayAct())
4420
+ return;
3869
4421
  m.ack();
3870
4422
  }
3871
4423
  /** Agent-side: bind + pump our pre-created Plane-3 DELIVER durable (`dlv_<id>`). Every message here is
@@ -4605,18 +5157,99 @@ export class CotalEndpoint extends EventEmitter {
4605
5157
  // the publisher — this covers stop(), setStatus("offline"), and any future offline publish site, so
4606
5158
  // the raw KV record is compliant, not only the observer-side roster materialization.
4607
5159
  const record = this.status === "offline" ? this.toOffline(p) : p;
4608
- await this.kv.put(this.card.id, JSON.stringify(record));
5160
+ // #1356: this put can still be in flight when a teardown runs, because a rebind reaches
5161
+ // publishPresence through onPresenceBucketEmpty and no teardown awaits that flight. Take the same
5162
+ // epoch fence {@link startPresenceWatch} takes, so a put that outlives its epoch still throws to
5163
+ // its caller but no longer writes presence-refusal state belonging to a later connection. Without
5164
+ // it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
5165
+ // refusal on the connection that just published successfully.
5166
+ const epoch = this.presenceEpoch;
5167
+ try {
5168
+ await this.kv.put(this.card.id, JSON.stringify(record));
5169
+ }
5170
+ catch (e) {
5171
+ // #1356: a broker can put this bucket into a state where it refuses every write and never
5172
+ // recovers, and the write is the ONLY thing that fails — open and watch both still succeed, so
5173
+ // nothing else here notices. Record WHEN the refusals started, at the one site that knows the
5174
+ // failing write was a presence write; a caller cannot infer that from the generic `warning`
5175
+ // stream, which carries any recoverable error.
5176
+ if (epoch === this.presenceEpoch && !this.stopped) {
5177
+ this.presenceWriteFailingSince ??= Date.now();
5178
+ this.lastPresenceWriteError = e?.message ?? String(e);
5179
+ }
5180
+ throw e;
5181
+ }
5182
+ // A late SUCCESS is the same hazard mirrored, and this fence answers only the cross-epoch half
5183
+ // of it: a success belonging to a retired epoch cannot erase a refusal the current one
5184
+ // established from its own evidence. It does NOT order puts within a single epoch, because it
5185
+ // compares epoch identity rather than which put is the latest evidence, so an earlier put that
5186
+ // succeeds late still clears a later put's refusal. Heartbeats run at 2s against a ~5s put
5187
+ // timeout, so that overlap is routine rather than a corner, and the next failing put re-plants
5188
+ // the record with a fresh `since`. Tracked in #1461, not repaired here.
5189
+ if (epoch !== this.presenceEpoch || this.stopped)
5190
+ return;
5191
+ this.clearPresenceWriteFailure();
4609
5192
  }
5193
+ /** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
5194
+ * away. "This bucket is refusing writes" is a claim about a specific broker connection; once that
5195
+ * connection is torn down or rebuilt the claim has no remaining basis, and a later failure on a
5196
+ * fresh connection must establish it again from its own evidence.
5197
+ *
5198
+ * Cleared at the SOURCE rather than guarded at each reader, because a guard protects one consumer
5199
+ * and clearing protects every consumer, including ones not yet written. Measured: without this, a
5200
+ * failed bind against an unreachable server still reported the presence-refusal sentence while the
5201
+ * endpoint's own `connectionIssue` already said "connection refused". */
5202
+ clearPresenceWriteFailure() {
5203
+ this.presenceWriteFailingSince = undefined;
5204
+ this.lastPresenceWriteError = undefined;
5205
+ }
5206
+ /** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
5207
+ * last publish succeeded. Cleared by the first successful write, so a survived blip reads as
5208
+ * healthy and only a SUSTAINED failure carries a duration.
5209
+ *
5210
+ * Presence writes are the CANARY, not the scope: the broker can disable JetStream account-wide
5211
+ * while the NATS connection stays up, so a caller must not read this as "only presence is
5212
+ * affected". It reports what was observed, not how far the fault extends. */
5213
+ presenceWriteFailure() {
5214
+ if (this.presenceWriteFailingSince === undefined)
5215
+ return undefined;
5216
+ return {
5217
+ since: this.presenceWriteFailingSince,
5218
+ forMs: Date.now() - this.presenceWriteFailingSince,
5219
+ error: this.lastPresenceWriteError,
5220
+ bucket: presenceBucket(this.space),
5221
+ };
5222
+ }
5223
+ /** Bind a presence watch on the current connection. Resolves true when the watch was
5224
+ * installed, false when the endpoint stopped or rebuilt while the bind was in flight: that
5225
+ * bind's iterator is released here and nothing is installed, because the epoch that asked
5226
+ * for it is gone and the epoch that replaced it binds its own watch through
5227
+ * {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
5228
+ * resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
5229
+ * overwrite the fresh epoch's watch with a dead-connection iterator. */
4610
5230
  async startPresenceWatch() {
4611
5231
  if (!this.kv)
4612
- return;
5232
+ return false;
5233
+ const epoch = this.presenceEpoch;
4613
5234
  let hydrated;
4614
5235
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
4615
5236
  const iter = await this.kv.watch();
5237
+ if (epoch !== this.presenceEpoch) {
5238
+ try {
5239
+ iter.stop();
5240
+ }
5241
+ catch { /* its connection may already be gone */ }
5242
+ hydrated();
5243
+ return false;
5244
+ }
4616
5245
  this.presenceWatchIter = iter;
4617
5246
  void (async () => {
4618
5247
  let ready = false;
4619
5248
  for await (const e of iter) {
5249
+ // A rebind bound a newer watch: this one's remaining entries belong to a retired epoch.
5250
+ // Stop consuming them (the rebind stops the iterator too; this ends the loop first).
5251
+ if (this.presenceWatchIter !== iter)
5252
+ break;
4620
5253
  this.handleKvEntry(e);
4621
5254
  // @nats-io/kv marks the final initial replay entry isUpdate=true. Later updates stay true.
4622
5255
  if (!ready && e.isUpdate) {
@@ -4628,6 +5261,68 @@ export class CotalEndpoint extends EventEmitter {
4628
5261
  }
4629
5262
  hydrated();
4630
5263
  })().catch((e) => this.emit("error", e));
5264
+ return true;
5265
+ }
5266
+ /**
5267
+ * Replace a presence watch that has gone silent past TTL while the connection is up. The new
5268
+ * ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
5269
+ * heartbeating is re-observed within one replay and a peer that is gone is aged out by the
5270
+ * next sweep exactly as if the watch had never stalled. Rate-limited to one attempt per TTL
5271
+ * per observer, never overlapping, never on a stopped or rebuilding endpoint (those own their
5272
+ * watch through {@link connectAndBind}). A stop or rebuild that lands while the bind is in
5273
+ * flight retires it: {@link startPresenceWatch} releases the late iterator and reports
5274
+ * nothing, since the epoch that was silent no longer exists. A failed bind is reported and
5275
+ * the view stays stale.
5276
+ */
5277
+ rebindStalePresenceWatch(now) {
5278
+ if (this.stopped || this.reconnecting || !this.kv || !this.nc || this.nc.isClosed())
5279
+ return;
5280
+ if (this.presenceRebind || now - this.presenceRebindAt < this.ttlMs)
5281
+ return;
5282
+ this.presenceRebindAt = now;
5283
+ const old = this.presenceWatchIter;
5284
+ const epoch = this.presenceEpoch;
5285
+ this.presenceRebind = (async () => {
5286
+ try {
5287
+ const silentMs = now - this.lastPresenceWatchAt;
5288
+ // Bind the replacement FIRST. A rebind the broker refuses (stream gone, create denied) or
5289
+ // one a held link never answers must leave the old watch in place: on a plain stall that
5290
+ // watch is the one that recovers by itself, and its replay is still guarded against
5291
+ // expired PUTs. Only a successfully bound watch retires its predecessor.
5292
+ const installed = await this.startPresenceWatch();
5293
+ // Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
5294
+ // released and the old watch was torn down by whoever moved the epoch. Nothing to
5295
+ // retire, nothing to report.
5296
+ if (!installed)
5297
+ return;
5298
+ if (old && old !== this.presenceWatchIter) {
5299
+ try {
5300
+ old.stop();
5301
+ }
5302
+ catch { /* already closed with its consumer */ }
5303
+ }
5304
+ // A bucket with no keys replays nothing, so the new watch cannot refresh
5305
+ // `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. Read
5306
+ // the consumer's initial pending count for that one fact; nats.js's KV watch computed it
5307
+ // from the same `info(true)` it used to place the isUpdate marker.
5308
+ const pending = this.presenceWatchIter?._data?._info?.num_pending;
5309
+ if (pending === 0)
5310
+ await this.onPresenceBucketEmpty();
5311
+ this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
5312
+ }
5313
+ catch (e) {
5314
+ // A bind the epoch swap itself rejected (connection drained under it) is not a fault of
5315
+ // the epoch that replaced it; only a refusal on a still-current epoch is reported.
5316
+ if (epoch === this.presenceEpoch)
5317
+ this.emit("error", e);
5318
+ }
5319
+ finally {
5320
+ // An epoch swap already disowned this flight (and may own a successor's by now); only
5321
+ // a flight still in its own epoch clears the slot. Within one epoch there is one flight.
5322
+ if (epoch === this.presenceEpoch)
5323
+ this.presenceRebind = undefined;
5324
+ }
5325
+ })();
4631
5326
  }
4632
5327
  /** Watch the channel registry: replay existing keys, then stream updates, into the local
4633
5328
  * cache. Best-effort — a registry the endpoint can't read leaves the cache empty (effective
@@ -4667,8 +5362,65 @@ export class CotalEndpoint extends EventEmitter {
4667
5362
  /* keep last good */
4668
5363
  }
4669
5364
  }
5365
+ /** The watch was just bound onto a bucket with no keys.
5366
+ *
5367
+ * A NON-REGISTERING observer (a `cotal status` probe, a lease checker) has real knowledge:
5368
+ * nobody is present. Every peer still in its roster is known gone (its key is not there to
5369
+ * replay), so it is marked offline now rather than aged out against a delivery that cannot
5370
+ * come; the silence gate is disarmed and the view reads current until the first write lands.
5371
+ * Without that the empty-bucket view relapsed to stale one window later and rebound again on
5372
+ * every window, one consumer create and one warning per TTL for as long as the mesh was empty.
5373
+ *
5374
+ * A REGISTERING observer (the manager) is itself one of the keys that should be there. An
5375
+ * empty bucket under it means the bucket was wiped since its last heartbeat (the stream
5376
+ * recreation), and the same wipe took every peer's record: their absence says the bucket is
5377
+ * new, not that they left. a reviewer reproduced the previous behaviour at default timing:
5378
+ * the rebind landed ~0.9s after the recreation, the observer marked every peer AND ITSELF
5379
+ * offline, and held the view current for up to one heartbeat, a false verdict `cotal ps`
5380
+ * would print as `mesh offline`. So a registering observer re-publishes its own record NOW,
5381
+ * which the new watch delivers, and lets the ordinary per-peer age-out run from that delivery:
5382
+ * a peer that is still heartbeating rewrites its key within its own heartbeat interval and is
5383
+ * re-observed live; one that is gone ages out exactly as after a plain rebind. The roster is
5384
+ * not touched here and the view is not held; the delivery is what makes it current.
5385
+ *
5386
+ * What neither branch covers: a consumer that dies again while the bucket is still empty is
5387
+ * not detectable by silence, so the first write after that is missed until the observer
5388
+ * restarts. */
5389
+ async onPresenceBucketEmpty() {
5390
+ if (this.doRegister) {
5391
+ // Our own key is missing from a bucket we write to: put it back. publishPresence throws on
5392
+ // a refused write; that surfaces through the rebind flight's error path with the epoch
5393
+ // fence intact, and the view stays stale, which is the honest state for an observer that
5394
+ // cannot even record itself.
5395
+ await this.publishPresence();
5396
+ return;
5397
+ }
5398
+ this.markPresenceBucketEmpty();
5399
+ }
5400
+ /** See {@link onPresenceBucketEmpty}: the non-registering branch. */
5401
+ markPresenceBucketEmpty() {
5402
+ this.presenceWatchEmpty = true;
5403
+ this.lastPresenceWatchAt = 0;
5404
+ let changed = false;
5405
+ for (const [id, p] of this.roster) {
5406
+ if (p.status === "offline")
5407
+ continue;
5408
+ const offline = this.toOffline(p);
5409
+ this.roster.set(id, offline);
5410
+ this.emit("presence", { type: "offline", presence: offline });
5411
+ changed = true;
5412
+ }
5413
+ if (changed)
5414
+ this.emit("roster", this.getRoster());
5415
+ this.emitPresenceViewIfChanged();
5416
+ }
4670
5417
  handleKvEntry(e) {
5418
+ // Provisioning uses this one reserved key to prove the bucket's max_age is enforced (#404).
5419
+ // It is maintenance traffic, never a peer identity, an offline transition, or view freshness.
5420
+ if (e.key === TTL_RECONCILE_CANARY_KEY)
5421
+ return;
4671
5422
  this.lastPresenceWatchAt = Date.now();
5423
+ this.presenceWatchEmpty = false;
4672
5424
  if (e.operation === "DEL" || e.operation === "PURGE") {
4673
5425
  this.markOffline(e.key);
4674
5426
  return;
@@ -4774,11 +5526,29 @@ export class CotalEndpoint extends EventEmitter {
4774
5526
  // watch freshness; surface the view as stale instead.
4775
5527
  if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
4776
5528
  this.emitPresenceViewIfChanged();
5529
+ // Staying stale is the right verdict for a held link (#1045), and the wrong END STATE when
5530
+ // the transport is up and the watch's own consumer is what died. Measured on a live deployment
5531
+ // 2026-09-09: the presence stream was deleted and recreated, its sequence restarted, and
5532
+ // every observer's ORDERED consumer re-created itself at the OLD start sequence (nats.js
5533
+ // 3.4.0 resets from its cursor). The broker kept sending idle heartbeats, so the client
5534
+ // never reset again, the iterator never closed, and the manager's roster stayed frozen at
5535
+ // the pre-recreation snapshot for hours: `cotal ps` read every older seat "mesh offline"
5536
+ // and every newer seat "not in roster" while all of them were heartbeating. The same
5537
+ // end state follows a plain consumer delete (an operator, or the 5-minute inactive
5538
+ // threshold after a long stall). Rebind the watch from the bucket's CURRENT state; a held
5539
+ // link's rebind fails or stays silent and the view simply stays stale, as before.
5540
+ this.rebindStalePresenceWatch(now);
4777
5541
  return;
4778
5542
  }
4779
5543
  let changed = false;
4780
5544
  for (const [id, p] of this.roster) {
4781
- if (p.status !== "offline" && now - p.ts > this.ttlMs) {
5545
+ // A peer's own `ts` always trails the observer's last delivery, so "older than TTL by the
5546
+ // wall clock" alone ages peers out on the tick just before the whole-bucket gate above
5547
+ // trips (#1311's flap; measured as 3 offline verdicts per silence in the rebind suite).
5548
+ // Require that the watch itself delivered for a full TTL after this peer's last heartbeat:
5549
+ // then other peers were heard and this one was not, which is the only silence that is the
5550
+ // peer's rather than the observer's.
5551
+ if (p.status !== "offline" && now - p.ts > this.ttlMs && this.lastPresenceWatchAt - p.ts > this.ttlMs) {
4782
5552
  const offline = this.toOffline(p);
4783
5553
  this.roster.set(id, offline);
4784
5554
  this.emit("presence", { type: "offline", presence: offline });
@@ -4817,10 +5587,18 @@ function kindFromParsed(kind) {
4817
5587
  throw new Error(`cannot derive a message kind from subject kind "${kind}"`);
4818
5588
  }
4819
5589
  }
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`. */
5590
+ /** Routing fields in the envelope are advisory. The broker forge-locks sender (and for DMs,
5591
+ * recipient) into the subject. Callers MUST already have rejected a missing `from`, an
5592
+ * unparseable subject, or `from.id !== parsed.sender` (SPEC §5). This derives the remaining
5593
+ * routing tokens from the subject for rows that survived — it does not rewrite a mismatched
5594
+ * `from.id`. Live tails, channel backfill, and channel recall skip the mismatch; history
5595
+ * does the same (#388). */
4822
5596
  function authenticatedMessage(msg, parsed) {
4823
- return parsed.kind === "chat" ? authenticatedChannelMessage(msg, parsed.rest) : msg;
5597
+ if (parsed.kind === "chat")
5598
+ return authenticatedChannelMessage(msg, parsed.rest);
5599
+ if (parsed.kind === "inst")
5600
+ return authenticatedDmMessage(msg, parsed.rest);
5601
+ return msg;
4824
5602
  }
4825
5603
  function authenticatedChannelMessage(msg, channel) {
4826
5604
  if (msg.channel === channel && msg.to === undefined && msg.toService === undefined)
@@ -4828,6 +5606,49 @@ function authenticatedChannelMessage(msg, channel) {
4828
5606
  const { to: _to, toService: _toService, ...base } = msg;
4829
5607
  return { ...base, channel };
4830
5608
  }
5609
+ function authenticatedDmMessage(msg, to) {
5610
+ if (msg.to === to && msg.channel === undefined && msg.toService === undefined)
5611
+ return msg;
5612
+ const { channel: _channel, toService: _toService, ...base } = msg;
5613
+ return { ...base, to };
5614
+ }
5615
+ /** History drain keeps `m.json()` and used to throw the subject away. SPEC §5: on receive, verify
5616
+ * `from.id` equals the subject sender; on mismatch, a missing `from`, or an unparseable delivery
5617
+ * subject, reject and never surface. Fail closed on shape too: a stored JSON `null` or a truthy
5618
+ * non-object `from` must not throw mid-array. Do not echo-drop `from.id === this.card.id`:
5619
+ * god-view history must include the viewer's own sends. */
5620
+ function historyMessageFromDelivery(m) {
5621
+ let raw;
5622
+ try {
5623
+ raw = m.json();
5624
+ }
5625
+ catch {
5626
+ return undefined;
5627
+ }
5628
+ if (!isHistoryDrainEnvelope(raw))
5629
+ return undefined;
5630
+ const parsed = parseSubject(m.subject);
5631
+ if (!parsed || !isPrincipalOwnerToken(parsed.owner))
5632
+ return undefined;
5633
+ if (raw.from.id !== parsed.sender)
5634
+ return undefined;
5635
+ return authenticatedMessage(raw, parsed);
5636
+ }
5637
+ /**
5638
+ * Narrow enough for the type checker and for fail-closed history: object envelope, usable `id`,
5639
+ * object `from`. That is what lets `from.id !== parsed.sender` run without throwing, and what
5640
+ * lets `authenticatedMessage` take the row without a cast.
5641
+ *
5642
+ * Does NOT verify SPEC §5 message shape. It does not require a string `from.id` (the SPEC §5
5643
+ * comparison still rejects a mismatch), exactly one route key, a finite `ts`, a string
5644
+ * `space`, a full EndpointRef `from` (`name`/`role`), or well-formed `parts`. Those belong
5645
+ * to `isCotalMessage` (Plane-3). History must not use that guard: a public
5646
+ * `unicast(..., { parts: [{ kind: "data", data: undefined }] })` serializes to `{kind:"data"}`
5647
+ * and must still surface.
5648
+ */
5649
+ function isHistoryDrainEnvelope(value) {
5650
+ return isRecord(value) && isUsableMessageId(value.id) && isRecord(value.from);
5651
+ }
4831
5652
  function isPlane3DeliveryFrame(value) {
4832
5653
  if (!isRecord(value))
4833
5654
  return false;
@@ -5028,8 +5849,14 @@ export function wsServers(servers) {
5028
5849
  * probe dials; a ws(s) broker is by definition published through an HTTPS edge (CDN tunnel,
5029
5850
  * reverse proxy), where TLS + upgrade + INFO + the auth round-trip routinely exceeds 1s cold —
5030
5851
  * measured ~60% spurious "not reachable" against a Cloudflare-fronted broker. Callers passing an
5031
- * explicit `timeoutMs` are untouched. */
5032
- function defaultProbeTimeoutMs(servers) {
5852
+ * explicit `timeoutMs` are untouched.
5853
+ *
5854
+ * EXPORTED because a caller that judges a probe by WHEN it answered has to compare against the
5855
+ * deadline this function actually handed the probe. The delivery watchdog does exactly that, and
5856
+ * hardcoding 1000 there silently misread every ws broker: honest refusals arrive at 2-5s, past a
5857
+ * budget that was never theirs, and would be classified as this process's starvation rather than
5858
+ * the server's refusal. The budget and the judgment must come from one place. */
5859
+ export function defaultProbeTimeoutMs(servers) {
5033
5860
  return wsServers(servers) ? 5000 : 1000;
5034
5861
  }
5035
5862
  /** Pick the dial function by SCHEME: `ws://`/`wss://` servers go through nats-core's