@cotal-ai/core 0.58.0 → 0.59.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 (94) hide show
  1. package/dist/acls.d.ts +7 -0
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +10 -0
  4. package/dist/acls.js.map +1 -1
  5. package/dist/auth-provider.d.ts +13 -1
  6. package/dist/auth-provider.d.ts.map +1 -1
  7. package/dist/auth-provider.js.map +1 -1
  8. package/dist/backup-config.d.ts +10 -2
  9. package/dist/backup-config.d.ts.map +1 -1
  10. package/dist/backup-config.js +12 -3
  11. package/dist/backup-config.js.map +1 -1
  12. package/dist/broker-tls.d.ts +15 -0
  13. package/dist/broker-tls.d.ts.map +1 -1
  14. package/dist/broker-tls.js +6 -6
  15. package/dist/broker-tls.js.map +1 -1
  16. package/dist/checkpoint-answer.d.ts +24 -0
  17. package/dist/checkpoint-answer.d.ts.map +1 -1
  18. package/dist/checkpoint-answer.js +45 -4
  19. package/dist/checkpoint-answer.js.map +1 -1
  20. package/dist/command.d.ts +4 -3
  21. package/dist/command.d.ts.map +1 -1
  22. package/dist/command.js +20 -4
  23. package/dist/command.js.map +1 -1
  24. package/dist/connector-setup.d.ts +27 -0
  25. package/dist/connector-setup.d.ts.map +1 -1
  26. package/dist/connector.d.ts +12 -0
  27. package/dist/connector.d.ts.map +1 -1
  28. package/dist/connector.js.map +1 -1
  29. package/dist/endpoint-reconcile.d.ts +5 -1
  30. package/dist/endpoint-reconcile.d.ts.map +1 -1
  31. package/dist/endpoint-reconcile.js +43 -21
  32. package/dist/endpoint-reconcile.js.map +1 -1
  33. package/dist/endpoint-service.d.ts +25 -0
  34. package/dist/endpoint-service.d.ts.map +1 -1
  35. package/dist/endpoint-service.js +25 -25
  36. package/dist/endpoint-service.js.map +1 -1
  37. package/dist/endpoint.d.ts +105 -8
  38. package/dist/endpoint.d.ts.map +1 -1
  39. package/dist/endpoint.js +349 -43
  40. package/dist/endpoint.js.map +1 -1
  41. package/dist/evict.d.ts +27 -0
  42. package/dist/evict.d.ts.map +1 -1
  43. package/dist/evict.js +95 -38
  44. package/dist/evict.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/issued-authority.d.ts +0 -2
  50. package/dist/issued-authority.d.ts.map +1 -1
  51. package/dist/issued-authority.js +0 -2
  52. package/dist/issued-authority.js.map +1 -1
  53. package/dist/launch-artifacts.d.ts +42 -0
  54. package/dist/launch-artifacts.d.ts.map +1 -0
  55. package/dist/launch-artifacts.js +155 -0
  56. package/dist/launch-artifacts.js.map +1 -0
  57. package/dist/lease.d.ts +5 -0
  58. package/dist/lease.d.ts.map +1 -1
  59. package/dist/lease.js.map +1 -1
  60. package/dist/liveness.d.ts +180 -0
  61. package/dist/liveness.d.ts.map +1 -0
  62. package/dist/liveness.js +114 -0
  63. package/dist/liveness.js.map +1 -0
  64. package/dist/provision.d.ts +24 -11
  65. package/dist/provision.d.ts.map +1 -1
  66. package/dist/provision.js +127 -42
  67. package/dist/provision.js.map +1 -1
  68. package/dist/remote-manager-authority.d.ts +73 -2
  69. package/dist/remote-manager-authority.d.ts.map +1 -1
  70. package/dist/remote-manager-authority.js +104 -0
  71. package/dist/remote-manager-authority.js.map +1 -1
  72. package/dist/run-host.d.ts +66 -2
  73. package/dist/run-host.d.ts.map +1 -1
  74. package/dist/run-journal.d.ts +14 -6
  75. package/dist/run-journal.d.ts.map +1 -1
  76. package/dist/run-journal.js +14 -14
  77. package/dist/run-journal.js.map +1 -1
  78. package/dist/run-record.d.ts +5 -5
  79. package/dist/runtime.d.ts +6 -1
  80. package/dist/runtime.d.ts.map +1 -1
  81. package/dist/schema-profile.d.ts.map +1 -1
  82. package/dist/schema-profile.js +4 -3
  83. package/dist/schema-profile.js.map +1 -1
  84. package/dist/streams.d.ts +22 -16
  85. package/dist/streams.d.ts.map +1 -1
  86. package/dist/streams.js +26 -6
  87. package/dist/streams.js.map +1 -1
  88. package/dist/subjects.d.ts +45 -5
  89. package/dist/subjects.d.ts.map +1 -1
  90. package/dist/subjects.js +52 -0
  91. package/dist/subjects.js.map +1 -1
  92. package/dist/types.d.ts +4 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { createConnection } from "node:net";
4
4
  import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
5
5
  import { wsconnect } from "@nats-io/nats-core";
6
+ import { parseLivenessAnswer, responderFromLease, responderFromProbe, isLivenessPlane, LIVENESS_PLANES, } from "./liveness.js";
6
7
  import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
7
8
  import { requireBrokerFloor } from "./broker-floor.js";
8
9
  import { inspectCredHealth } from "./provision.js";
@@ -15,16 +16,17 @@ import { readAcceptedRow } from "./issued-authority.js";
15
16
  import { liveKvEntries } from "./kv-scan.js";
16
17
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
17
18
  import { assertValidName } from "./resolve.js";
18
- 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";
19
+ import { EVICT_PRINCIPALS_MAX } from "./evict.js";
20
+ import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY, PRESENCE_STORAGE } from "./streams.js";
19
21
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
20
22
  import {} from "@nats-io/jetstream";
21
23
  import { Kvm } from "@nats-io/kv";
22
24
  import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
23
25
  import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, listLifecycleMemberChannels, durableEligible, StaleMembershipWrite, } from "./members.js";
24
- import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
26
+ import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
25
27
  import { openDeliveryRegistry } from "./lease.js";
26
28
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
27
- 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";
29
+ import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, livenessSubject, livenessServeFilter, 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";
28
30
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
29
31
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
30
32
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -187,12 +189,12 @@ export class CotalEndpoint extends EventEmitter {
187
189
  * {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
188
190
  deliveryServeSub;
189
191
  deliveryAdminServeSub;
190
- /** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
191
- * daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
192
- * re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
193
192
  /** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
194
193
  * Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
195
194
  plane3Quiesced = false;
195
+ /** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
196
+ * daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
197
+ * re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
196
198
  plane3;
197
199
  /** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
198
200
  channelConfigs = new Map();
@@ -206,6 +208,11 @@ export class CotalEndpoint extends EventEmitter {
206
208
  * `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
207
209
  histLock = Promise.resolve();
208
210
  subs = [];
211
+ /** The liveness responders {@link serveLiveness} was asked for, by plane. This is INTENT, re-bound
212
+ * by {@link bindLiveness} on every (re)connect: a rebuild closes the old connection and every
213
+ * subscription on it, so a responder bound once would leave its plane to the broker's
214
+ * no-responders answer, which a peer grades `unbound`, while this endpoint is connected and serving. */
215
+ livenessResponders = new Map();
209
216
  streamMsgs = [];
210
217
  /** Per-channel native core subscriptions (SPEC v0.3) — the manager-free live read path for boot +
211
218
  * runtime channels (there is no per-instance chat durable). Keyed by channel so leave unsubscribes
@@ -289,6 +296,8 @@ export class CotalEndpoint extends EventEmitter {
289
296
  presenceRebindAt = 0;
290
297
  status = "idle";
291
298
  activity;
299
+ /** Last harness-reported work progress. Carried by the next heartbeat, never published per event. */
300
+ activeAt;
292
301
  condition;
293
302
  /** Advances on every condition change so an older in-flight put cannot be the final KV state. */
294
303
  conditionRevision = 0;
@@ -1030,7 +1039,7 @@ export class CotalEndpoint extends EventEmitter {
1030
1039
  // OPENs it (it's pre-created at `cotal up`; KV stream-create is denied to agents).
1031
1040
  this.kv = this.authed
1032
1041
  ? await kvm.open(presenceBucket(this.space))
1033
- : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs });
1042
+ : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
1034
1043
  }
1035
1044
  if (this.doWatch) {
1036
1045
  await this.startPresenceWatch();
@@ -1118,6 +1127,10 @@ export class CotalEndpoint extends EventEmitter {
1118
1127
  // endpoint hosts it. The first arm comes from startPlane3 (after start()); this re-binds the loops
1119
1128
  // a reconnect's clearConnectionScoped() tore down, so a broker blip doesn't silently kill the backstop.
1120
1129
  await this.armPlane3();
1130
+ // Re-bind the liveness responders on this connection, for armPlane3's reason: the rebuild closed
1131
+ // the old connection and the responders with it.
1132
+ for (const plane of this.livenessResponders.keys())
1133
+ this.bindLiveness(plane);
1121
1134
  // Bound and live — covers initial start, manual reconnect, AND background self-heal (every
1122
1135
  // path lands here). The single signal an in-process agent's connected flag tracks.
1123
1136
  //
@@ -2240,6 +2253,12 @@ export class CotalEndpoint extends EventEmitter {
2240
2253
  this.status = status;
2241
2254
  await this.publishPresence();
2242
2255
  }
2256
+ /** Record harness-reported work progress as `activeAt`. It writes nothing itself: the next heartbeat
2257
+ * carries it, so a stream of events costs no presence writes. */
2258
+ noteActivity(at = Date.now()) {
2259
+ if (Number.isFinite(at) && at > (this.activeAt ?? 0))
2260
+ this.activeAt = at;
2261
+ }
2243
2262
  /** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
2244
2263
  async setCondition(condition) {
2245
2264
  this.condition = condition ?? undefined;
@@ -3407,8 +3426,11 @@ export class CotalEndpoint extends EventEmitter {
3407
3426
  * daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
3408
3427
  * processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
3409
3428
  leaseIncarnation = randomUUID();
3410
- encodeLease(ready) {
3411
- return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, incarnation: this.leaseIncarnation, since: Date.now(), ready }));
3429
+ /** When this endpoint last WON each shard's lease create. Every later write of that row carries it
3430
+ * unchanged, while `since` is re-stamped. See {@link DeliveryLeaseInfo.acquiredAt}. */
3431
+ leaseAcquiredAt = new Map();
3432
+ encodeLease(shardIndex, ready, acquiredAt = this.leaseAcquiredAt.get(shardIndex)) {
3433
+ return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, incarnation: this.leaseIncarnation, acquiredAt, since: Date.now(), ready }));
3412
3434
  }
3413
3435
  /** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
3414
3436
  * just failed has to answer before it decides whether it still owns the shard.
@@ -3428,13 +3450,17 @@ export class CotalEndpoint extends EventEmitter {
3428
3450
  * freeing a re-acquire. Acquired BEFORE binding (single-flight gate); {@link markDeliveryLeaseReady}
3429
3451
  * flips it ready AFTER the loops + `ctl.delivery` are bound. Returns the lease revision. */
3430
3452
  async acquireDeliveryLease(shardIndex) {
3431
- return (await this.deliveryRegistry()).create(leaseKey(shardIndex), this.encodeLease(false));
3453
+ // Recorded only once the create wins: a refused create leaves the held row, and so its time, as it was.
3454
+ const acquiredAt = Date.now();
3455
+ const revision = await (await this.deliveryRegistry()).create(leaseKey(shardIndex), this.encodeLease(shardIndex, false, acquiredAt));
3456
+ this.leaseAcquiredAt.set(shardIndex, acquiredAt);
3457
+ return revision;
3432
3458
  }
3433
3459
  /** Flip the held lease to READY (CAS `kv.update`) AFTER `startPlane3` has bound the loops + the
3434
3460
  * `ctl.delivery` responder — so "lease ready" proves the responder is up, not just that the slot was
3435
3461
  * claimed. Returns the new revision. */
3436
3462
  async markDeliveryLeaseReady(shardIndex, revision) {
3437
- return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
3463
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
3438
3464
  }
3439
3465
  /** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
3440
3466
  * holder that has UNBOUND its loops and control responder but has not given up the shard.
@@ -3446,13 +3472,13 @@ export class CotalEndpoint extends EventEmitter {
3446
3472
  * the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
3447
3473
  * should be invited in; what is being withdrawn is only the claim to be answering. */
3448
3474
  async markDeliveryLeaseNotReady(shardIndex, revision) {
3449
- return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
3475
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, false), revision);
3450
3476
  }
3451
3477
  /** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
3452
3478
  * the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
3453
3479
  * the daemon should exit). */
3454
3480
  async renewDeliveryLease(shardIndex, revision) {
3455
- return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
3481
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
3456
3482
  }
3457
3483
  /** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
3458
3484
  * effort, a crash just lets the bucket TTL expire it).
@@ -3997,7 +4023,7 @@ export class CotalEndpoint extends EventEmitter {
3997
4023
  continue;
3998
4024
  }
3999
4025
  const parsed = parseSubject(m.subject);
4000
- if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
4026
+ if (!isRecord(msg) || !parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
4001
4027
  continue;
4002
4028
  await this.publishDinbox(owner, lifecycleUid, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
4003
4029
  copied++;
@@ -4022,13 +4048,9 @@ export class CotalEndpoint extends EventEmitter {
4022
4048
  async startPlane3(aclFor, opts = {}) {
4023
4049
  if (!this.js)
4024
4050
  throw new Error("endpoint not started");
4025
- this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity, onDeliveryCredsAdopted: opts.onDeliveryCredsAdopted };
4051
+ this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, evictPrincipals: opts.evictPrincipals, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity, onDeliveryCredsAdopted: opts.onDeliveryCredsAdopted, startupComplete: opts.startupComplete };
4026
4052
  await this.armPlane3();
4027
4053
  }
4028
- /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
4029
- * id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
4030
- * is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
4031
- * truth, no in-memory ledger to drift). */
4032
4054
  /** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
4033
4055
  *
4034
4056
  * Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
@@ -4046,6 +4068,10 @@ export class CotalEndpoint extends EventEmitter {
4046
4068
  plane3MayAct() {
4047
4069
  return !this.plane3Quiesced;
4048
4070
  }
4071
+ /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
4072
+ * id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
4073
+ * is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
4074
+ * truth, no in-memory ledger to drift). */
4049
4075
  async handleDeliveryControl(req) {
4050
4076
  // FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
4051
4077
  // this shard's control rail while the winner is already READY.
@@ -4388,6 +4414,216 @@ export class CotalEndpoint extends EventEmitter {
4388
4414
  }
4389
4415
  this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
4390
4416
  }
4417
+ // ---- the peer-readable liveness surface (#1577) --------------------------
4418
+ /** Bind this endpoint as the liveness RESPONDER for one plane, answering presence and nothing
4419
+ * else to any credentialed peer that asks (`live.<plane>.<owner>.<actor>`).
4420
+ *
4421
+ * THE HANDLER IS THE PRIVACY BOUNDARY, and that is the entire reason this is a request/reply
4422
+ * probe rather than a KV read grant. `readState` returns the responder's OWN verdict, derived
4423
+ * from the lease it can already read; the LEASE ROW NEVER LEAVES THIS PROCESS. A peer therefore
4424
+ * learns one enum about one plane and cannot learn the holder, the workspace root, the pid, the
4425
+ * instance id, the runtime, or that any of those exist. Handing a peer the manager bucket instead
4426
+ * would have handed it the operator's filesystem path and a pid to signal.
4427
+ *
4428
+ * `queue`-grouped by plane, so several manager instances in one space answer a probe ONCE rather
4429
+ * than N times. A queue group is correct here and would be wrong on a roster-style surface: the
4430
+ * question is "is ANY responder bound", which any one member can answer.
4431
+ *
4432
+ * THE ANSWER NAMES WHICH RESPONDER GAVE IT (`instance`), and that field exists because the queue
4433
+ * group alone leaves a real ambiguity. Manager instances coexist per instance id by design, each
4434
+ * member answers only about ITSELF, and the group delivers one probe to an arbitrary member. So
4435
+ * when two instances hold opposite verdicts, identical probes alternate between them, and without
4436
+ * a discriminator the two answers are indistinguishable from one instance changing state. With
4437
+ * the field, a caller that probes more than once can see that two DIFFERENT responders answered
4438
+ * and that the verdicts disagree. The field is the responder's own endpoint-scoped instance
4439
+ * token, never the lease row's `instanceId`, its holder, its pid or its root: see
4440
+ * {@link LivenessAnswer} for why none of those may cross the wire.
4441
+ *
4442
+ * ONE PROBE STILL SAMPLES ONE MEMBER. This does not aggregate N instances, and a single probe
4443
+ * cannot report a split; it makes the split OBSERVABLE to a caller that asks again, where before
4444
+ * it was not observable at all.
4445
+ *
4446
+ * BOUNDED REPLY, for the same confused-deputy reason `serveControl` documents: this responder
4447
+ * holds a wildcard publish grant over `live.<plane>.*.*.reply.>`, so without the bound check an
4448
+ * authenticated caller could name a PEER's reply lane as its reply target and have us publish
4449
+ * into it. The broker does not permission-check a requester's embedded reply subject; we do.
4450
+ *
4451
+ * A HANDLER THAT THROWS ANSWERS `unknown`, never silence and never health. Silence would reach
4452
+ * the prober as a timeout, which it correctly grades `unknown` anyway — but only after burning
4453
+ * the full deadline, and an operator staring at a hung probe learns less than one told plainly
4454
+ * that the axis could not be checked.
4455
+ *
4456
+ * THE BINDING IS KEPT AS INTENT and re-bound on every (re)connect, so a reconnect does not leave
4457
+ * the plane answered by the broker's no-responders status while this endpoint is serving. A second
4458
+ * call for the same plane replaces the first. */
4459
+ serveLiveness(plane, readState) {
4460
+ if (!this.nc)
4461
+ throw new Error("endpoint not started");
4462
+ const held = this.livenessResponders.get(plane);
4463
+ if (held)
4464
+ held.readState = readState;
4465
+ else
4466
+ this.livenessResponders.set(plane, { readState });
4467
+ this.bindLiveness(plane);
4468
+ }
4469
+ /** (Re)bind one plane's liveness responder on the CURRENT connection, dropping the stale sub first
4470
+ * as {@link armDeliveryControl} does. */
4471
+ bindLiveness(plane) {
4472
+ const held = this.livenessResponders.get(plane);
4473
+ if (!held || !this.nc)
4474
+ return;
4475
+ if (held.sub) {
4476
+ try {
4477
+ held.sub.unsubscribe();
4478
+ }
4479
+ catch { /* dead with the old connection */ }
4480
+ const i = this.subs.indexOf(held.sub);
4481
+ if (i >= 0)
4482
+ this.subs.splice(i, 1);
4483
+ }
4484
+ const { readState } = held;
4485
+ const sub = this.nc.subscribe(livenessServeFilter(this.space, plane), { queue: `live.${plane}` });
4486
+ held.sub = sub;
4487
+ this.subs.push(sub);
4488
+ // The responder token this bind answers under: AN OPAQUE VALUE MINTED HERE, and deliberately
4489
+ // not any name this process already has. It is what makes "which responder answered" askable
4490
+ // without making "who is this responder" answerable — the two properties the surface has to
4491
+ // hold at once. The endpoint's own `card.id` would be its principal, the lease row's
4492
+ // `instanceId` is a field of a bucket an agent holds no grant on, and either would turn a
4493
+ // presence probe into an identity read for every credentialed peer in the space. A fresh
4494
+ // random token correlates with nothing but this plane's other answers, which is the whole job:
4495
+ // two answers carrying two different tokens came from two different responders.
4496
+ //
4497
+ // Per BIND rather than per process, so a responder that goes away and comes back answers under
4498
+ // a new token. That is the honest reading: a caller comparing answers across a restart is
4499
+ // comparing two different incarnations, and a token that survived the restart would say
4500
+ // otherwise. A re-bind after a reconnect is such a return: the plane had no responder here for
4501
+ // the gap.
4502
+ const instance = randomUUID();
4503
+ void (async () => {
4504
+ for await (const m of sub) {
4505
+ // Sender-bound reply guard. Drop silently rather than publishing somewhere else: a reply
4506
+ // aimed outside the sender's own subtree is not a request we can answer safely.
4507
+ //
4508
+ // REPORTED ON `warning`, NOT ON `error`, AND THAT IS A SAFETY PROPERTY RATHER THAN A CHOICE
4509
+ // OF CHANNEL. A rejected probe is a condition this responder is already surviving: it drops
4510
+ // the frame and keeps serving. `error` cannot carry such a notice on this class, because
4511
+ // Node's `EventEmitter` RETHROWS an `error` emit that has no listener attached, so the
4512
+ // notice would end the process of any embedder that had not attached one — and the plane
4513
+ // would then genuinely be unbound, which a peer's next probe reports as `unbound`. The
4514
+ // condition would have manufactured the state it described. `warning` is observable and
4515
+ // never fatal without a listener (see {@link emitRecoverable} and #891, where retry notices
4516
+ // on `error` killed hosts the endpoint intended to keep running).
4517
+ //
4518
+ // Since ANY credentialed peer may publish a probe, and the reply target inside it is chosen
4519
+ // by that peer and not permission-checked by the broker, reaching this branch is a peer's
4520
+ // decision, not an operator's. Safety here must therefore hold with no listener attached,
4521
+ // which is what CELL F1 runs.
4522
+ if (!m.reply || !m.reply.startsWith(`${m.subject}.reply.`)) {
4523
+ this.emitRecoverable(new Error(`rejected liveness probe on ${m.subject}: reply target "${m.reply ?? "(none)"}" is not under the sender's own reply subtree`));
4524
+ continue;
4525
+ }
4526
+ let responder;
4527
+ try {
4528
+ responder = await readState();
4529
+ }
4530
+ catch {
4531
+ responder = "unknown";
4532
+ }
4533
+ const answer = { plane, responder, instance };
4534
+ try {
4535
+ m.respond(JSON.stringify(answer));
4536
+ }
4537
+ catch { /* the requester is gone */ }
4538
+ }
4539
+ // The loop itself ends only on a SUBSCRIPTION-level fault (the connection went away), which
4540
+ // is not something a peer's probe can cause: the guard above `continue`s, `readState` is
4541
+ // caught, and the respond is caught. So this arm stays on `error` — it reports that this
4542
+ // responder has stopped answering at all, which is a fault an embedder should not be able to
4543
+ // miss, and no credentialed peer can reach it.
4544
+ })().catch((e) => this.emit("error", e));
4545
+ }
4546
+ /** Bind the liveness responder for the DELIVERY plane, grading itself from its own shard-0 lease
4547
+ * through the shared classifier. The daemon is the one process that can answer this honestly: it
4548
+ * knows its own incarnation, so a predecessor's `ready:true` corpse in the bucket classifies
4549
+ * `stale` rather than as its own health.
4550
+ *
4551
+ * It reads the lease at PROBE time rather than caching a flag set at bind time. A cached flag
4552
+ * would answer `bound` for as long as this process lived, including after it had lost the shard
4553
+ * and stood down — a responder that reports its intent instead of its state, which is the whole
4554
+ * family of bug this issue is about. */
4555
+ serveDeliveryLiveness(shardIndex = 0) {
4556
+ this.serveLiveness("delivery", async () => responderFromLease(await this.readDeliveryLease(shardIndex), this.card.id));
4557
+ }
4558
+ /** Ask whether a responder is bound for `plane`, as a credentialed peer that need not own it.
4559
+ *
4560
+ * EVERY WAY THIS CAN END IS CLASSIFIED, and the classification lives in ONE place
4561
+ * ({@link responderFromProbe}) so no call site can invent its own mapping. Only the broker's own
4562
+ * no-responders 503 becomes `unbound`. A timeout, a permission refusal, a transport failure and
4563
+ * an unreadable reply ALL become `unknown`, because each is a failure to find out, and this
4564
+ * issue exists because failures to find out were being reported as findings.
4565
+ *
4566
+ * `noMux` with a named reply subject is required rather than stylistic: the muxed inbox would
4567
+ * swallow the 503 into an ordinary timeout, and the 503 is the only outcome that carries a
4568
+ * positive verdict. Collapsing it would leave a probe that can say `bound` or `unknown` and
4569
+ * never `unbound` — an instrument that cannot report the failure it was built to report.
4570
+ *
4571
+ * The reply rides `<request>.reply.<uuid>`, the sender's own subtree, so the responder's bound
4572
+ * reply guard accepts it and the responder needs no broad inbox-publish grant to answer. */
4573
+ async probeLiveness(plane, timeoutMs = 2_000) {
4574
+ if (!isLivenessPlane(plane))
4575
+ // A closed set, refused loudly. Answering an unknown plane at all would make this a probe
4576
+ // that returns something for every input, which is indistinguishable from one that is not
4577
+ // measuring anything.
4578
+ throw new Error(`liveness: "${plane}" is not a plane this surface answers for (${[...LIVENESS_PLANES].join(", ")})`);
4579
+ if (!this.nc)
4580
+ throw new Error(this.notLiveMsg());
4581
+ const reqSubject = livenessSubject(this.space, plane, this.owner, this.actor);
4582
+ const reply = `${reqSubject}.reply.${randomUUID()}`;
4583
+ let outcome;
4584
+ let answered;
4585
+ let instance;
4586
+ try {
4587
+ const m = await this.nc.request(reqSubject, "", { timeout: timeoutMs, noMux: true, reply });
4588
+ let body;
4589
+ try {
4590
+ body = m.json();
4591
+ }
4592
+ catch {
4593
+ body = undefined;
4594
+ }
4595
+ const parsed = parseLivenessAnswer(body, plane);
4596
+ // A reply we cannot read is `malformed`, which grades `unknown`. It is NOT promoted to
4597
+ // `bound` on the strength of having replied at all: that promotion is the `pgrep` error,
4598
+ // where evidence a process exists was read as evidence it works.
4599
+ outcome = parsed ? "replied" : "malformed";
4600
+ answered = parsed?.responder;
4601
+ instance = parsed?.instance;
4602
+ }
4603
+ catch (e) {
4604
+ outcome = this.isNoResponders(e) ? "noResponders" : this.probeFailureOutcome(e);
4605
+ }
4606
+ // `instance` rides ONLY a reply that was read. Every other outcome means no responder answered,
4607
+ // so there is no responder to name, and a token attached to an `unbound` or an `unknown` would
4608
+ // claim one had. It stays absent on those arms by construction: nothing assigns it.
4609
+ const verdict = { plane, responder: responderFromProbe(outcome, answered) };
4610
+ return instance === undefined ? verdict : { ...verdict, instance };
4611
+ }
4612
+ /** Grade a failed probe that was NOT a no-responders answer. Both arms return an outcome that
4613
+ * maps to `unknown`; they are told apart so the distinction stays legible at the call site and so
4614
+ * a future caller can render the two differently (a refusal is about YOUR credential, a timeout
4615
+ * is about something being wedged). Neither may ever mean health. */
4616
+ probeFailureOutcome(e) {
4617
+ if (e instanceof AuthorizationError || e instanceof PermissionViolationError)
4618
+ return "refused";
4619
+ const name = e?.name;
4620
+ const msg = e?.message ?? "";
4621
+ if (name === "TimeoutError" || /timeout/i.test(msg))
4622
+ return "timeout";
4623
+ // Anything else is a transport or client failure: it says something about our link, not about
4624
+ // the plane, so it is graded like a refusal rather than guessed at.
4625
+ return "refused";
4626
+ }
4391
4627
  /** Serve one PRIVILEGED delivery-admin request (the D5 rail-split). The cred layer is the caller
4392
4628
  * boundary — only the supervisor profile can publish here — and `serveControl`'s sender check +
4393
4629
  * bounded reply still apply on top. `reloadCreds` is the class-2 renewal ADOPTION step: re-read
@@ -4399,6 +4635,12 @@ export class CotalEndpoint extends EventEmitter {
4399
4635
  if (!this.plane3MayAct())
4400
4636
  return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
4401
4637
  if (req.op === "reloadCreds") {
4638
+ // NOT BEFORE START-UP IS DONE (#2304). Both halves below would run: the delivery half commits
4639
+ // and schedules `nc.reconnect()`, which lands underneath start-up awaits still in flight on
4640
+ // this connection (the lease watch's consumer create times out and the daemon exits). Refuse
4641
+ // before either half acts, so nothing is adopted and the renewal owner records why.
4642
+ if (this.plane3?.startupComplete?.() === false)
4643
+ return { ok: false, error: "delivery: this daemon has not finished starting; nothing adopted - the next renewal pass or the daemon's 75% re-read adopts the re-signed creds" };
4402
4644
  // The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
4403
4645
  // component. A missing entry means "no expectation" (the passive backstop still adopts).
4404
4646
  const expected = (req.args?.expected ?? {});
@@ -4446,6 +4688,24 @@ export class CotalEndpoint extends EventEmitter {
4446
4688
  return { ok: false, error: e.message };
4447
4689
  }
4448
4690
  }
4691
+ if (req.op === "evictPrincipals") {
4692
+ // The same executor over a SET: one shared sweep instead of one request and one scan per
4693
+ // principal. The set is bounded so one request stays inside one sweep's work.
4694
+ if (!this.plane3?.evictPrincipals)
4695
+ return { ok: false, error: "evictPrincipals: no batch eviction executor wired on this daemon" };
4696
+ const raw = req.args?.principals;
4697
+ const principals = Array.isArray(raw) ? raw.map((p) => (typeof p === "string" ? p.trim() : "")) : [];
4698
+ if (principals.length === 0 || principals.some((p) => !p))
4699
+ return { ok: false, error: "evictPrincipals: principals must be a non-empty array of owner.actor dot-form principals" };
4700
+ if (principals.length > EVICT_PRINCIPALS_MAX || new Set(principals).size !== principals.length)
4701
+ return { ok: false, error: `evictPrincipals: principals must be distinct and at most ${EVICT_PRINCIPALS_MAX}` };
4702
+ try {
4703
+ return { ok: true, data: await this.plane3.evictPrincipals(principals) };
4704
+ }
4705
+ catch (e) {
4706
+ return { ok: false, error: e.message };
4707
+ }
4708
+ }
4449
4709
  if (req.op === "planeConnLiveness") {
4450
4710
  // The plane-claim liveness oracle (#29 HIGH 3): a CLOSED read-only verb — two claimed
4451
4711
  // scanner tuples in, two bound verdicts + sweep completeness out. The executor hook owns
@@ -4592,7 +4852,7 @@ export class CotalEndpoint extends EventEmitter {
4592
4852
  m.ack();
4593
4853
  return;
4594
4854
  }
4595
- if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
4855
+ if (!isRecord(msg) || !msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner)) {
4596
4856
  m.ack();
4597
4857
  return;
4598
4858
  } // authenticity (owner must be a real principal, not an old-shape alias)
@@ -4702,12 +4962,52 @@ export class CotalEndpoint extends EventEmitter {
4702
4962
  m.ack();
4703
4963
  return;
4704
4964
  } // undecodable — drop
4965
+ if (!isRecord(entry)) {
4966
+ m.ack();
4967
+ return;
4968
+ } // non-object envelope — permanently invalid
4705
4969
  const redeliveries = m.info?.deliveryCount ?? 1; // JsMsg delivery attempts (1 on first delivery)
4706
4970
  // Lifecycle-exact ACL re-auth (SPEC 13.1): the entry was addressed to pr.lifecycleUid's inbox, so
4707
4971
  // the row read is that lifecycle's exact key — a retired lifecycle's purged row reads as unknown
4708
- // and its residual entries terminate at the redelivery ceiling, never against the successor's row.
4972
+ // and its residual entries are removed below, never authorized against the successor's row.
4709
4973
  const acl = await this.plane3?.aclFor(owner, pr.lifecycleUid);
4710
4974
  if (acl === undefined) {
4975
+ // RETIRED lifecycle: its exact ACL key carries retirement's tombstone, so no attempt can ever
4976
+ // deliver this entry. Drop it AND remove it from the store. Deferring it to the ceiling and
4977
+ // keeping it would make every reader that starts from a fresh cursor pay the same sweep again.
4978
+ // An ACL row that is merely absent proves nothing and still defers below.
4979
+ if (await aclRetired(await this.aclRegistry(), owner, pr.lifecycleUid)) {
4980
+ if (!this.plane3MayAct())
4981
+ return;
4982
+ // Remove BEFORE the ack. The ack ends the reader's work on this entry, so acking first and
4983
+ // then failing the delete would keep the entry with nothing left to remove it. A failed delete
4984
+ // stays pending and retries, bounded by the same ceiling; an entry the stream no longer holds
4985
+ // is already removed (the broker answers a delete of a missing sequence with 10043).
4986
+ let failure;
4987
+ try {
4988
+ await this.jsm.streams.deleteMessage(inboxStream(this.space), m.seq, false);
4989
+ }
4990
+ catch (e) {
4991
+ if (!isJetStreamMissing(e, 10043))
4992
+ failure = e instanceof Error ? e : new Error(String(e));
4993
+ }
4994
+ // FENCE AFTER THE DELETE, which is broker I/O. A daemon that stopped serving while it was in
4995
+ // flight leaves the entry to the holder: a term here would move the shared durable past an entry
4996
+ // the failed delete left stored, so the holder would never retry it.
4997
+ if (!this.plane3MayAct())
4998
+ return;
4999
+ if (failure) {
5000
+ if (redeliveries >= READER_MAX_REDELIVERIES) {
5001
+ m.term();
5002
+ this.emit("error", new Error(`plane-3 reader: gave up removing entry ${m.seq} for retired lifecycle ${owner}.${pr.lifecycleUid} after ${redeliveries} redeliveries: ${failure.message}`));
5003
+ return;
5004
+ }
5005
+ m.nak(2000);
5006
+ return;
5007
+ }
5008
+ m.ack();
5009
+ return;
5010
+ }
4711
5011
  // UNKNOWN owner — the manager has not (re)hydrated this owner's ACL yet (e.g. right after a
4712
5012
  // manager PROCESS restart). This is NOT a revocation: DEFER (redeliver), never drop — an ack here
4713
5013
  // would lose at-least-once on restart (impl-review BLOCKER-2). A delayed nak + a redelivery
@@ -4889,7 +5189,7 @@ export class CotalEndpoint extends EventEmitter {
4889
5189
  }
4890
5190
  catch (e) {
4891
5191
  if (attempt === 0)
4892
- this.emit("error", new Error(`channel "${channel}": Plane-3 durable membership (generation ${generation}) not yet tombstoned after a refused live sub - retrying; §7 boundary may be open until it succeeds (${e.message})`));
5192
+ this.emitRecoverable(new Error(`channel "${channel}": Plane-3 durable membership (generation ${generation}) not yet tombstoned after a refused live sub - retrying; §7 boundary may be open until it succeeds (${e.message})`));
4893
5193
  await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt)));
4894
5194
  }
4895
5195
  }
@@ -5083,7 +5383,7 @@ export class CotalEndpoint extends EventEmitter {
5083
5383
  // server policed who could publish. The payload `from` is advisory — it must match,
5084
5384
  // and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
5085
5385
  // Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
5086
- if (!isUsableMessageId(msg.id)) {
5386
+ if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
5087
5387
  m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
5088
5388
  this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
5089
5389
  continue;
@@ -5178,7 +5478,7 @@ export class CotalEndpoint extends EventEmitter {
5178
5478
  this.emit("error", e);
5179
5479
  return;
5180
5480
  }
5181
- if (!isUsableMessageId(msg.id))
5481
+ if (!isRecord(msg) || !isUsableMessageId(msg.id))
5182
5482
  return; // malformed envelope (SPEC sec 5) — live is at-most-once: drop
5183
5483
  if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner))
5184
5484
  return; // spoof/malformed/old-shape-alias — drop (at-most-once)
@@ -5414,7 +5714,7 @@ export class CotalEndpoint extends EventEmitter {
5414
5714
  continue; // skip undecodable
5415
5715
  }
5416
5716
  // Same authenticity guard as the tail; skip our own echoes in history.
5417
- if (!isUsableMessageId(msg.id))
5717
+ if (!isRecord(msg) || !isUsableMessageId(msg.id))
5418
5718
  continue; // malformed envelope (SPEC sec 5) — history skips
5419
5719
  const parsed = parseSubject(sm.subject);
5420
5720
  if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
@@ -5431,7 +5731,10 @@ export class CotalEndpoint extends EventEmitter {
5431
5731
  * not a push into context) plus `dropped: true` when the window is not complete: either the
5432
5732
  * channel's earliest *retained* message is already newer than the watermark (some ambient aged
5433
5733
  * out of the per-subject window), or replay is off for the channel below. Either way the caller
5434
- * must say so rather than silently reporting an empty, complete window.
5734
+ * must say so rather than silently reporting an empty, complete window. `seqs[i]` is the chat
5735
+ * stream sequence of `messages[i]`, the one identity two identical messages do not share.
5736
+ * `unanswered` is true when no history read answered (replay is off, or the read failed), so
5737
+ * `messages` says nothing about what the channel retains.
5435
5738
  *
5436
5739
  * Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
5437
5740
  * `replay=off` channel returns no messages, so `focus` can't become a history bypass for a
@@ -5445,10 +5748,10 @@ export class CotalEndpoint extends EventEmitter {
5445
5748
  if (!this.jsm)
5446
5749
  throw new Error(this.notLiveMsg());
5447
5750
  if (!isConcreteChannel(channel))
5448
- return { messages: [], dropped: false };
5751
+ return { messages: [], seqs: [], dropped: false, unanswered: true };
5449
5752
  const policy = await this.joinPolicyFresh(channel);
5450
5753
  if (!policy.replay)
5451
- return { messages: [], dropped: true };
5754
+ return { messages: [], seqs: [], dropped: true, unanswered: true };
5452
5755
  const subject = chatSubject(this.space, "*", "*", channel);
5453
5756
  let raw;
5454
5757
  try {
@@ -5458,9 +5761,10 @@ export class CotalEndpoint extends EventEmitter {
5458
5761
  this.emit("error", e);
5459
5762
  if (isPermissionDenied(e))
5460
5763
  throw e;
5461
- raw = [];
5764
+ return { messages: [], seqs: [], dropped: true, unanswered: true };
5462
5765
  }
5463
5766
  const collected = [];
5767
+ const seqs = [];
5464
5768
  for (const sm of raw) {
5465
5769
  let msg;
5466
5770
  try {
@@ -5470,15 +5774,16 @@ export class CotalEndpoint extends EventEmitter {
5470
5774
  continue; // skip undecodable
5471
5775
  }
5472
5776
  // Same authenticity guard as the tail/backfill; skip our own echoes.
5473
- if (!isUsableMessageId(msg.id))
5777
+ if (!isRecord(msg) || !isUsableMessageId(msg.id))
5474
5778
  continue; // malformed envelope (SPEC sec 5) — recall skips
5475
5779
  const parsed = parseSubject(sm.subject);
5476
5780
  if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
5477
5781
  continue;
5478
5782
  collected.push(authenticatedMessage(msg, parsed));
5783
+ seqs.push(sm.seq);
5479
5784
  }
5480
5785
  const dropped = await this.channelDropped(subject, sinceSeq);
5481
- return { messages: collected, dropped };
5786
+ return { messages: collected, seqs, dropped, unanswered: false };
5482
5787
  }
5483
5788
  /** Did focus recall on `subject` miss ambient that aged out past the watermark? Ambient is only
5484
5789
  * ever discarded once a sender-subject reaches {@link MAX_MSGS_PER_SUBJECT} (`DiscardPolicy.Old`);
@@ -5533,6 +5838,7 @@ export class CotalEndpoint extends EventEmitter {
5533
5838
  condition: this.condition,
5534
5839
  environment: this.environment,
5535
5840
  activity: this.activity,
5841
+ activeAt: this.activeAt,
5536
5842
  attention: this.attentionMode,
5537
5843
  channelModes: this.channelModes,
5538
5844
  ts: Date.now(),
@@ -5992,22 +6298,22 @@ export class CotalEndpoint extends EventEmitter {
5992
6298
  this.emit("roster", this.getRoster());
5993
6299
  }
5994
6300
  }
5995
- /** Map an authenticated parsed-subject kind to the message class surfaced to "message" listeners.
5996
- * Throws on `ctl` (control-plane is request/reply, never a "message") — per repo convention, no
5997
- * silent default: an unexpected delivering kind is a bug, not something to swallow. */
5998
- /** A usable delivery-message id (#624): a string, possibly empty (the never-a-key case), but
5999
- * never absent and never a non-string. An absent or non-string id is a malformed envelope under
6000
- * SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
6001
- * skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
6002
6301
  /** What a history read failure NAMES when it could not finish. One filter subject is the useful
6003
6302
  * thing to print; a set of sixty-nine of them is a wall of text in a message a human has to read,
6004
6303
  * so a set says its size and the stream it was read from instead. */
6005
6304
  function subjectLabel(subjects) {
6006
6305
  return subjects.length === 1 ? subjects[0] : `${subjects.length} filtered subjects`;
6007
6306
  }
6307
+ /** A usable delivery-message id (#624): a string, possibly empty (the never-a-key case), but
6308
+ * never absent and never a non-string. An absent or non-string id is a malformed envelope under
6309
+ * SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
6310
+ * skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
6008
6311
  function isUsableMessageId(id) {
6009
6312
  return typeof id === "string";
6010
6313
  }
6314
+ /** Map an authenticated parsed-subject kind to the message class surfaced to "message" listeners.
6315
+ * Throws on `ctl` (control-plane is request/reply, never a "message") — per repo convention, no
6316
+ * silent default: an unexpected delivering kind is a bug, not something to swallow. */
6011
6317
  function kindFromParsed(kind) {
6012
6318
  switch (kind) {
6013
6319
  case "chat":
@@ -6304,11 +6610,6 @@ function authOpts(a) {
6304
6610
  }
6305
6611
  return { token: a.token, user: a.user, pass: a.pass, tls };
6306
6612
  }
6307
- /** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
6308
- * bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
6309
- * bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
6310
- * claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
6311
- * `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
6312
6613
  /** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
6313
6614
  * `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
6314
6615
  function bearerExpiryMs(bearer) {
@@ -6326,6 +6627,11 @@ function bearerExpiryMs(bearer) {
6326
6627
  throw new Error("user-mode bearer is missing a numeric exp claim");
6327
6628
  return claims.exp * 1000;
6328
6629
  }
6630
+ /** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
6631
+ * bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
6632
+ * bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
6633
+ * claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
6634
+ * `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
6329
6635
  function decodeBearerPrincipal(bearer) {
6330
6636
  const payload = bearer.split(".")[1];
6331
6637
  if (!payload)