@cotal-ai/core 0.11.6 → 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.
- package/dist/acls.d.ts +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +90 -10
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup-config.d.ts +55 -0
- package/dist/backup-config.d.ts.map +1 -0
- package/dist/backup-config.js +243 -0
- package/dist/backup-config.js.map +1 -0
- package/dist/backup.d.ts +86 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +497 -0
- package/dist/backup.js.map +1 -0
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +8 -1
- package/dist/channels.js.map +1 -1
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +78 -20
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +214 -37
- package/dist/provision.js.map +1 -1
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/secret-store.d.ts +46 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +2 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/space-auth.d.ts +9 -0
- package/dist/space-auth.d.ts.map +1 -0
- package/dist/space-auth.js +126 -0
- package/dist/space-auth.js.map +1 -0
- package/dist/streams.d.ts +14 -10
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +47 -76
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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),
|
|
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
|
|
919
|
-
* For connectors that learn
|
|
920
|
-
* hook
|
|
921
|
-
*
|
|
922
|
-
|
|
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
|
-
|
|
979
|
+
const v = variant?.trim();
|
|
980
|
+
if (!m || (this.card.meta?.model === m && this.card.meta.variant === v))
|
|
926
981
|
return;
|
|
927
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1333
|
-
* (`allowSubscribe`) from the durable registry, or `undefined`
|
|
1334
|
-
* reader DEFERS, never drops). A present `[]` (known
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1703
|
-
return { ok: false, error: `channel "${channel}" is not within your read ACL [${acl.
|
|
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
|
-
|
|
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
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
|
|
1884
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1987
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 &&
|