@cotal-ai/core 0.49.0 → 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 (47) hide show
  1. package/dist/endpoint-envelope.d.ts +1 -1
  2. package/dist/endpoint-envelope.d.ts.map +1 -1
  3. package/dist/endpoint-envelope.js +1 -1
  4. package/dist/endpoint-envelope.js.map +1 -1
  5. package/dist/endpoint-error.d.ts +10 -1
  6. package/dist/endpoint-error.d.ts.map +1 -1
  7. package/dist/endpoint-error.js +12 -0
  8. package/dist/endpoint-error.js.map +1 -1
  9. package/dist/endpoint-invoke.d.ts.map +1 -1
  10. package/dist/endpoint-invoke.js +7 -2
  11. package/dist/endpoint-invoke.js.map +1 -1
  12. package/dist/endpoint-verbs.js +1 -1
  13. package/dist/endpoint-verbs.js.map +1 -1
  14. package/dist/endpoint.d.ts +156 -5
  15. package/dist/endpoint.d.ts.map +1 -1
  16. package/dist/endpoint.js +363 -16
  17. package/dist/endpoint.js.map +1 -1
  18. package/dist/lease.d.ts +22 -0
  19. package/dist/lease.d.ts.map +1 -1
  20. package/dist/lease.js +14 -1
  21. package/dist/lease.js.map +1 -1
  22. package/dist/membership-feed.d.ts +24 -1
  23. package/dist/membership-feed.d.ts.map +1 -1
  24. package/dist/membership-feed.js +61 -3
  25. package/dist/membership-feed.js.map +1 -1
  26. package/dist/provision.d.ts.map +1 -1
  27. package/dist/provision.js +12 -1
  28. package/dist/provision.js.map +1 -1
  29. package/dist/run-driver-grants.d.ts +10 -0
  30. package/dist/run-driver-grants.d.ts.map +1 -1
  31. package/dist/run-driver-grants.js +14 -0
  32. package/dist/run-driver-grants.js.map +1 -1
  33. package/dist/run-host.d.ts +2 -1
  34. package/dist/run-host.d.ts.map +1 -1
  35. package/dist/run-journal.d.ts +4 -0
  36. package/dist/run-journal.d.ts.map +1 -1
  37. package/dist/run-journal.js +80 -17
  38. package/dist/run-journal.js.map +1 -1
  39. package/dist/secret-store.d.ts +11 -5
  40. package/dist/secret-store.d.ts.map +1 -1
  41. package/dist/secret-store.js +16 -9
  42. package/dist/secret-store.js.map +1 -1
  43. package/dist/subjects.d.ts +8 -0
  44. package/dist/subjects.d.ts.map +1 -1
  45. package/dist/subjects.js +8 -0
  46. package/dist/subjects.js.map +1 -1
  47. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -23,7 +23,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
23
23
  import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
24
24
  import { openDeliveryRegistry } from "./lease.js";
25
25
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.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, 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";
27
27
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
28
28
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
29
29
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -147,6 +147,10 @@ export class CotalEndpoint extends EventEmitter {
147
147
  aclKv;
148
148
  deliveryKv;
149
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;
150
154
  membershipFeedKv;
151
155
  /** Caller-owned membership watches survive a connection rebuild as INTENT. Their iterators are
152
156
  * connection-scoped and are stopped/re-created around the epoch swap. */
@@ -158,6 +162,9 @@ export class CotalEndpoint extends EventEmitter {
158
162
  /** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
159
163
  * daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
160
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;
161
168
  plane3;
162
169
  /** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
163
170
  channelConfigs = new Map();
@@ -476,8 +483,44 @@ export class CotalEndpoint extends EventEmitter {
476
483
  const claims = decodeBearerPrincipal(bearer);
477
484
  if (claims.owner !== this.owner || claims.actor !== this.actor)
478
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
+ }
479
517
  this.currentBearer = bearer;
480
- 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);
481
524
  }
482
525
  catch (e) {
483
526
  if (initial)
@@ -538,6 +581,35 @@ export class CotalEndpoint extends EventEmitter {
538
581
  : "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
539
582
  return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
540
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
+ }
541
613
  /** The disposable-preflight connect bound for the EXPLICIT reload proof (D5 class-2 adoption). A
542
614
  * rogue or unreachable candidate must resolve well UNDER the manager's delivery-admin request
543
615
  * bound, so this stays a few seconds and never blocks the responder. */
@@ -563,8 +635,12 @@ export class CotalEndpoint extends EventEmitter {
563
635
  const id = idFromCreds(creds);
564
636
  if (id !== this.connId)
565
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);
566
642
  this.currentCreds = creds;
567
- this.armCredsRefresh(credsRenewalDelayMs(creds));
643
+ this.armCredsRefresh(delay);
568
644
  const { iat, exp } = credsClaims(creds);
569
645
  return { iat, exp };
570
646
  }
@@ -711,6 +787,7 @@ export class CotalEndpoint extends EventEmitter {
711
787
  // lacking a numeric `exp`, and that throw must not leave currentCreds flipped to a candidate the
712
788
  // authenticator would present on the next reconnect (a post-preflight validation failure is a no-op).
713
789
  const delay = credsRenewalDelayMs(candidate);
790
+ CotalEndpoint.assertRenewableGeneration(candidate, this.currentCreds, delay);
714
791
  this.currentCreds = candidate;
715
792
  this.armCredsRefresh(delay);
716
793
  return credsClaims(candidate);
@@ -1085,6 +1162,7 @@ export class CotalEndpoint extends EventEmitter {
1085
1162
  this.membershipFeedKv = undefined;
1086
1163
  this.deliveryKv = undefined;
1087
1164
  this.managerLeaseKv = undefined;
1165
+ this.daemonRenewalLeaseRevision = undefined;
1088
1166
  // Handles that armDeliveryControl created on a previous connection: null them so a rebind
1089
1167
  // does not carry a dead protocol's subs forward. Best-effort unsubscribe first (the sub is
1090
1168
  // dead with its connection either way; unsubscribing an already-dead sub is a noop).
@@ -1234,6 +1312,7 @@ export class CotalEndpoint extends EventEmitter {
1234
1312
  // The manager's liveness-lease handle too: left bound to the old connection, every renew and
1235
1313
  // re-read after a reconnect times out, and the manager reports its lease unknown for good.
1236
1314
  this.managerLeaseKv = undefined;
1315
+ this.daemonRenewalLeaseRevision = undefined;
1237
1316
  // This is an application-requested epoch teardown, not a transient nats.js blip. The old
1238
1317
  // status iterator is now stale by construction and its close is epoch-dropped, so this line is
1239
1318
  // the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
@@ -3115,8 +3194,25 @@ export class CotalEndpoint extends EventEmitter {
3115
3194
  this.deliveryKv ??= await openDeliveryRegistry(this.nc, this.space);
3116
3195
  return this.deliveryKv;
3117
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();
3118
3202
  encodeLease(ready) {
3119
- 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;
3120
3216
  }
3121
3217
  /** Acquire the single-flight delivery lease for a shard via an ATOMIC CAS create, marked NOT-ready.
3122
3218
  * THROWS if a live lease exists — a loud refusal-to-bind (the daemon exits), never a retry, so two
@@ -3132,6 +3228,18 @@ export class CotalEndpoint extends EventEmitter {
3132
3228
  async markDeliveryLeaseReady(shardIndex, revision) {
3133
3229
  return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
3134
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
+ }
3135
3243
  /** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
3136
3244
  * the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
3137
3245
  * the daemon should exit). */
@@ -3139,25 +3247,54 @@ export class CotalEndpoint extends EventEmitter {
3139
3247
  return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
3140
3248
  }
3141
3249
  /** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
3142
- * effort a crash just lets the bucket TTL expire it). */
3143
- 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;
3144
3271
  try {
3145
- await (await this.deliveryRegistry()).delete(leaseKey(shardIndex));
3272
+ await (await this.deliveryRegistry()).delete(leaseKey(shardIndex), { previousSeq: revision });
3146
3273
  }
3147
3274
  catch {
3148
3275
  // Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
3149
- // 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.
3150
3278
  }
3151
3279
  }
3152
3280
  /** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
3153
3281
  * READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
3154
3282
  * under its own cred, which holds lease-bucket read but no write). */
3155
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) {
3156
3293
  const e = await (await this.deliveryRegistry()).get(leaseKey(shardIndex));
3157
3294
  if (!e || e.operation === "DEL" || e.operation === "PURGE")
3158
3295
  return undefined;
3159
3296
  try {
3160
- return e.json();
3297
+ return { info: e.json(), revision: e.revision };
3161
3298
  }
3162
3299
  catch {
3163
3300
  return undefined;
@@ -3198,6 +3335,53 @@ export class CotalEndpoint extends EventEmitter {
3198
3335
  }
3199
3336
  return this.managerLeaseKv;
3200
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
+ }
3201
3385
  encodeManagerLease(info) {
3202
3386
  return new TextEncoder().encode(JSON.stringify(info));
3203
3387
  }
@@ -3529,7 +3713,28 @@ export class CotalEndpoint extends EventEmitter {
3529
3713
  * id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
3530
3714
  * is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
3531
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
+ }
3532
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" };
3533
3738
  const caller = req.from.id;
3534
3739
  const args = req.args ?? {};
3535
3740
  if (req.op === "durableJoin")
@@ -3548,11 +3753,18 @@ export class CotalEndpoint extends EventEmitter {
3548
3753
  catch (e) {
3549
3754
  return { ok: false, error: e.message };
3550
3755
  }
3551
- 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 } };
3552
3764
  }
3553
3765
  return { ok: false, error: `op "${req.op}" not supported on the delivery control service` };
3554
3766
  }
3555
- /** 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).
3556
3768
  * Returns the channel on success or a ControlReply error to short-circuit. */
3557
3769
  checkDurableChannelArg(args, op) {
3558
3770
  const channel = typeof args.channel === "string" ? args.channel.trim() : "";
@@ -3735,6 +3947,85 @@ export class CotalEndpoint extends EventEmitter {
3735
3947
  const max = this.nc?.info?.max_payload ?? 1_048_576;
3736
3948
  return Math.max(1, Math.floor(max * 0.9));
3737
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
+ }
3738
4029
  /** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
3739
4030
  * cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
3740
4031
  * the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
@@ -3743,6 +4034,11 @@ export class CotalEndpoint extends EventEmitter {
3743
4034
  async armPlane3() {
3744
4035
  if (!this.plane3 || !this.js)
3745
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;
3746
4042
  await this.manager(); // the manager runs consume:false, so this.jsm is lazy — ensure it
3747
4043
  this.armDeliveryControl();
3748
4044
  await this.runFanout();
@@ -3783,6 +4079,9 @@ export class CotalEndpoint extends EventEmitter {
3783
4079
  * feed's rw connection, and reply with proof (identities + the adopted JWT windows) — or a
3784
4080
  * structured failure (e.g. the file was never re-signed), never a silent partial. */
3785
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" };
3786
4085
  if (req.op === "reloadCreds") {
3787
4086
  // The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
3788
4087
  // component. A missing entry means "no expectation" (the passive backstop still adopts).
@@ -3911,6 +4210,13 @@ export class CotalEndpoint extends EventEmitter {
3911
4210
  * members within interval; `live` channel → `@mention` targets authorized to read it (ACL only).
3912
4211
  * Members KV is scanned FRESH per message (no cache — red-team BLOCKER-1 catch-up correctness). */
3913
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;
3914
4220
  const parsed = parseSubject(m.subject);
3915
4221
  if (!parsed || parsed.kind !== "chat") {
3916
4222
  m.ack();
@@ -3931,6 +4237,12 @@ export class CotalEndpoint extends EventEmitter {
3931
4237
  } // authenticity (owner must be a real principal, not an old-shape alias)
3932
4238
  const seq = m.seq;
3933
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;
3934
4246
  if ((await this.deliveryClassFresh(channel)) === "durable") {
3935
4247
  for (const rec of await listMembers(await this.membersRegistry(), { channel })) {
3936
4248
  if (rec.owner === msg.from.id)
@@ -3941,6 +4253,12 @@ export class CotalEndpoint extends EventEmitter {
3941
4253
  // retired lifecycle's inbox, never the alias's new occupant (SPEC 13.1 cross-plane scoping).
3942
4254
  // Store the AUTHENTICATED-channel copy (main's normalization): the durable frame validates
3943
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;
3944
4262
  await this.publishDinbox(rec.owner, rec.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "durable-channel", generation: rec.generation });
3945
4263
  }
3946
4264
  }
@@ -3962,9 +4280,16 @@ export class CotalEndpoint extends EventEmitter {
3962
4280
  }
3963
4281
  if (!row || !channelInAllow(row.allowSubscribe, channel))
3964
4282
  continue; // @mention can't bypass the read ACL
4283
+ if (!this.plane3MayAct())
4284
+ return; // same rule on the live-mention path
3965
4285
  await this.publishDinbox(owner, row.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "live-mention", generation: 0 });
3966
4286
  }
3967
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;
3968
4293
  m.ack();
3969
4294
  }
3970
4295
  /** Trusted-reader loop: bind the single privileged `reader` durable over `dinbox.>` and re-authorize
@@ -3998,6 +4323,10 @@ export class CotalEndpoint extends EventEmitter {
3998
4323
  * revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
3999
4324
  * has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
4000
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;
4001
4330
  const pr = parseDinboxPrincipal(m.subject);
4002
4331
  if (!pr) {
4003
4332
  m.ack();
@@ -4047,6 +4376,12 @@ export class CotalEndpoint extends EventEmitter {
4047
4376
  return;
4048
4377
  }
4049
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;
4050
4385
  try {
4051
4386
  // DLV has no original chat subject, so preserve the channel the trusted fan-out reader derived
4052
4387
  // from CHAT. Never let the publisher-controlled payload label choose connector attention.
@@ -4077,6 +4412,12 @@ export class CotalEndpoint extends EventEmitter {
4077
4412
  m.nak(2000);
4078
4413
  return;
4079
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;
4080
4421
  m.ack();
4081
4422
  }
4082
4423
  /** Agent-side: bind + pump our pre-created Plane-3 DELIVER durable (`dlv_<id>`). Every message here is
@@ -5031,9 +5372,9 @@ export class CotalEndpoint extends EventEmitter {
5031
5372
  * every window, one consumer create and one warning per TTL for as long as the mesh was empty.
5032
5373
  *
5033
5374
  * A REGISTERING observer (the manager) is itself one of the keys that should be there. An
5034
- * empty bucket under it means the bucket was wiped since its last heartbeat (the netcup
5375
+ * empty bucket under it means the bucket was wiped since its last heartbeat (the stream
5035
5376
  * recreation), and the same wipe took every peer's record: their absence says the bucket is
5036
- * new, not that they left. rev-1421-gpt reproduced the previous behaviour at default timing:
5377
+ * new, not that they left. a reviewer reproduced the previous behaviour at default timing:
5037
5378
  * the rebind landed ~0.9s after the recreation, the observer marked every peer AND ITSELF
5038
5379
  * offline, and held the view current for up to one heartbeat, a false verdict `cotal ps`
5039
5380
  * would print as `mesh offline`. So a registering observer re-publishes its own record NOW,
@@ -5186,7 +5527,7 @@ export class CotalEndpoint extends EventEmitter {
5186
5527
  if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
5187
5528
  this.emitPresenceViewIfChanged();
5188
5529
  // Staying stale is the right verdict for a held link (#1045), and the wrong END STATE when
5189
- // the transport is up and the watch's own consumer is what died. Measured on netcup
5530
+ // the transport is up and the watch's own consumer is what died. Measured on a live deployment
5190
5531
  // 2026-09-09: the presence stream was deleted and recreated, its sequence restarted, and
5191
5532
  // every observer's ORDERED consumer re-created itself at the OLD start sequence (nats.js
5192
5533
  // 3.4.0 resets from its cursor). The broker kept sending idle heartbeats, so the client
@@ -5508,8 +5849,14 @@ export function wsServers(servers) {
5508
5849
  * probe dials; a ws(s) broker is by definition published through an HTTPS edge (CDN tunnel,
5509
5850
  * reverse proxy), where TLS + upgrade + INFO + the auth round-trip routinely exceeds 1s cold —
5510
5851
  * measured ~60% spurious "not reachable" against a Cloudflare-fronted broker. Callers passing an
5511
- * explicit `timeoutMs` are untouched. */
5512
- 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) {
5513
5860
  return wsServers(servers) ? 5000 : 1000;
5514
5861
  }
5515
5862
  /** Pick the dial function by SCHEME: `ws://`/`wss://` servers go through nats-core's