@cotal-ai/core 0.57.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 (124) 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 +25 -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-envelope.js +2 -2
  30. package/dist/endpoint-envelope.js.map +1 -1
  31. package/dist/endpoint-grants.d.ts.map +1 -1
  32. package/dist/endpoint-grants.js +4 -2
  33. package/dist/endpoint-grants.js.map +1 -1
  34. package/dist/endpoint-handle.d.ts.map +1 -1
  35. package/dist/endpoint-handle.js +5 -2
  36. package/dist/endpoint-handle.js.map +1 -1
  37. package/dist/endpoint-reconcile.d.ts +5 -1
  38. package/dist/endpoint-reconcile.d.ts.map +1 -1
  39. package/dist/endpoint-reconcile.js +43 -21
  40. package/dist/endpoint-reconcile.js.map +1 -1
  41. package/dist/endpoint-service.d.ts +25 -0
  42. package/dist/endpoint-service.d.ts.map +1 -1
  43. package/dist/endpoint-service.js +25 -25
  44. package/dist/endpoint-service.js.map +1 -1
  45. package/dist/endpoint-subjects.d.ts +6 -3
  46. package/dist/endpoint-subjects.d.ts.map +1 -1
  47. package/dist/endpoint-subjects.js +5 -5
  48. package/dist/endpoint-subjects.js.map +1 -1
  49. package/dist/endpoint-verbs.d.ts +1 -1
  50. package/dist/endpoint-verbs.d.ts.map +1 -1
  51. package/dist/endpoint-verbs.js +2 -2
  52. package/dist/endpoint-verbs.js.map +1 -1
  53. package/dist/endpoint.d.ts +114 -8
  54. package/dist/endpoint.d.ts.map +1 -1
  55. package/dist/endpoint.js +393 -45
  56. package/dist/endpoint.js.map +1 -1
  57. package/dist/evict.d.ts +27 -0
  58. package/dist/evict.d.ts.map +1 -1
  59. package/dist/evict.js +95 -38
  60. package/dist/evict.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/issued-authority.d.ts +0 -2
  66. package/dist/issued-authority.d.ts.map +1 -1
  67. package/dist/issued-authority.js +0 -2
  68. package/dist/issued-authority.js.map +1 -1
  69. package/dist/kv-scan.d.ts +5 -2
  70. package/dist/kv-scan.d.ts.map +1 -1
  71. package/dist/kv-scan.js +103 -27
  72. package/dist/kv-scan.js.map +1 -1
  73. package/dist/launch-artifacts.d.ts +42 -0
  74. package/dist/launch-artifacts.d.ts.map +1 -0
  75. package/dist/launch-artifacts.js +155 -0
  76. package/dist/launch-artifacts.js.map +1 -0
  77. package/dist/lease.d.ts +5 -0
  78. package/dist/lease.d.ts.map +1 -1
  79. package/dist/lease.js.map +1 -1
  80. package/dist/liveness.d.ts +180 -0
  81. package/dist/liveness.d.ts.map +1 -0
  82. package/dist/liveness.js +114 -0
  83. package/dist/liveness.js.map +1 -0
  84. package/dist/members.d.ts +10 -0
  85. package/dist/members.d.ts.map +1 -1
  86. package/dist/members.js +28 -1
  87. package/dist/members.js.map +1 -1
  88. package/dist/membership-feed.d.ts.map +1 -1
  89. package/dist/membership-feed.js +19 -6
  90. package/dist/membership-feed.js.map +1 -1
  91. package/dist/provision.d.ts +24 -11
  92. package/dist/provision.d.ts.map +1 -1
  93. package/dist/provision.js +154 -66
  94. package/dist/provision.js.map +1 -1
  95. package/dist/remote-manager-authority.d.ts +193 -3
  96. package/dist/remote-manager-authority.d.ts.map +1 -1
  97. package/dist/remote-manager-authority.js +104 -0
  98. package/dist/remote-manager-authority.js.map +1 -1
  99. package/dist/run-host.d.ts +66 -2
  100. package/dist/run-host.d.ts.map +1 -1
  101. package/dist/run-journal.d.ts +14 -6
  102. package/dist/run-journal.d.ts.map +1 -1
  103. package/dist/run-journal.js +14 -14
  104. package/dist/run-journal.js.map +1 -1
  105. package/dist/run-record.d.ts +24 -5
  106. package/dist/run-record.d.ts.map +1 -1
  107. package/dist/run-record.js +20 -0
  108. package/dist/run-record.js.map +1 -1
  109. package/dist/runtime.d.ts +6 -1
  110. package/dist/runtime.d.ts.map +1 -1
  111. package/dist/schema-profile.d.ts.map +1 -1
  112. package/dist/schema-profile.js +4 -3
  113. package/dist/schema-profile.js.map +1 -1
  114. package/dist/streams.d.ts +59 -3
  115. package/dist/streams.d.ts.map +1 -1
  116. package/dist/streams.js +226 -21
  117. package/dist/streams.js.map +1 -1
  118. package/dist/subjects.d.ts +52 -7
  119. package/dist/subjects.d.ts.map +1 -1
  120. package/dist/subjects.js +62 -3
  121. package/dist/subjects.js.map +1 -1
  122. package/dist/types.d.ts +4 -0
  123. package/dist/types.d.ts.map +1 -1
  124. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -3,7 +3,9 @@ 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";
8
+ import { requireBrokerFloor } from "./broker-floor.js";
7
9
  import { inspectCredHealth } from "./provision.js";
8
10
  import { parseSecretStoreIdentity, } from "./secret-store.js";
9
11
  import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
@@ -14,16 +16,17 @@ import { readAcceptedRow } from "./issued-authority.js";
14
16
  import { liveKvEntries } from "./kv-scan.js";
15
17
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
16
18
  import { assertValidName } from "./resolve.js";
17
- import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY } from "./streams.js";
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";
18
21
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
19
22
  import {} from "@nats-io/jetstream";
20
23
  import { Kvm } from "@nats-io/kv";
21
24
  import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
22
- import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
23
- import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
25
+ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, listLifecycleMemberChannels, durableEligible, StaleMembershipWrite, } from "./members.js";
26
+ import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
24
27
  import { openDeliveryRegistry } from "./lease.js";
25
28
  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, 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";
27
30
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
28
31
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
29
32
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -143,6 +146,8 @@ export class CotalEndpoint extends EventEmitter {
143
146
  authExpiryReconnectTimer;
144
147
  sentinelCreds;
145
148
  tls;
149
+ transportPingIntervalMs;
150
+ transportMaxPingOut;
146
151
  heartbeatMs;
147
152
  ttlMs;
148
153
  doRegister;
@@ -184,12 +189,12 @@ export class CotalEndpoint extends EventEmitter {
184
189
  * {@link armDeliveryControl}; tracked so the stale one is dropped on reconnect. */
185
190
  deliveryServeSub;
186
191
  deliveryAdminServeSub;
187
- /** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
188
- * daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
189
- * re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
190
192
  /** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
191
193
  * Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
192
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. */
193
198
  plane3;
194
199
  /** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
195
200
  channelConfigs = new Map();
@@ -203,6 +208,11 @@ export class CotalEndpoint extends EventEmitter {
203
208
  * `chathist_<id>` consumer, so overlapping reads would delete/recreate it under one another. */
204
209
  histLock = Promise.resolve();
205
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();
206
216
  streamMsgs = [];
207
217
  /** Per-channel native core subscriptions (SPEC v0.3) — the manager-free live read path for boot +
208
218
  * runtime channels (there is no per-instance chat durable). Keyed by channel so leave unsubscribes
@@ -286,6 +296,8 @@ export class CotalEndpoint extends EventEmitter {
286
296
  presenceRebindAt = 0;
287
297
  status = "idle";
288
298
  activity;
299
+ /** Last harness-reported work progress. Carried by the next heartbeat, never published per event. */
300
+ activeAt;
289
301
  condition;
290
302
  /** Advances on every condition change so an older in-flight put cannot be the final KV state. */
291
303
  conditionRevision = 0;
@@ -483,6 +495,14 @@ export class CotalEndpoint extends EventEmitter {
483
495
  this.user = opts.user;
484
496
  this.pass = opts.pass;
485
497
  this.tls = opts.tls ?? false;
498
+ for (const [label, value] of [["transportPingIntervalMs", opts.transportPingIntervalMs], ["transportMaxPingOut", opts.transportMaxPingOut]]) {
499
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0))
500
+ throw new Error(`EndpointOptions.${label} must be a positive safe integer`);
501
+ }
502
+ if ((opts.transportPingIntervalMs === undefined) !== (opts.transportMaxPingOut === undefined))
503
+ throw new Error("EndpointOptions transportPingIntervalMs and transportMaxPingOut must be configured together");
504
+ this.transportPingIntervalMs = opts.transportPingIntervalMs;
505
+ this.transportMaxPingOut = opts.transportMaxPingOut;
486
506
  // No implicit channel: an endpoint reads exactly what its caller lists. Omitted means none.
487
507
  this.channels = opts.channels ?? [];
488
508
  this.heartbeatMs = opts.heartbeatMs ?? 2000;
@@ -849,6 +869,7 @@ export class CotalEndpoint extends EventEmitter {
849
869
  CotalEndpoint.assertRenewableGeneration(candidate, this.currentCreds, delay);
850
870
  this.currentCreds = candidate;
851
871
  this.armCredsRefresh(delay);
872
+ this.emit("creds-adopted", credsClaims(candidate));
852
873
  return credsClaims(candidate);
853
874
  }
854
875
  /** The 75%-of-lifetime renewal timer tick: prove + adopt + swap the LIVE connection. Preflights (it
@@ -979,6 +1000,8 @@ export class CotalEndpoint extends EventEmitter {
979
1000
  // sub.allow=[_INBOX_<connId>.>] it stops a peer from subscribing the wildcard inbox to sniff
980
1001
  // others' DM deliveries. Set unconditionally so the prefix can never drift from the ACL.
981
1002
  inboxPrefix: `_INBOX_${this.connId}`,
1003
+ ...(this.transportPingIntervalMs === undefined ? {} : { pingInterval: this.transportPingIntervalMs }),
1004
+ ...(this.transportMaxPingOut === undefined ? {} : { maxPingOut: this.transportMaxPingOut }),
982
1005
  // The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
983
1006
  // attempt, so internal reconnects present whatever refreshBearer last fetched.
984
1007
  // Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt — including the
@@ -992,6 +1015,9 @@ export class CotalEndpoint extends EventEmitter {
992
1015
  // instead. Anonymous access stays reachable the only way it should be: by passing no creds.
993
1016
  ...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.currentCreds !== undefined || this.credsSource ? () => this.credsForWire() : undefined, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
994
1017
  });
1018
+ // SPEC §13.12: the control surface requires nats-server >= 2.12; this runs on every
1019
+ // fresh connection, including the reconnects the library performs on its own here.
1020
+ requireBrokerFloor(this.nc);
995
1021
  this.armAuthExpiryReconnectFence(this.nc);
996
1022
  this.watchStatus();
997
1023
  this.js = jetstream(this.nc);
@@ -1013,7 +1039,7 @@ export class CotalEndpoint extends EventEmitter {
1013
1039
  // OPENs it (it's pre-created at `cotal up`; KV stream-create is denied to agents).
1014
1040
  this.kv = this.authed
1015
1041
  ? await kvm.open(presenceBucket(this.space))
1016
- : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs });
1042
+ : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
1017
1043
  }
1018
1044
  if (this.doWatch) {
1019
1045
  await this.startPresenceWatch();
@@ -1101,6 +1127,10 @@ export class CotalEndpoint extends EventEmitter {
1101
1127
  // endpoint hosts it. The first arm comes from startPlane3 (after start()); this re-binds the loops
1102
1128
  // a reconnect's clearConnectionScoped() tore down, so a broker blip doesn't silently kill the backstop.
1103
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);
1104
1134
  // Bound and live — covers initial start, manual reconnect, AND background self-heal (every
1105
1135
  // path lands here). The single signal an in-process agent's connected flag tracks.
1106
1136
  //
@@ -2223,6 +2253,12 @@ export class CotalEndpoint extends EventEmitter {
2223
2253
  this.status = status;
2224
2254
  await this.publishPresence();
2225
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
+ }
2226
2262
  /** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
2227
2263
  async setCondition(condition) {
2228
2264
  this.condition = condition ?? undefined;
@@ -3390,8 +3426,11 @@ export class CotalEndpoint extends EventEmitter {
3390
3426
  * daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
3391
3427
  * processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
3392
3428
  leaseIncarnation = randomUUID();
3393
- encodeLease(ready) {
3394
- 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 }));
3395
3434
  }
3396
3435
  /** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
3397
3436
  * just failed has to answer before it decides whether it still owns the shard.
@@ -3411,13 +3450,17 @@ export class CotalEndpoint extends EventEmitter {
3411
3450
  * freeing a re-acquire. Acquired BEFORE binding (single-flight gate); {@link markDeliveryLeaseReady}
3412
3451
  * flips it ready AFTER the loops + `ctl.delivery` are bound. Returns the lease revision. */
3413
3452
  async acquireDeliveryLease(shardIndex) {
3414
- 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;
3415
3458
  }
3416
3459
  /** Flip the held lease to READY (CAS `kv.update`) AFTER `startPlane3` has bound the loops + the
3417
3460
  * `ctl.delivery` responder — so "lease ready" proves the responder is up, not just that the slot was
3418
3461
  * claimed. Returns the new revision. */
3419
3462
  async markDeliveryLeaseReady(shardIndex, revision) {
3420
- return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
3463
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
3421
3464
  }
3422
3465
  /** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
3423
3466
  * holder that has UNBOUND its loops and control responder but has not given up the shard.
@@ -3429,13 +3472,13 @@ export class CotalEndpoint extends EventEmitter {
3429
3472
  * the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
3430
3473
  * should be invited in; what is being withdrawn is only the claim to be answering. */
3431
3474
  async markDeliveryLeaseNotReady(shardIndex, revision) {
3432
- return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
3475
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, false), revision);
3433
3476
  }
3434
3477
  /** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
3435
3478
  * the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
3436
3479
  * the daemon should exit). */
3437
3480
  async renewDeliveryLease(shardIndex, revision) {
3438
- return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
3481
+ return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(shardIndex, true), revision);
3439
3482
  }
3440
3483
  /** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
3441
3484
  * effort, a crash just lets the bucket TTL expire it).
@@ -3980,7 +4023,7 @@ export class CotalEndpoint extends EventEmitter {
3980
4023
  continue;
3981
4024
  }
3982
4025
  const parsed = parseSubject(m.subject);
3983
- 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)
3984
4027
  continue;
3985
4028
  await this.publishDinbox(owner, lifecycleUid, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
3986
4029
  copied++;
@@ -4005,13 +4048,9 @@ export class CotalEndpoint extends EventEmitter {
4005
4048
  async startPlane3(aclFor, opts = {}) {
4006
4049
  if (!this.js)
4007
4050
  throw new Error("endpoint not started");
4008
- this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity };
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 };
4009
4052
  await this.armPlane3();
4010
4053
  }
4011
- /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
4012
- * id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
4013
- * is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
4014
- * truth, no in-memory ledger to drift). */
4015
4054
  /** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
4016
4055
  *
4017
4056
  * Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
@@ -4029,6 +4068,10 @@ export class CotalEndpoint extends EventEmitter {
4029
4068
  plane3MayAct() {
4030
4069
  return !this.plane3Quiesced;
4031
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). */
4032
4075
  async handleDeliveryControl(req) {
4033
4076
  // FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
4034
4077
  // this shard's control rail while the winner is already READY.
@@ -4371,6 +4414,216 @@ export class CotalEndpoint extends EventEmitter {
4371
4414
  }
4372
4415
  this.deliveryAdminServeSub = this.serveControl(CONTROL_DELIVERY_ADMIN, (req) => this.handleDeliveryAdmin(req), { boundReply: true });
4373
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
+ }
4374
4627
  /** Serve one PRIVILEGED delivery-admin request (the D5 rail-split). The cred layer is the caller
4375
4628
  * boundary — only the supervisor profile can publish here — and `serveControl`'s sender check +
4376
4629
  * bounded reply still apply on top. `reloadCreds` is the class-2 renewal ADOPTION step: re-read
@@ -4382,6 +4635,12 @@ export class CotalEndpoint extends EventEmitter {
4382
4635
  if (!this.plane3MayAct())
4383
4636
  return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
4384
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" };
4385
4644
  // The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
4386
4645
  // component. A missing entry means "no expectation" (the passive backstop still adopts).
4387
4646
  const expected = (req.args?.expected ?? {});
@@ -4405,8 +4664,10 @@ export class CotalEndpoint extends EventEmitter {
4405
4664
  // Arm the resident wire swap ONLY now — after BOTH proofs settled, right before the reply is
4406
4665
  // returned+responded — so a slow membership proof can never let the delivery reconnect strand
4407
4666
  // this reply. Only when delivery actually adopted a new candidate (currentCreds was updated).
4408
- if (delivery.ok)
4667
+ if (delivery.ok) {
4668
+ this.plane3?.onDeliveryCredsAdopted?.();
4409
4669
  this.scheduleResidentSwap();
4670
+ }
4410
4671
  return failures.length
4411
4672
  ? { ok: false, error: failures.join("; "), data: { delivery, membership } }
4412
4673
  : { ok: true, data: { delivery, membership } };
@@ -4427,6 +4688,24 @@ export class CotalEndpoint extends EventEmitter {
4427
4688
  return { ok: false, error: e.message };
4428
4689
  }
4429
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
+ }
4430
4709
  if (req.op === "planeConnLiveness") {
4431
4710
  // The plane-claim liveness oracle (#29 HIGH 3): a CLOSED read-only verb — two claimed
4432
4711
  // scanner tuples in, two bound verdicts + sweep completeness out. The executor hook owns
@@ -4442,6 +4721,29 @@ export class CotalEndpoint extends EventEmitter {
4442
4721
  return { ok: false, error: e.message };
4443
4722
  }
4444
4723
  }
4724
+ if (req.op === "lifecycleMemberships") {
4725
+ // The terminal teardown's membership INVENTORY: a complete, read-only, lifecycle-exact listing
4726
+ // of one principal's durable membership rows (tombstones included) from the trusted daemon that
4727
+ // owns the members bucket. The caller deletes only the exact keys it gets back, through its
4728
+ // target-pinned deprovisioner grant; this verb deletes nothing and returns no other lifecycle.
4729
+ const principal = typeof req.args?.principal === "string" ? req.args.principal.trim() : "";
4730
+ const uid = typeof req.args?.lifecycleUid === "string" ? req.args.lifecycleUid : "";
4731
+ if (!parsePrincipalKey(principal))
4732
+ return { ok: false, error: "lifecycleMemberships: a principal (owner.actor dot-form) is required" };
4733
+ try {
4734
+ assertLifecycleToken(uid);
4735
+ }
4736
+ catch (e) {
4737
+ return { ok: false, error: `lifecycleMemberships: ${e.message}` };
4738
+ }
4739
+ try {
4740
+ const channels = await listLifecycleMemberChannels(await this.membersRegistry(), principal, uid);
4741
+ return { ok: true, data: { complete: true, channels } };
4742
+ }
4743
+ catch (e) {
4744
+ return { ok: false, error: `lifecycleMemberships: the inventory read did not complete (${e.message})` };
4745
+ }
4746
+ }
4445
4747
  if (req.op === "principalLiveness") {
4446
4748
  // The freeze-holder liveness probe (#391): the READ-ONLY half of `evictPrincipal`. A repair
4447
4749
  // that must REFUSE while the holder is alive cannot use eviction as its own precheck — that
@@ -4550,7 +4852,7 @@ export class CotalEndpoint extends EventEmitter {
4550
4852
  m.ack();
4551
4853
  return;
4552
4854
  }
4553
- 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)) {
4554
4856
  m.ack();
4555
4857
  return;
4556
4858
  } // authenticity (owner must be a real principal, not an old-shape alias)
@@ -4660,12 +4962,52 @@ export class CotalEndpoint extends EventEmitter {
4660
4962
  m.ack();
4661
4963
  return;
4662
4964
  } // undecodable — drop
4965
+ if (!isRecord(entry)) {
4966
+ m.ack();
4967
+ return;
4968
+ } // non-object envelope — permanently invalid
4663
4969
  const redeliveries = m.info?.deliveryCount ?? 1; // JsMsg delivery attempts (1 on first delivery)
4664
4970
  // Lifecycle-exact ACL re-auth (SPEC 13.1): the entry was addressed to pr.lifecycleUid's inbox, so
4665
4971
  // the row read is that lifecycle's exact key — a retired lifecycle's purged row reads as unknown
4666
- // 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.
4667
4973
  const acl = await this.plane3?.aclFor(owner, pr.lifecycleUid);
4668
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
+ }
4669
5011
  // UNKNOWN owner — the manager has not (re)hydrated this owner's ACL yet (e.g. right after a
4670
5012
  // manager PROCESS restart). This is NOT a revocation: DEFER (redeliver), never drop — an ack here
4671
5013
  // would lose at-least-once on restart (impl-review BLOCKER-2). A delayed nak + a redelivery
@@ -4847,7 +5189,7 @@ export class CotalEndpoint extends EventEmitter {
4847
5189
  }
4848
5190
  catch (e) {
4849
5191
  if (attempt === 0)
4850
- 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})`));
4851
5193
  await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** attempt)));
4852
5194
  }
4853
5195
  }
@@ -5041,7 +5383,7 @@ export class CotalEndpoint extends EventEmitter {
5041
5383
  // server policed who could publish. The payload `from` is advisory — it must match,
5042
5384
  // and a missing `from` or an unparseable subject on a delivery is itself an anomaly.
5043
5385
  // Reject (term — a spoof is permanently invalid, never redeliver) BEFORE any handler.
5044
- if (!isUsableMessageId(msg.id)) {
5386
+ if (!isRecord(msg) || !isUsableMessageId(msg.id)) {
5045
5387
  m.term(); // malformed envelope (SPEC sec 5): absent/non-string id — permanently invalid
5046
5388
  this.emit("error", new Error(`dropped message on ${m.subject}: absent or non-string id`));
5047
5389
  continue;
@@ -5136,7 +5478,7 @@ export class CotalEndpoint extends EventEmitter {
5136
5478
  this.emit("error", e);
5137
5479
  return;
5138
5480
  }
5139
- if (!isUsableMessageId(msg.id))
5481
+ if (!isRecord(msg) || !isUsableMessageId(msg.id))
5140
5482
  return; // malformed envelope (SPEC sec 5) — live is at-most-once: drop
5141
5483
  if (!msg.from || msg.from.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner))
5142
5484
  return; // spoof/malformed/old-shape-alias — drop (at-most-once)
@@ -5372,7 +5714,7 @@ export class CotalEndpoint extends EventEmitter {
5372
5714
  continue; // skip undecodable
5373
5715
  }
5374
5716
  // Same authenticity guard as the tail; skip our own echoes in history.
5375
- if (!isUsableMessageId(msg.id))
5717
+ if (!isRecord(msg) || !isUsableMessageId(msg.id))
5376
5718
  continue; // malformed envelope (SPEC sec 5) — history skips
5377
5719
  const parsed = parseSubject(sm.subject);
5378
5720
  if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
@@ -5389,7 +5731,10 @@ export class CotalEndpoint extends EventEmitter {
5389
5731
  * not a push into context) plus `dropped: true` when the window is not complete: either the
5390
5732
  * channel's earliest *retained* message is already newer than the watermark (some ambient aged
5391
5733
  * out of the per-subject window), or replay is off for the channel below. Either way the caller
5392
- * 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.
5393
5738
  *
5394
5739
  * Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
5395
5740
  * `replay=off` channel returns no messages, so `focus` can't become a history bypass for a
@@ -5403,10 +5748,10 @@ export class CotalEndpoint extends EventEmitter {
5403
5748
  if (!this.jsm)
5404
5749
  throw new Error(this.notLiveMsg());
5405
5750
  if (!isConcreteChannel(channel))
5406
- return { messages: [], dropped: false };
5751
+ return { messages: [], seqs: [], dropped: false, unanswered: true };
5407
5752
  const policy = await this.joinPolicyFresh(channel);
5408
5753
  if (!policy.replay)
5409
- return { messages: [], dropped: true };
5754
+ return { messages: [], seqs: [], dropped: true, unanswered: true };
5410
5755
  const subject = chatSubject(this.space, "*", "*", channel);
5411
5756
  let raw;
5412
5757
  try {
@@ -5416,9 +5761,10 @@ export class CotalEndpoint extends EventEmitter {
5416
5761
  this.emit("error", e);
5417
5762
  if (isPermissionDenied(e))
5418
5763
  throw e;
5419
- raw = [];
5764
+ return { messages: [], seqs: [], dropped: true, unanswered: true };
5420
5765
  }
5421
5766
  const collected = [];
5767
+ const seqs = [];
5422
5768
  for (const sm of raw) {
5423
5769
  let msg;
5424
5770
  try {
@@ -5428,15 +5774,16 @@ export class CotalEndpoint extends EventEmitter {
5428
5774
  continue; // skip undecodable
5429
5775
  }
5430
5776
  // Same authenticity guard as the tail/backfill; skip our own echoes.
5431
- if (!isUsableMessageId(msg.id))
5777
+ if (!isRecord(msg) || !isUsableMessageId(msg.id))
5432
5778
  continue; // malformed envelope (SPEC sec 5) — recall skips
5433
5779
  const parsed = parseSubject(sm.subject);
5434
5780
  if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === this.card.id)
5435
5781
  continue;
5436
5782
  collected.push(authenticatedMessage(msg, parsed));
5783
+ seqs.push(sm.seq);
5437
5784
  }
5438
5785
  const dropped = await this.channelDropped(subject, sinceSeq);
5439
- return { messages: collected, dropped };
5786
+ return { messages: collected, seqs, dropped, unanswered: false };
5440
5787
  }
5441
5788
  /** Did focus recall on `subject` miss ambient that aged out past the watermark? Ambient is only
5442
5789
  * ever discarded once a sender-subject reaches {@link MAX_MSGS_PER_SUBJECT} (`DiscardPolicy.Old`);
@@ -5491,6 +5838,7 @@ export class CotalEndpoint extends EventEmitter {
5491
5838
  condition: this.condition,
5492
5839
  environment: this.environment,
5493
5840
  activity: this.activity,
5841
+ activeAt: this.activeAt,
5494
5842
  attention: this.attentionMode,
5495
5843
  channelModes: this.channelModes,
5496
5844
  ts: Date.now(),
@@ -5950,22 +6298,22 @@ export class CotalEndpoint extends EventEmitter {
5950
6298
  this.emit("roster", this.getRoster());
5951
6299
  }
5952
6300
  }
5953
- /** Map an authenticated parsed-subject kind to the message class surfaced to "message" listeners.
5954
- * Throws on `ctl` (control-plane is request/reply, never a "message") — per repo convention, no
5955
- * silent default: an unexpected delivering kind is a bug, not something to swallow. */
5956
- /** A usable delivery-message id (#624): a string, possibly empty (the never-a-key case), but
5957
- * never absent and never a non-string. An absent or non-string id is a malformed envelope under
5958
- * SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
5959
- * skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
5960
6301
  /** What a history read failure NAMES when it could not finish. One filter subject is the useful
5961
6302
  * thing to print; a set of sixty-nine of them is a wall of text in a message a human has to read,
5962
6303
  * so a set says its size and the stream it was read from instead. */
5963
6304
  function subjectLabel(subjects) {
5964
6305
  return subjects.length === 1 ? subjects[0] : `${subjects.length} filtered subjects`;
5965
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`. */
5966
6311
  function isUsableMessageId(id) {
5967
6312
  return typeof id === "string";
5968
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. */
5969
6317
  function kindFromParsed(kind) {
5970
6318
  switch (kind) {
5971
6319
  case "chat":
@@ -6262,11 +6610,6 @@ function authOpts(a) {
6262
6610
  }
6263
6611
  return { token: a.token, user: a.user, pass: a.pass, tls };
6264
6612
  }
6265
- /** Decode the owner+actor PRINCIPAL from a user bearer WITHOUT verifying it — the client trusts its own
6266
- * bearer only to build its subjects; the broker's minted grant (from the callout, which DOES verify the
6267
- * bearer) is the real boundary, so a client that lied to itself would just be denied. Per the token
6268
- * claim semantics the OWNER is the JWT `sub` (`act.owner` merely restates it) and the ACTOR is
6269
- * `act.actor`. Throws on a structurally-unusable bearer (fail-loud). */
6270
6613
  /** The bearer's `exp` as epoch ms — what the refresh schedule keys on. A bearer without a numeric
6271
6614
  * `exp` is structurally unusable for a refreshing endpoint (fail-loud, like the principal decode). */
6272
6615
  function bearerExpiryMs(bearer) {
@@ -6284,6 +6627,11 @@ function bearerExpiryMs(bearer) {
6284
6627
  throw new Error("user-mode bearer is missing a numeric exp claim");
6285
6628
  return claims.exp * 1000;
6286
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). */
6287
6635
  function decodeBearerPrincipal(bearer) {
6288
6636
  const payload = bearer.split(".")[1];
6289
6637
  if (!payload)