@cotal-ai/core 0.12.0 → 0.13.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 (152) hide show
  1. package/dist/acls.d.ts +31 -7
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +105 -17
  4. package/dist/acls.js.map +1 -1
  5. package/dist/agent-file.js +1 -1
  6. package/dist/agent-file.js.map +1 -1
  7. package/dist/auth-provider.d.ts +8 -0
  8. package/dist/auth-provider.d.ts.map +1 -1
  9. package/dist/auth-provider.js.map +1 -1
  10. package/dist/backup.d.ts.map +1 -1
  11. package/dist/backup.js +6 -4
  12. package/dist/backup.js.map +1 -1
  13. package/dist/broker-floor.d.ts +34 -0
  14. package/dist/broker-floor.d.ts.map +1 -0
  15. package/dist/broker-floor.js +39 -0
  16. package/dist/broker-floor.js.map +1 -0
  17. package/dist/canonical.d.ts +24 -0
  18. package/dist/canonical.d.ts.map +1 -0
  19. package/dist/canonical.js +159 -0
  20. package/dist/canonical.js.map +1 -0
  21. package/dist/connector.d.ts +4 -0
  22. package/dist/connector.d.ts.map +1 -1
  23. package/dist/endpoint-action.d.ts +424 -0
  24. package/dist/endpoint-action.d.ts.map +1 -0
  25. package/dist/endpoint-action.js +910 -0
  26. package/dist/endpoint-action.js.map +1 -0
  27. package/dist/endpoint-binding.d.ts +344 -0
  28. package/dist/endpoint-binding.d.ts.map +1 -0
  29. package/dist/endpoint-binding.js +753 -0
  30. package/dist/endpoint-binding.js.map +1 -0
  31. package/dist/endpoint-checkpoint.d.ts +292 -0
  32. package/dist/endpoint-checkpoint.d.ts.map +1 -0
  33. package/dist/endpoint-checkpoint.js +818 -0
  34. package/dist/endpoint-checkpoint.js.map +1 -0
  35. package/dist/endpoint-cluster.d.ts +95 -0
  36. package/dist/endpoint-cluster.d.ts.map +1 -0
  37. package/dist/endpoint-cluster.js +194 -0
  38. package/dist/endpoint-cluster.js.map +1 -0
  39. package/dist/endpoint-contract-store.d.ts +90 -0
  40. package/dist/endpoint-contract-store.d.ts.map +1 -0
  41. package/dist/endpoint-contract-store.js +348 -0
  42. package/dist/endpoint-contract-store.js.map +1 -0
  43. package/dist/endpoint-envelope.d.ts +165 -0
  44. package/dist/endpoint-envelope.d.ts.map +1 -0
  45. package/dist/endpoint-envelope.js +346 -0
  46. package/dist/endpoint-envelope.js.map +1 -0
  47. package/dist/endpoint-grants.d.ts +123 -0
  48. package/dist/endpoint-grants.d.ts.map +1 -0
  49. package/dist/endpoint-grants.js +215 -0
  50. package/dist/endpoint-grants.js.map +1 -0
  51. package/dist/endpoint-guard.d.ts +222 -0
  52. package/dist/endpoint-guard.d.ts.map +1 -0
  53. package/dist/endpoint-guard.js +468 -0
  54. package/dist/endpoint-guard.js.map +1 -0
  55. package/dist/endpoint-handle.d.ts +176 -0
  56. package/dist/endpoint-handle.d.ts.map +1 -0
  57. package/dist/endpoint-handle.js +608 -0
  58. package/dist/endpoint-handle.js.map +1 -0
  59. package/dist/endpoint-journal.d.ts +225 -0
  60. package/dist/endpoint-journal.d.ts.map +1 -0
  61. package/dist/endpoint-journal.js +431 -0
  62. package/dist/endpoint-journal.js.map +1 -0
  63. package/dist/endpoint-receipt.d.ts +225 -0
  64. package/dist/endpoint-receipt.d.ts.map +1 -0
  65. package/dist/endpoint-receipt.js +417 -0
  66. package/dist/endpoint-receipt.js.map +1 -0
  67. package/dist/endpoint-records.d.ts +243 -0
  68. package/dist/endpoint-records.d.ts.map +1 -0
  69. package/dist/endpoint-records.js +591 -0
  70. package/dist/endpoint-records.js.map +1 -0
  71. package/dist/endpoint-serve.d.ts +176 -0
  72. package/dist/endpoint-serve.d.ts.map +1 -0
  73. package/dist/endpoint-serve.js +446 -0
  74. package/dist/endpoint-serve.js.map +1 -0
  75. package/dist/endpoint-service.d.ts +518 -0
  76. package/dist/endpoint-service.d.ts.map +1 -0
  77. package/dist/endpoint-service.js +973 -0
  78. package/dist/endpoint-service.js.map +1 -0
  79. package/dist/endpoint-session.d.ts +398 -0
  80. package/dist/endpoint-session.d.ts.map +1 -0
  81. package/dist/endpoint-session.js +834 -0
  82. package/dist/endpoint-session.js.map +1 -0
  83. package/dist/endpoint-signing.d.ts +105 -0
  84. package/dist/endpoint-signing.d.ts.map +1 -0
  85. package/dist/endpoint-signing.js +190 -0
  86. package/dist/endpoint-signing.js.map +1 -0
  87. package/dist/endpoint-subjects.d.ts +222 -0
  88. package/dist/endpoint-subjects.d.ts.map +1 -0
  89. package/dist/endpoint-subjects.js +381 -0
  90. package/dist/endpoint-subjects.js.map +1 -0
  91. package/dist/endpoint-supervisor.d.ts +19 -0
  92. package/dist/endpoint-supervisor.d.ts.map +1 -0
  93. package/dist/endpoint-supervisor.js +12 -0
  94. package/dist/endpoint-supervisor.js.map +1 -0
  95. package/dist/endpoint-traits.d.ts +225 -0
  96. package/dist/endpoint-traits.d.ts.map +1 -0
  97. package/dist/endpoint-traits.js +469 -0
  98. package/dist/endpoint-traits.js.map +1 -0
  99. package/dist/endpoint-verbs.d.ts +277 -0
  100. package/dist/endpoint-verbs.d.ts.map +1 -0
  101. package/dist/endpoint-verbs.js +576 -0
  102. package/dist/endpoint-verbs.js.map +1 -0
  103. package/dist/endpoint-virtual.d.ts +308 -0
  104. package/dist/endpoint-virtual.d.ts.map +1 -0
  105. package/dist/endpoint-virtual.js +440 -0
  106. package/dist/endpoint-virtual.js.map +1 -0
  107. package/dist/endpoint-work.d.ts +304 -0
  108. package/dist/endpoint-work.d.ts.map +1 -0
  109. package/dist/endpoint-work.js +728 -0
  110. package/dist/endpoint-work.js.map +1 -0
  111. package/dist/endpoint.d.ts +66 -22
  112. package/dist/endpoint.d.ts.map +1 -1
  113. package/dist/endpoint.js +289 -89
  114. package/dist/endpoint.js.map +1 -1
  115. package/dist/evict.d.ts +95 -32
  116. package/dist/evict.d.ts.map +1 -1
  117. package/dist/evict.js +193 -3
  118. package/dist/evict.js.map +1 -1
  119. package/dist/index.d.ts +25 -0
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +25 -0
  122. package/dist/index.js.map +1 -1
  123. package/dist/lease.d.ts +4 -0
  124. package/dist/lease.d.ts.map +1 -1
  125. package/dist/lease.js.map +1 -1
  126. package/dist/members.d.ts +4 -4
  127. package/dist/members.d.ts.map +1 -1
  128. package/dist/members.js +12 -12
  129. package/dist/members.js.map +1 -1
  130. package/dist/provision.d.ts +70 -18
  131. package/dist/provision.d.ts.map +1 -1
  132. package/dist/provision.js +194 -33
  133. package/dist/provision.js.map +1 -1
  134. package/dist/safe-pattern.d.ts +33 -0
  135. package/dist/safe-pattern.d.ts.map +1 -0
  136. package/dist/safe-pattern.js +486 -0
  137. package/dist/safe-pattern.js.map +1 -0
  138. package/dist/schema-profile.d.ts +93 -0
  139. package/dist/schema-profile.d.ts.map +1 -0
  140. package/dist/schema-profile.js +284 -0
  141. package/dist/schema-profile.js.map +1 -0
  142. package/dist/streams.d.ts +13 -8
  143. package/dist/streams.d.ts.map +1 -1
  144. package/dist/streams.js +35 -18
  145. package/dist/streams.js.map +1 -1
  146. package/dist/subjects.d.ts +100 -40
  147. package/dist/subjects.d.ts.map +1 -1
  148. package/dist/subjects.js +136 -60
  149. package/dist/subjects.js.map +1 -1
  150. package/dist/types.d.ts +9 -0
  151. package/dist/types.d.ts.map +1 -1
  152. package/package.json +3 -1
package/dist/endpoint.js CHANGED
@@ -9,10 +9,10 @@ import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfi
9
9
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
10
10
  import { Kvm } from "@nats-io/kv";
11
11
  import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
12
- import { openAclRegistry, readAcl, commitAcl as writeAclRecord } from "./acls.js";
12
+ import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord } from "./acls.js";
13
13
  import { openDeliveryRegistry } from "./lease.js";
14
14
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
15
- import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_SELF_SERVICE, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
15
+ import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_SELF_SERVICE, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, 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";
16
16
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
17
17
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
18
18
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -168,6 +168,15 @@ export class CotalEndpoint extends EventEmitter {
168
168
  owner;
169
169
  /** This endpoint's actor token (principal half 2) — the connection id in the dev default. */
170
170
  actor;
171
+ /** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
172
+ ownLifecycleUid;
173
+ /** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
174
+ * ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
175
+ requireLifecycleUid(what) {
176
+ if (!this.ownLifecycleUid)
177
+ throw new Error(`${what} requires this endpoint's lifecycleUid (EndpointOptions.lifecycleUid): dm/dlv/chathist broker resources are lifecycle-keyed names (SPEC 13.1)`);
178
+ return this.ownLifecycleUid;
179
+ }
171
180
  constructor(opts) {
172
181
  super();
173
182
  this.space = opts.space;
@@ -235,6 +244,19 @@ export class CotalEndpoint extends EventEmitter {
235
244
  this.owner = opts.card.owner ?? DEV_OWNER;
236
245
  this.actor = opts.card.actor ?? this.connId;
237
246
  }
247
+ // The incarnation's lifecycle UID (SPEC §13.1). AUTH mode (JWT creds/bearer) REQUIRES the
248
+ // launcher to supply it — the dm/dlv/chathist durable names must match the exact names the
249
+ // provisioner minted into the credential, so a self-minted uid would name a durable the cred
250
+ // cannot bind; absent, `requireLifecycleUid` fails loud at the first consuming path. OPEN/token
251
+ // mode is self-identifying (one process is one lifecycle, no provisioner, no ledger, no
252
+ // same-alias respawn), so it mints its OWN uid here exactly as it self-assigns `connId`/`actor`
253
+ // — NOT an alias fallback (a fresh CSPRNG uid), just the open-mode identity source.
254
+ this.ownLifecycleUid =
255
+ opts.lifecycleUid !== undefined
256
+ ? assertLifecycleToken(opts.lifecycleUid)
257
+ : this.authed
258
+ ? undefined
259
+ : mintLifecycleUid();
238
260
  // `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
239
261
  // principalKey validates both tokens.
240
262
  const principal = principalKey(this.owner, this.actor);
@@ -437,12 +459,18 @@ export class CotalEndpoint extends EventEmitter {
437
459
  if (watchChannels)
438
460
  await this.startChannelWatch();
439
461
  }
440
- if (this.doRegister) {
441
- await this.publishPresence();
442
- this.heartbeatTimer = setInterval(() => {
443
- this.publishPresence().catch((e) => this.emit("error", e));
444
- }, this.heartbeatMs);
445
- }
462
+ // FAIL BEFORE PRESENCE (SPEC 13.1): an AUTHED endpoint that will register on the roster or
463
+ // bind lifecycle-keyed consumers must hold its launcher-supplied lifecycle uid BEFORE anything
464
+ // makes it visible - a missing uid must never leave a roster ghost that could not bind (false
465
+ // readiness). Non-registering, non-consuming infra endpoints (a provisioner window, the
466
+ // delivery daemon, one-shot CLI probes) bind no lifecycle-keyed names and stay outside the
467
+ // rule; open/token mode self-minted at construction, so this only ever throws for a
468
+ // mis-launched AUTH endpoint.
469
+ if (this.authed && (this.doConsume || this.doRegister))
470
+ this.requireLifecycleUid(this.doConsume ? "an authed consuming endpoint" : "an authed presence-registering endpoint");
471
+ // Consumers bind BEFORE presence publishes: the durable bind is the broker's proof that this
472
+ // incarnation's lifecycle-keyed names match its minted grants, so a wrong-uid launch dies
473
+ // with NO presence ghost instead of advertising an agent that can never receive.
446
474
  if (this.doConsume) {
447
475
  this.jsm = await jetstreamManager(this.nc);
448
476
  // Open mode: lazily create the streams on the first endpoint. Auth mode: they are
@@ -451,6 +479,31 @@ export class CotalEndpoint extends EventEmitter {
451
479
  await this.ensureStreams();
452
480
  await this.startConsumers();
453
481
  }
482
+ // The register-only broker proof: a consuming agent proved its lifecycle by binding a
483
+ // durable above, but an authed AGENT that only REGISTERS presence (consume:false) has bound
484
+ // nothing yet, so a wrong-but-valid uid would grammar-pass requireLifecycleUid and then
485
+ // advertise a ghost that can receive nothing. Prove the incarnation at the broker FIRST — an
486
+ // exact info on this agent's own pre-provisioned dm_<owner>-<actor>-<uid> durable (the
487
+ // manager/self-provision minted it under the SAME uid). A wrong uid names a durable that
488
+ // does not exist, so the info throws and presence never publishes. Only for `kind: "agent"`
489
+ // (a peer on the roster); pure `kind: "endpoint"` infra (manager, delivery, feedback intake)
490
+ // holds no dm_ durable and stays exempt.
491
+ if (this.doRegister && !this.doConsume && this.authed && this.card.kind === "agent") {
492
+ const jsm = this.jsm ?? (this.jsm = await jetstreamManager(this.nc));
493
+ const uid = this.requireLifecycleUid("an authed presence-registering agent");
494
+ try {
495
+ await jsm.consumers.info(dmStream(this.space), dmDurable(this.owner, this.actor, uid));
496
+ }
497
+ catch (e) {
498
+ throw new Error(`lifecycle proof failed for ${this.card.id} (uid ${uid}): its dm_ durable is not present at the broker, so this incarnation's uid does not match its provisioned resources - refusing to publish presence (SPEC 13.1 fail-before-presence): ${e?.message ?? String(e)}`);
499
+ }
500
+ }
501
+ if (this.doRegister) {
502
+ await this.publishPresence();
503
+ this.heartbeatTimer = setInterval(() => {
504
+ this.publishPresence().catch((e) => this.emit("error", e));
505
+ }, this.heartbeatMs);
506
+ }
454
507
  // Re-arm Plane-3 (delivery-daemon-hosted fan-out + trusted reader + ctl.delivery) on every (re)connect — no-op unless this
455
508
  // endpoint hosts it. The first arm comes from startPlane3 (after start()); this re-binds the loops
456
509
  // a reconnect's clearConnectionScoped() tore down, so a broker blip doesn't silently kill the backstop.
@@ -744,7 +797,9 @@ export class CotalEndpoint extends EventEmitter {
744
797
  }
745
798
  /** Subscribe to a read-only observer feed. Defaults to the whole space; an observer under
746
799
  * auth must pass `chatWildcard(space)` since its `sub.allow` only covers chat (DM/anycast
747
- * stay confidential), otherwise the space-wildcard subscribe is denied and the feed dies. */
800
+ * stay confidential), and an admin must tap the messaging planes individually
801
+ * (`chat`/`inst`/`svc` — its enumerated `sub.allow` excludes the v0.4 endpoint rails,
802
+ * SPEC 13.9/13.11), otherwise the space-wildcard subscribe is denied and the feed dies. */
748
803
  tap(handler, opts) {
749
804
  if (!this.nc)
750
805
  return;
@@ -915,16 +970,21 @@ export class CotalEndpoint extends EventEmitter {
915
970
  this.channelModes = Object.keys(modes).length ? modes : undefined;
916
971
  await this.publishPresence();
917
972
  }
918
- /** Overlay the host's live model onto the card's display-only `meta.model` and republish presence.
919
- * For connectors that learn the actual model only *after* launch (e.g. Claude Code's `SessionStart`
920
- * hook payload) rather than from an operator pin. Display-only discovery metadata; a no-op when the
921
- * value is empty or already current (no redundant publish). The mutated card is read live by every
922
- * later publish, so even a pre-connect call surfaces on the first presence write. */
923
- async setCardModel(model) {
973
+ /** Overlay the host's live model and optional variant onto the card's display-only metadata, then
974
+ * republish presence. For connectors that learn their actual selection only after launch (e.g.
975
+ * Claude Code's `SessionStart` hook). The mutated card is read live by every later publish, so even
976
+ * a pre-connect call surfaces on the first presence write. */
977
+ async setCardModel(model, variant) {
924
978
  const m = model.trim();
925
- if (!m || this.card.meta?.model === m)
979
+ const v = variant?.trim();
980
+ if (!m || (this.card.meta?.model === m && this.card.meta.variant === v))
926
981
  return;
927
- this.card.meta = { ...(this.card.meta ?? {}), model: m };
982
+ const meta = { ...(this.card.meta ?? {}), model: m };
983
+ if (v)
984
+ meta.variant = v;
985
+ else
986
+ delete meta.variant;
987
+ this.card.meta = meta;
928
988
  await this.publishPresence();
929
989
  }
930
990
  // ---- channel discovery ---------------------------------------------------
@@ -937,6 +997,11 @@ export class CotalEndpoint extends EventEmitter {
937
997
  channelReplay(channel) {
938
998
  return effectiveReplay(this.channelConfigs.get(channel), this.channelDefaults);
939
999
  }
1000
+ /** Effective replay window for a channel (per-channel override ?? space default), or undefined
1001
+ * for the full retained window. Only meaningful when {@link channelReplay} is true. */
1002
+ channelReplayWindow(channel) {
1003
+ return this.channelConfigs.get(channel)?.replayWindow ?? this.channelDefaults.replayWindow;
1004
+ }
940
1005
  /** Effective delivery class for a channel (per-channel override ?? space default ?? "durable"),
941
1006
  * from the live watch cache — drives the non-gating delivery-health surface (only durable-class
942
1007
  * channels have a Plane-3 backstop to report on). */
@@ -1147,7 +1212,7 @@ export class CotalEndpoint extends EventEmitter {
1147
1212
  try {
1148
1213
  asOf = e.json().observedAt;
1149
1214
  }
1150
- catch { /* heartbeat garbled leave undefined */ }
1215
+ catch { /* heartbeat garbled; leave undefined */ }
1151
1216
  continue;
1152
1217
  }
1153
1218
  try {
@@ -1272,9 +1337,47 @@ export class CotalEndpoint extends EventEmitter {
1272
1337
  * creating a durable filtered to someone else's inbox. Idempotent (byte-identical config),
1273
1338
  * safe to call again on manager restart. The caller must be permissive on DM_<space>.
1274
1339
  */
1275
- async provisionDmInbox(owner, actor) {
1340
+ async provisionDmInbox(owner, actor, lifecycleUid) {
1341
+ await this.ensureDmDurable(owner, actor, lifecycleUid, {});
1342
+ }
1343
+ /** Idempotent-PER-LIFECYCLE create of a `dm_<o>-<a>-<uid>` durable with its ACTIVATION FRONTIER
1344
+ * (SPEC :467). Info-first: an existing durable (a manager-restart re-provision of the SAME uid, or
1345
+ * the same lifecycle's own restart) is kept as-is, preserving the ORIGINAL frontier — the
1346
+ * activation moment never moves. A fresh lifecycle captures the DM stream's current `last_seq` and
1347
+ * starts delivery at frontier+1, so a same-alias successor inherits none of the predecessor's
1348
+ * pending DMs (its filter is the shared alias subject `inst.>`; the FRONTIER, not the subject, is
1349
+ * the cut).
1350
+ *
1351
+ * HONESTY (panel-locked): the no-gap guarantee (a DM published between the capture and the create
1352
+ * lands ABOVE the frontier and is delivered) holds ONLY under ONE provisioner per lifecycle uid —
1353
+ * the manager provisions sequentially, which satisfies it. Under CONCURRENT same-uid provisioners
1354
+ * (a split-brain manager), a higher-frontier winner excludes the loser's N+1..M capture window:
1355
+ * the lost-race branch below keeps the winner's durable unconditionally. No per-uid serialization
1356
+ * or persisted-frontier machinery is added in this slice; concurrent same-uid provisioning is
1357
+ * out-of-contract (at-least-once best-effort). */
1358
+ async ensureDmDurable(owner, actor, lifecycleUid, opts) {
1276
1359
  const jsm = await this.manager();
1277
- await jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, owner, actor));
1360
+ const stream = dmStream(this.space);
1361
+ const name = dmDurable(owner, actor, lifecycleUid);
1362
+ try {
1363
+ await jsm.consumers.info(stream, name);
1364
+ return; // this lifecycle's durable exists — keep its original frontier
1365
+ }
1366
+ catch { /* absent; create below */ }
1367
+ const frontier = (await jsm.streams.info(stream)).state.last_seq;
1368
+ try {
1369
+ await jsm.consumers.add(stream, dmDurableConfig(this.space, owner, actor, lifecycleUid, { ...opts, activationFrontier: frontier }));
1370
+ }
1371
+ catch (e) {
1372
+ // A concurrent same-lifecycle provisioner may have won the create with an earlier frontier —
1373
+ // if the durable now exists it is authoritative; anything else stays a loud failure.
1374
+ try {
1375
+ await jsm.consumers.info(stream, name);
1376
+ return;
1377
+ }
1378
+ catch { /* not a lost race */ }
1379
+ throw e;
1380
+ }
1278
1381
  }
1279
1382
  /**
1280
1383
  * Privileged: pre-create an agent's bind-only Plane-3 DELIVER durable (`dlv_<id>`, filtered to
@@ -1283,9 +1386,9 @@ export class CotalEndpoint extends EventEmitter {
1283
1386
  * the agent never does. The trusted reader transfers re-authorized copies onto `dlv.<id>`; the agent
1284
1387
  * acks them via native JetStream (SPEC §8). Idempotent. The caller must be permissive on DLV.
1285
1388
  */
1286
- async provisionDlvInbox(owner, actor) {
1389
+ async provisionDlvInbox(owner, actor, lifecycleUid) {
1287
1390
  const jsm = await this.manager();
1288
- await jsm.consumers.add(dlvStream(this.space), dlvDurableConfig(this.space, owner, actor));
1391
+ await jsm.consumers.add(dlvStream(this.space), dlvDurableConfig(this.space, owner, actor, lifecycleUid));
1289
1392
  }
1290
1393
  /**
1291
1394
  * Privileged: pre-create a role's shared TASK work-queue durable (auth mode), so agents
@@ -1326,14 +1429,23 @@ export class CotalEndpoint extends EventEmitter {
1326
1429
  * delivery daemon can re-authorize the agent's durable entries and validate its runtime
1327
1430
  * durable-joins without holding any in-memory ledger. Written ATOMICALLY ({@link writeAclRecord}),
1328
1431
  * so a present record is always complete (`[]` = known no-read, never a half-write). */
1329
- async commitAcl(targetId, allowSubscribe) {
1330
- await writeAclRecord(await this.aclRegistry(), targetId, allowSubscribe);
1331
- }
1332
- /** The server-side delivery daemon's fresh-per-entry ACL read: an owner's CURRENT read ACL
1333
- * (`allowSubscribe`) from the durable registry, or `undefined` if no record (an unknown owner — the
1334
- * reader DEFERS, never drops). A present `[]` (known no-read) returns `[]` (the reader DROPS). */
1335
- async aclForOwner(owner) {
1336
- return (await readAcl(await this.aclRegistry(), owner))?.record.allowSubscribe;
1432
+ async commitAcl(targetId, lifecycleUid, allowSubscribe) {
1433
+ await writeAclRecord(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
1434
+ }
1435
+ /** The server-side delivery daemon's fresh-per-entry ACL read: one LIFECYCLE's current read ACL
1436
+ * (`allowSubscribe`) from the durable registry (exact key `<owner>.<actor>.<uid>`), or `undefined`
1437
+ * if no record (an unknown lifecycle — the reader DEFERS, never drops). A present `[]` (known
1438
+ * no-read) returns `[]` (the reader DROPS). */
1439
+ async aclForOwner(owner, lifecycleUid) {
1440
+ return (await readAcl(await this.aclRegistry(), owner, lifecycleUid))?.record.allowSubscribe;
1441
+ }
1442
+ /** Resolve an ALIAS to its single live lifecycle-keyed ACL row (`readAclForAlias`): the daemon's
1443
+ * authz seam for callers that arrive with alias identity only (a `ctl.delivery` durable-join).
1444
+ * THROWS {@link AmbiguousAclAlias} on two live rows — first-match would let a stale lifecycle
1445
+ * authorize the successor (SPEC 13.1: at most one live lifecycle per alias). */
1446
+ async aclForAlias(principal) {
1447
+ const row = await readAclForAlias(await this.aclRegistry(), principal);
1448
+ return row === undefined ? undefined : { allowSubscribe: row.record.allowSubscribe, lifecycleUid: row.lifecycleUid };
1337
1449
  }
1338
1450
  /** Lazily open the delivery lease/readiness KV (pre-created at `cotal up`; bind, never create). */
1339
1451
  async deliveryRegistry() {
@@ -1464,10 +1576,15 @@ export class CotalEndpoint extends EventEmitter {
1464
1576
  * but the non-activated ones are returned too so `leaveChannel` can discover + close a record that
1465
1577
  * still routes under the pure-interval predicate (a crash-stuck pending activation) — without reading
1466
1578
  * the privileged KV itself. */
1467
- async ownerMemberships(owner) {
1579
+ async ownerMemberships(owner, lifecycleUid) {
1580
+ // LIFECYCLE-EXACT (SPEC 13.1): an alias-wide listing would hand a same-alias successor the
1581
+ // PREDECESSOR's rows/generations (both incarnations share the alias), and a first-match consumer
1582
+ // like leaveChannel could then act on the wrong incarnation's state. Rows are filtered to the
1583
+ // caller's own uid; the uid is caller-asserted but confined to its own authenticated alias, the
1584
+ // same trust shape as durableLeave.
1468
1585
  const recs = await listMembers(await this.membersRegistry(), { owner });
1469
1586
  return recs
1470
- .filter((r) => r.leaveCursor === undefined)
1587
+ .filter((r) => r.lifecycleUid === lifecycleUid && r.leaveCursor === undefined)
1471
1588
  .map((r) => ({ channel: r.channel, generation: r.generation, activated: r.activated === true }));
1472
1589
  }
1473
1590
  /** Effective delivery class read AUTHORITATIVELY from the registry KV (not the watch cache) — so a
@@ -1488,17 +1605,21 @@ export class CotalEndpoint extends EventEmitter {
1488
1605
  const matches = [...this.roster.values()].filter((p) => p.card.name.toLowerCase() === name.toLowerCase());
1489
1606
  return matches.length === 1 ? matches[0].card.id : undefined;
1490
1607
  }
1491
- /** Publish one fan-out entry into a member principal's mixed inbox, idempotent via `Nats-Msg-Id`
1492
- * (`<msgId>:<principal>:<generation>`) so a catch-up copy and a racing fan-out copy collapse. The
1493
- * `principal` is the member's owner+actor dot-form (dinbox is per-agent); split for the subject. */
1494
- async publishDinbox(principal, entry) {
1608
+ /** Publish one fan-out entry into a member LIFECYCLE's mixed inbox (`dinbox.<o>.<a>.<uid>`, SPEC
1609
+ * §13.1: fan-out addresses the member row's RECORDED lifecycle, never the alias's current
1610
+ * occupant), idempotent via `Nats-Msg-Id` (`<msgId>:<principal>:<generation>`) so a catch-up copy
1611
+ * and a racing fan-out copy collapse. The `principal` is the member's owner+actor dot-form. */
1612
+ async publishDinbox(principal, lifecycleUid, entry) {
1495
1613
  if (!this.js)
1496
1614
  return;
1497
1615
  const p = parsePrincipalKey(principal);
1498
1616
  if (!p)
1499
1617
  throw new Error(`publishDinbox: "${principal}" is not a valid member principal <owner>.<actor>`);
1500
- await this.js.publish(dinboxSubject(this.space, p.owner, p.actor), JSON.stringify(entry), {
1501
- msgID: `${entry.msg.id}:${principal}:${entry.generation}`,
1618
+ await this.js.publish(dinboxSubject(this.space, p.owner, p.actor, lifecycleUid), JSON.stringify(entry), {
1619
+ // JetStream dedupe is STREAM-WIDE, so the id must carry the LIFECYCLE too: with an alias-keyed
1620
+ // id, lifecycle A's copy would suppress a same-alias successor B's copy of the same message
1621
+ // (both start at generation 1) — cross-lifecycle suppression, not dedup.
1622
+ msgID: `${entry.msg.id}:${principal}:${lifecycleUid}:${entry.generation}`,
1502
1623
  });
1503
1624
  }
1504
1625
  /** The fan-out consumer's delivered stream-seq — the activation-fence upper bound (red-team
@@ -1518,12 +1639,12 @@ export class CotalEndpoint extends EventEmitter {
1518
1639
  * Runs on the daemon (which hosts the fan-out/reader loops + the members KV), so catch-up + the
1519
1640
  * activation fence read are in-process — no cross-process cursor read.
1520
1641
  */
1521
- async durableJoinFor(owner, channel) {
1642
+ async durableJoinFor(owner, channel, lifecycleUid) {
1522
1643
  if (!this.js)
1523
1644
  throw new Error("endpoint not started");
1524
1645
  await this.manager(); // ensure jsm — a non-consuming provisioner inits it lazily; catch-up + fence need it
1525
1646
  const kv = await this.membersRegistry();
1526
- const existing = await readMember(kv, channel, owner);
1647
+ const existing = await readMember(kv, channel, owner, lifecycleUid);
1527
1648
  const open = existing?.record.state === "durable-active" && existing.record.leaveCursor === undefined;
1528
1649
  if (open && existing.record.activated)
1529
1650
  return { durable: true, generation: existing.record.generation }; // fully activated — idempotent
@@ -1538,13 +1659,13 @@ export class CotalEndpoint extends EventEmitter {
1538
1659
  const joinCursor = open ? existing.record.joinCursor : await this.chatFrontier();
1539
1660
  const generation = open ? existing.record.generation : (existing?.record.generation ?? 0) + 1;
1540
1661
  const base = {
1541
- channel, owner, state: "durable-active", joinCursor, generation,
1662
+ channel, owner, lifecycleUid, state: "durable-active", joinCursor, generation,
1542
1663
  activated: false, writerIdentity: this.card.id, updatedAt: Date.now(),
1543
1664
  };
1544
1665
  if (!open)
1545
1666
  await commitMember(kv, base);
1546
1667
  const fence = Math.max(await this.chatFrontier(), await this.fanoutDeliveredSeq());
1547
- const cu = await this.catchupCopy(owner, channel, joinCursor, fence, generation);
1668
+ const cu = await this.catchupCopy(owner, lifecycleUid, channel, joinCursor, fence, generation);
1548
1669
  if (cu.evicted) {
1549
1670
  // Catch-up window irreparably evicted (the oldest in-window message aged out) — this join can never
1550
1671
  // be a complete backstop. TOMBSTONE the just-committed record at `fence` so it does NOT route:
@@ -1554,7 +1675,7 @@ export class CotalEndpoint extends EventEmitter {
1554
1675
  // one won, StaleMembershipWrite is the correct no-op (the rejoin is the live record). Then degrade
1555
1676
  // honestly — a retry is a fresh join (no longer `open`, so a current joinCursor is captured).
1556
1677
  try {
1557
- await tombstoneMember(kv, channel, owner, fence, this.card.id, generation);
1678
+ await tombstoneMember(kv, channel, owner, lifecycleUid, fence, this.card.id, generation);
1558
1679
  }
1559
1680
  catch (e) {
1560
1681
  if (!(e instanceof StaleMembershipWrite))
@@ -1565,26 +1686,26 @@ export class CotalEndpoint extends EventEmitter {
1565
1686
  // Flip → reported durable, ATOMICALLY: refuse if a concurrent SAME-generation leave (tombstone) or a
1566
1687
  // rejoin superseded this pending join while catch-up ran. A blind same-gen commit would clobber the
1567
1688
  // tombstone (clear leaveCursor) and resurrect the membership, reopening §7 (review-general-2 BLOCKER).
1568
- const activated = await activateMember(kv, channel, owner, generation, joinCursor);
1689
+ const activated = await activateMember(kv, channel, owner, lifecycleUid, generation, joinCursor);
1569
1690
  if (!activated)
1570
1691
  return { durable: false, reason: "activation superseded by a concurrent leave or rejoin", generation };
1571
1692
  return { durable: true, generation };
1572
1693
  }
1573
1694
  /** Privileged durable-LEAVE write: tombstone the membership at `leaveCursor = frontier` so the
1574
1695
  * backstop denies `seq > leaveCursor` while a pre-leave entry stays deliverable (SPEC §7 interval). */
1575
- async durableLeaveFor(owner, channel, expectedGeneration) {
1696
+ async durableLeaveFor(owner, channel, lifecycleUid, expectedGeneration) {
1576
1697
  if (!this.plane3)
1577
1698
  return; // not a Plane-3 host — no membership to tombstone
1578
1699
  const kv = await this.membersRegistry();
1579
1700
  // expectedGeneration (captured by the agent at durableJoin) refuses a stale leave from tombstoning
1580
1701
  // a newer rejoin (StaleMembershipWrite) — a durable-disable primitive otherwise.
1581
- await tombstoneMember(kv, channel, owner, await this.chatFrontier(), this.card.id, expectedGeneration);
1702
+ await tombstoneMember(kv, channel, owner, lifecycleUid, await this.chatFrontier(), this.card.id, expectedGeneration);
1582
1703
  }
1583
1704
  /** Idempotently copy the eligible chat messages in `(fromSeqExcl, toSeqIncl]` for `channel` into the
1584
1705
  * owner inbox, via a DEDICATED per-(owner,join) ephemeral consumer (NOT the agent-scoped
1585
1706
  * `chathist_<id>`/`histLock` — red-team HIGH-8). `evicted` ⇒ the oldest eligible seq aged out under
1586
1707
  * `discard=Old` (the start seq could not be served), a durable shortfall the caller surfaces. */
1587
- async catchupCopy(owner, channel, fromSeqExcl, toSeqIncl, generation) {
1708
+ async catchupCopy(owner, lifecycleUid, channel, fromSeqExcl, toSeqIncl, generation) {
1588
1709
  if (!this.js || !this.jsm || toSeqIncl <= fromSeqExcl)
1589
1710
  return { copied: 0, evicted: false };
1590
1711
  const subject = chatSubject(this.space, "*", "*", channel);
@@ -1597,9 +1718,11 @@ export class CotalEndpoint extends EventEmitter {
1597
1718
  // Consumer NAME must be JetStream-safe (no `.`) AND collision-free: use the principal DASH-form
1598
1719
  // (`<owner>-<actor>`, `-` reserved as the sole separator), NOT `token(owner)` — `token()` maps the
1599
1720
  // dot-form `.`→`_`, which is NOT collision-free (`_` is legal inside a token, so `a.b_c` and `a_b.c`
1600
- // would both underscore to `a_b_c`).
1721
+ // would both underscore to `a_b_c`). LIFECYCLE-KEYED (SPEC 13.1): generations restart at 1 per
1722
+ // lifecycle, so an alias-keyed `cu_<principal>_<gen>` would let a same-alias successor
1723
+ // delete/recreate a predecessor's in-flight catch-up consumer — the uid disambiguates.
1601
1724
  const cuP = parsePrincipalKey(owner);
1602
- const name = `cu_${cuP ? principalKey(cuP.owner, cuP.actor).name : token(owner)}_${generation}`;
1725
+ const name = `cu_${cuP ? lifecycleNameKey(cuP.owner, cuP.actor, lifecycleUid) : `${token(owner)}-${lifecycleUid}`}_${generation}`;
1603
1726
  try {
1604
1727
  await this.jsm.consumers.delete(chatStream(this.space), name);
1605
1728
  }
@@ -1630,7 +1753,7 @@ export class CotalEndpoint extends EventEmitter {
1630
1753
  const parsed = parseSubject(m.subject);
1631
1754
  if (!parsed || msg.from?.id !== parsed.sender || !isPrincipalOwnerToken(parsed.owner) || msg.from.id === owner)
1632
1755
  continue;
1633
- await this.publishDinbox(owner, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
1756
+ await this.publishDinbox(owner, lifecycleUid, { msg, channel, seq: m.seq, reason: "durable-channel", generation });
1634
1757
  copied++;
1635
1758
  }
1636
1759
  if (got < want)
@@ -1656,7 +1779,7 @@ export class CotalEndpoint extends EventEmitter {
1656
1779
  async startPlane3(aclFor, opts = {}) {
1657
1780
  if (!this.js)
1658
1781
  throw new Error("endpoint not started");
1659
- this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal };
1782
+ this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness };
1660
1783
  await this.armPlane3();
1661
1784
  }
1662
1785
  /** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
@@ -1670,8 +1793,18 @@ export class CotalEndpoint extends EventEmitter {
1670
1793
  return this.deliveryJoin(caller, args);
1671
1794
  if (req.op === "durableLeave")
1672
1795
  return this.deliveryLeave(caller, args);
1673
- if (req.op === "listMemberships")
1674
- return { ok: true, data: { memberships: await this.ownerMemberships(caller) } };
1796
+ if (req.op === "listMemberships") {
1797
+ if (typeof args.lifecycleUid !== "string")
1798
+ return { ok: false, error: "listMemberships: the caller's lifecycleUid is required (membership rows are lifecycle-keyed, SPEC 13.1)" };
1799
+ let uid;
1800
+ try {
1801
+ uid = assertLifecycleToken(args.lifecycleUid);
1802
+ }
1803
+ catch (e) {
1804
+ return { ok: false, error: e.message };
1805
+ }
1806
+ return { ok: true, data: { memberships: await this.ownerMemberships(caller, uid) } };
1807
+ }
1675
1808
  return { ok: false, error: `op "${req.op}" not supported on the delivery control service` };
1676
1809
  }
1677
1810
  /** Validate the channel ARG shape only — non-blank, valid, concrete (NO ACL check, that is op-specific).
@@ -1696,13 +1829,23 @@ export class CotalEndpoint extends EventEmitter {
1696
1829
  const channel = this.checkDurableChannelArg(args, "durableJoin");
1697
1830
  if (typeof channel !== "string")
1698
1831
  return channel; // a ControlReply error
1699
- const acl = await readAcl(await this.aclRegistry(), caller);
1832
+ // The caller arrives as an ALIAS (the control subject carries owner+actor, never a uid): resolve
1833
+ // its single live lifecycle-keyed ACL row SERVER-SIDE — the trusted registry, never a caller
1834
+ // assertion, decides which lifecycle joins. Two live rows (a reservation breach / unfinished
1835
+ // teardown) refuse loudly rather than binding a membership to a guessed lifecycle.
1836
+ let acl;
1837
+ try {
1838
+ acl = await this.aclForAlias(caller);
1839
+ }
1840
+ catch (e) {
1841
+ return { ok: false, error: e.message };
1842
+ }
1700
1843
  if (acl === undefined)
1701
1844
  return { ok: false, error: `durableJoin: no read ACL on record for ${caller} (not provisioned for durable delivery)` };
1702
- if (!channelInAllow(acl.record.allowSubscribe, channel))
1703
- return { ok: false, error: `channel "${channel}" is not within your read ACL [${acl.record.allowSubscribe.join(", ")}]` };
1845
+ if (!channelInAllow(acl.allowSubscribe, channel))
1846
+ return { ok: false, error: `channel "${channel}" is not within your read ACL [${acl.allowSubscribe.join(", ")}]` };
1704
1847
  try {
1705
- return { ok: true, data: await this.durableJoinFor(caller, channel) };
1848
+ return { ok: true, data: await this.durableJoinFor(caller, channel, acl.lifecycleUid) };
1706
1849
  }
1707
1850
  catch (e) {
1708
1851
  return { ok: false, error: e.message };
@@ -1720,11 +1863,26 @@ export class CotalEndpoint extends EventEmitter {
1720
1863
  return channel; // a ControlReply error
1721
1864
  if (typeof args.generation !== "number" || !Number.isFinite(args.generation))
1722
1865
  return { ok: false, error: "durableLeave: a finite generation is required (fail-closed stale-leave guard)" };
1723
- const existing = await readMember(await this.membersRegistry(), channel, caller);
1866
+ // The LEAVE carries the leaver's own lifecycleUid: leave must work precisely when the ACL row was
1867
+ // narrowed or already purged (see the method doc), so the alias→row resolution join uses is not
1868
+ // available here. The uid is caller-asserted but harmless to lie about: the member key it selects
1869
+ // is confined to the AUTHENTICATED caller's own alias (`<channel>/<caller>.<uid>`), so the worst a
1870
+ // false uid reaches is the caller's own retired incarnation's row — legitimate cleanup — and the
1871
+ // generation guard still applies.
1872
+ if (typeof args.lifecycleUid !== "string")
1873
+ return { ok: false, error: "durableLeave: the leaving incarnation's lifecycleUid is required (membership rows are lifecycle-keyed, SPEC 13.1)" };
1874
+ let uid;
1875
+ try {
1876
+ uid = assertLifecycleToken(args.lifecycleUid);
1877
+ }
1878
+ catch (e) {
1879
+ return { ok: false, error: e.message };
1880
+ }
1881
+ const existing = await readMember(await this.membersRegistry(), channel, caller, uid);
1724
1882
  if (!existing)
1725
1883
  return { ok: true, data: { channel, alreadyLeft: true } }; // nothing to tombstone — idempotent
1726
1884
  try {
1727
- await this.durableLeaveFor(caller, channel, args.generation);
1885
+ await this.durableLeaveFor(caller, channel, uid, args.generation);
1728
1886
  }
1729
1887
  catch (e) {
1730
1888
  return { ok: false, error: e.message };
@@ -1805,6 +1963,21 @@ export class CotalEndpoint extends EventEmitter {
1805
1963
  return { ok: false, error: e.message };
1806
1964
  }
1807
1965
  }
1966
+ if (req.op === "planeConnLiveness") {
1967
+ // The plane-claim liveness oracle (#29 HIGH 3): a CLOSED read-only verb — two claimed
1968
+ // scanner tuples in, two bound verdicts + sweep completeness out. The executor hook owns
1969
+ // the closed query validation (it holds the $SYS observer cred outside this trust boundary);
1970
+ // absent hook = a daemon build without the oracle, refused loudly (the auth plane treats
1971
+ // that refusal as UNKNOWN and never reclaims over it).
1972
+ if (!this.plane3?.planeConnLiveness)
1973
+ return { ok: false, error: "planeConnLiveness: no plane-liveness oracle wired on this daemon" };
1974
+ try {
1975
+ return { ok: true, data: await this.plane3.planeConnLiveness(req.args?.query) };
1976
+ }
1977
+ catch (e) {
1978
+ return { ok: false, error: e.message };
1979
+ }
1980
+ }
1808
1981
  return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
1809
1982
  }
1810
1983
  /** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
@@ -1866,13 +2039,11 @@ export class CotalEndpoint extends EventEmitter {
1866
2039
  continue; // never backstop the sender's own post
1867
2040
  if (!durableEligible(rec, seq))
1868
2041
  continue; // routing fast-filter (reader re-checks)
1869
- await this.publishDinbox(rec.owner, {
1870
- msg: normalizedMsg,
1871
- channel,
1872
- seq,
1873
- reason: "durable-channel",
1874
- generation: rec.generation,
1875
- });
2042
+ // Address the member row's RECORDED lifecycle: a retired row (tombstone pending) routes to the
2043
+ // retired lifecycle's inbox, never the alias's new occupant (SPEC 13.1 cross-plane scoping).
2044
+ // Store the AUTHENTICATED-channel copy (main's normalization): the durable frame validates
2045
+ // msg.channel === frame.channel, and payload to/toService are stripped.
2046
+ await this.publishDinbox(rec.owner, rec.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "durable-channel", generation: rec.generation });
1876
2047
  }
1877
2048
  }
1878
2049
  else {
@@ -1880,16 +2051,20 @@ export class CotalEndpoint extends EventEmitter {
1880
2051
  const owner = this.resolveOwnerByName(name);
1881
2052
  if (!owner || owner === msg.from.id)
1882
2053
  continue;
1883
- const acl = await this.plane3?.aclFor(owner);
1884
- if (!acl || !channelInAllow(acl, channel))
2054
+ // A live-mention target arrives as an ALIAS (the roster names no lifecycle): resolve its single
2055
+ // live ACL row for BOTH the read-authorization and the lifecycle to address. Ambiguity (two
2056
+ // live rows) refuses THIS copy loudly rather than guessing a lifecycle.
2057
+ let row;
2058
+ try {
2059
+ row = await this.aclForAlias(owner);
2060
+ }
2061
+ catch (e) {
2062
+ this.emit("error", e);
2063
+ continue;
2064
+ }
2065
+ if (!row || !channelInAllow(row.allowSubscribe, channel))
1885
2066
  continue; // @mention can't bypass the read ACL
1886
- await this.publishDinbox(owner, {
1887
- msg: normalizedMsg,
1888
- channel,
1889
- seq,
1890
- reason: "live-mention",
1891
- generation: 0,
1892
- });
2067
+ await this.publishDinbox(owner, row.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "live-mention", generation: 0 });
1893
2068
  }
1894
2069
  }
1895
2070
  m.ack();
@@ -1931,7 +2106,7 @@ export class CotalEndpoint extends EventEmitter {
1931
2106
  if (!pr) {
1932
2107
  m.ack();
1933
2108
  return;
1934
- } // unparseable subject — not a real entry
2109
+ } // unparseable subject (incl. a pre-cut 5-segment form) — not a real entry
1935
2110
  const owner = `${pr.owner}.${pr.actor}`; // the member principal dot-form (acl/member keys, msgID)
1936
2111
  let entry;
1937
2112
  try {
@@ -1942,7 +2117,10 @@ export class CotalEndpoint extends EventEmitter {
1942
2117
  return;
1943
2118
  } // undecodable — drop
1944
2119
  const redeliveries = m.info?.deliveryCount ?? 1; // JsMsg delivery attempts (1 on first delivery)
1945
- const acl = await this.plane3?.aclFor(owner);
2120
+ // Lifecycle-exact ACL re-auth (SPEC 13.1): the entry was addressed to pr.lifecycleUid's inbox, so
2121
+ // the row read is that lifecycle's exact key — a retired lifecycle's purged row reads as unknown
2122
+ // and its residual entries terminate at the redelivery ceiling, never against the successor's row.
2123
+ const acl = await this.plane3?.aclFor(owner, pr.lifecycleUid);
1946
2124
  if (acl === undefined) {
1947
2125
  // UNKNOWN owner — the manager has not (re)hydrated this owner's ACL yet (e.g. right after a
1948
2126
  // manager PROCESS restart). This is NOT a revocation: DEFER (redeliver), never drop — an ack here
@@ -1965,7 +2143,7 @@ export class CotalEndpoint extends EventEmitter {
1965
2143
  return;
1966
2144
  }
1967
2145
  if (entry.reason === "durable-channel") {
1968
- const rec = await readMember(await this.membersRegistry(), entry.channel, owner);
2146
+ const rec = await readMember(await this.membersRegistry(), entry.channel, owner, pr.lifecycleUid);
1969
2147
  // INTERVAL re-auth (not a current-member boolean): a pre-leave entry (seq ≤ leaveCursor) stays
1970
2148
  // deliverable; seq > leaveCursor (or after a rejoin's newer joinCursor) is the hard cut.
1971
2149
  if (!rec || !durableEligible(rec.record, entry.seq)) {
@@ -1983,8 +2161,11 @@ export class CotalEndpoint extends EventEmitter {
1983
2161
  };
1984
2162
  const frameHeaders = headers();
1985
2163
  frameHeaders.set(PLANE3_FRAME_HEADER, "1");
1986
- await this.js.publish(dlvSubject(this.space, pr.owner, pr.actor), JSON.stringify(frame), {
1987
- msgID: `${entry.msg.id}:${owner}:${entry.generation}`,
2164
+ // Lifecycle-keyed subject + msgID (ours): the DLV consumer filters on the lifecycle-scoped
2165
+ // subject (streams.ts filter_subject), and a predecessor's transferred copy must never suppress
2166
+ // a successor's under the same alias (disjoint lifecycles, stream-wide dedupe).
2167
+ await this.js.publish(dlvSubject(this.space, pr.owner, pr.actor, pr.lifecycleUid), JSON.stringify(frame), {
2168
+ msgID: `${entry.msg.id}:${owner}:${pr.lifecycleUid}:${entry.generation}`,
1988
2169
  headers: frameHeaders,
1989
2170
  });
1990
2171
  }
@@ -2009,9 +2190,11 @@ export class CotalEndpoint extends EventEmitter {
2009
2190
  async pumpDlv() {
2010
2191
  if (!this.js)
2011
2192
  return;
2193
+ if (!this.ownLifecycleUid)
2194
+ return; // no lifecycle uid — never provisioned for Plane-3 (its durable is lifecycle-keyed)
2012
2195
  let consumer;
2013
2196
  try {
2014
- consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor));
2197
+ consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor, this.ownLifecycleUid));
2015
2198
  }
2016
2199
  catch {
2017
2200
  return;
@@ -2071,9 +2254,12 @@ export class CotalEndpoint extends EventEmitter {
2071
2254
  return reply.data ?? { durable: false };
2072
2255
  }
2073
2256
  /** Agent-side: release a Plane-3 durable backstop (tombstone membership at the leave cursor). Passes
2074
- * the join generation so a stale leave can't tombstone a newer rejoin (the delivery daemon validates it). */
2257
+ * the join generation so a stale leave can't tombstone a newer rejoin (the delivery daemon validates
2258
+ * it) AND this incarnation's lifecycleUid — membership rows are lifecycle-keyed (SPEC 13.1), and a
2259
+ * leave must resolve its OWN row even after the ACL row was narrowed or purged. */
2075
2260
  async durableLeaveChannel(channel, generation) {
2076
- const reply = await this.requestDelivery("durableLeave", { channel, generation });
2261
+ const lifecycleUid = this.requireLifecycleUid("leaving a durable channel");
2262
+ const reply = await this.requestDelivery("durableLeave", { channel, generation, lifecycleUid });
2077
2263
  if (!reply.ok)
2078
2264
  throw new Error(reply.error ?? "durable leave rejected");
2079
2265
  }
@@ -2121,7 +2307,7 @@ export class CotalEndpoint extends EventEmitter {
2121
2307
  async fetchMemberships() {
2122
2308
  let reply;
2123
2309
  try {
2124
- reply = await this.requestDelivery("listMemberships", {}, 5_000);
2310
+ reply = await this.requestDelivery("listMemberships", { lifecycleUid: this.requireLifecycleUid("listing durable memberships") }, 5_000);
2125
2311
  }
2126
2312
  catch (e) {
2127
2313
  if (this.isNoResponders(e))
@@ -2220,13 +2406,17 @@ export class CotalEndpoint extends EventEmitter {
2220
2406
  // Unicast: this instance's private DM inbox, keyed on this endpoint's owner+actor principal. Open
2221
2407
  // mode self-creates; auth mode BINDS a durable the provisioner pre-created (agents are denied
2222
2408
  // CONSUMER.CREATE on DM_<space>, since the create-time filter_subject is the attack surface).
2409
+ const ownUid = this.requireLifecycleUid("consuming the DM inbox");
2223
2410
  if (!this.authed) {
2224
- await this.jsm.consumers.add(dmStream(this.space), dmDurableConfig(this.space, this.owner, this.actor, {
2411
+ // Open-mode self-create rides the SAME per-lifecycle ensure as the privileged pre-create: an
2412
+ // existing `dm_…-<uid>` durable (this lifecycle's own restart) is kept with its ORIGINAL
2413
+ // activation frontier; a fresh lifecycle captures its frontier at creation.
2414
+ await this.ensureDmDurable(this.owner, this.actor, ownUid, {
2225
2415
  ackWaitMs: this.ackWaitMs,
2226
2416
  inactiveThresholdMs: this.inactiveThresholdMs,
2227
- }));
2417
+ });
2228
2418
  }
2229
- await this.pump(dmStream(this.space), dmDurable(this.owner, this.actor));
2419
+ await this.pump(dmStream(this.space), dmDurable(this.owner, this.actor, ownUid));
2230
2420
  // Plane-3 (SPEC §8): bind + pump our per-member DELIVER durable (`dlv_<id>`) — the re-authorized
2231
2421
  // durable-backstop channel copies the trusted reader transfers to us. No-op when it isn't present
2232
2422
  // (open mode / un-provisioned). Auth-only feature; the pump self-guards on the durable's existence.
@@ -2513,14 +2703,14 @@ export class CotalEndpoint extends EventEmitter {
2513
2703
  if (!this.jsm || !this.js)
2514
2704
  throw new Error("endpoint not started");
2515
2705
  const stream = chatStream(this.space);
2516
- const name = chatHistDurable(this.owner, this.actor);
2706
+ const name = chatHistDurable(this.owner, this.actor, this.requireLifecycleUid("chat history reads"));
2517
2707
  const out = [];
2518
2708
  // Clear any consumer leaked by a crashed prior read before re-creating it with THIS read's
2519
2709
  // single filter (the read ACL is enforced at create — see the doc above).
2520
2710
  try {
2521
2711
  await this.jsm.consumers.delete(stream, name);
2522
2712
  }
2523
- catch { /* none fine */ }
2713
+ catch { /* none; fine */ }
2524
2714
  await this.jsm.consumers.add(stream, {
2525
2715
  name,
2526
2716
  filter_subject: subject,
@@ -2691,6 +2881,9 @@ export class CotalEndpoint extends EventEmitter {
2691
2881
  return; // observers watch but never publish their own record
2692
2882
  const p = {
2693
2883
  card: this.card,
2884
+ // SPEC §6/:315: presence carries the incarnation's lifecycle UID (MUST in auth mode from v0.4);
2885
+ // omitted only where the endpoint has none (a pure operator/daemon connection never registers).
2886
+ ...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
2694
2887
  status: this.status,
2695
2888
  activity: this.activity,
2696
2889
  attention: this.attentionMode,
@@ -2794,9 +2987,16 @@ export class CotalEndpoint extends EventEmitter {
2794
2987
  }
2795
2988
  // Heartbeat refresh with no real change: bump liveness quietly and don't
2796
2989
  // emit — otherwise the periodic keep-alive looks like a stream of "updates".
2990
+ // A CHANGED lifecycleUid is never a heartbeat: it is a NEW LIFECYCLE reusing the same principal (a
2991
+ // terminal same-name recreation — in user mode a retired lifecycle and its same-name successor
2992
+ // share `<owner>.<actor>`; a supervised respawn keeps the uid and is unaffected), so it MUST notify
2993
+ // watchers even when its status/activity match the prior lifecycle's lingering record. Omitting this
2994
+ // left a same-principal successor invisible to presence-event consumers (e.g. the manager's
2995
+ // readiness race), which then timed out "uncertain" on a healthy agent (#29).
2797
2996
  if (prev &&
2798
2997
  prev.status !== "offline" &&
2799
2998
  p.status !== "offline" &&
2999
+ prev.lifecycleUid === p.lifecycleUid &&
2800
3000
  prev.status === p.status &&
2801
3001
  prev.activity === p.activity &&
2802
3002
  prev.attention === p.attention &&