@cotal-ai/core 0.18.0 → 0.19.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 (93) hide show
  1. package/dist/acls.d.ts +0 -7
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +10 -7
  4. package/dist/acls.js.map +1 -1
  5. package/dist/agui-kind.d.ts +71 -0
  6. package/dist/agui-kind.d.ts.map +1 -0
  7. package/dist/agui-kind.js +95 -0
  8. package/dist/agui-kind.js.map +1 -0
  9. package/dist/endpoint-action.d.ts +3 -3
  10. package/dist/endpoint-action.js +4 -4
  11. package/dist/endpoint-action.js.map +1 -1
  12. package/dist/endpoint-binding.d.ts +48 -5
  13. package/dist/endpoint-binding.d.ts.map +1 -1
  14. package/dist/endpoint-binding.js +82 -6
  15. package/dist/endpoint-binding.js.map +1 -1
  16. package/dist/endpoint-cluster.d.ts +20 -0
  17. package/dist/endpoint-cluster.d.ts.map +1 -1
  18. package/dist/endpoint-cluster.js +47 -0
  19. package/dist/endpoint-cluster.js.map +1 -1
  20. package/dist/endpoint-effects.d.ts +73 -0
  21. package/dist/endpoint-effects.d.ts.map +1 -0
  22. package/dist/endpoint-effects.js +46 -0
  23. package/dist/endpoint-effects.js.map +1 -0
  24. package/dist/endpoint-envelope.d.ts +32 -1
  25. package/dist/endpoint-envelope.d.ts.map +1 -1
  26. package/dist/endpoint-envelope.js +56 -2
  27. package/dist/endpoint-envelope.js.map +1 -1
  28. package/dist/endpoint-epname.d.ts +62 -0
  29. package/dist/endpoint-epname.d.ts.map +1 -0
  30. package/dist/endpoint-epname.js +273 -0
  31. package/dist/endpoint-epname.js.map +1 -0
  32. package/dist/endpoint-error.d.ts +155 -1
  33. package/dist/endpoint-error.d.ts.map +1 -1
  34. package/dist/endpoint-error.js +114 -2
  35. package/dist/endpoint-error.js.map +1 -1
  36. package/dist/endpoint-goaleff.d.ts +56 -0
  37. package/dist/endpoint-goaleff.d.ts.map +1 -0
  38. package/dist/endpoint-goaleff.js +212 -0
  39. package/dist/endpoint-goaleff.js.map +1 -0
  40. package/dist/endpoint-grants.d.ts +31 -0
  41. package/dist/endpoint-grants.d.ts.map +1 -1
  42. package/dist/endpoint-grants.js +47 -0
  43. package/dist/endpoint-grants.js.map +1 -1
  44. package/dist/endpoint-invoke.d.ts +1 -1
  45. package/dist/endpoint-invoke.d.ts.map +1 -1
  46. package/dist/endpoint-invoke.js +89 -7
  47. package/dist/endpoint-invoke.js.map +1 -1
  48. package/dist/endpoint-journal.d.ts +103 -0
  49. package/dist/endpoint-journal.d.ts.map +1 -1
  50. package/dist/endpoint-journal.js +290 -0
  51. package/dist/endpoint-journal.js.map +1 -1
  52. package/dist/endpoint-records.d.ts.map +1 -1
  53. package/dist/endpoint-records.js +37 -0
  54. package/dist/endpoint-records.js.map +1 -1
  55. package/dist/endpoint-serve.d.ts.map +1 -1
  56. package/dist/endpoint-serve.js +45 -1
  57. package/dist/endpoint-serve.js.map +1 -1
  58. package/dist/endpoint-verbs.d.ts +12 -3
  59. package/dist/endpoint-verbs.d.ts.map +1 -1
  60. package/dist/endpoint-verbs.js +113 -19
  61. package/dist/endpoint-verbs.js.map +1 -1
  62. package/dist/endpoint.d.ts +149 -6
  63. package/dist/endpoint.d.ts.map +1 -1
  64. package/dist/endpoint.js +330 -14
  65. package/dist/endpoint.js.map +1 -1
  66. package/dist/event-channel.d.ts +107 -0
  67. package/dist/event-channel.d.ts.map +1 -0
  68. package/dist/event-channel.js +139 -0
  69. package/dist/event-channel.js.map +1 -0
  70. package/dist/index.d.ts +5 -0
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +5 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/membership-feed.d.ts.map +1 -1
  75. package/dist/membership-feed.js +35 -12
  76. package/dist/membership-feed.js.map +1 -1
  77. package/dist/parts.d.ts +29 -5
  78. package/dist/parts.d.ts.map +1 -1
  79. package/dist/parts.js +46 -3
  80. package/dist/parts.js.map +1 -1
  81. package/dist/provision.d.ts +1 -1
  82. package/dist/resolve.d.ts.map +1 -1
  83. package/dist/resolve.js +38 -0
  84. package/dist/resolve.js.map +1 -1
  85. package/dist/streams.d.ts +27 -3
  86. package/dist/streams.d.ts.map +1 -1
  87. package/dist/streams.js +28 -4
  88. package/dist/streams.js.map +1 -1
  89. package/dist/subjects.d.ts +42 -0
  90. package/dist/subjects.d.ts.map +1 -1
  91. package/dist/subjects.js +117 -0
  92. package/dist/subjects.js.map +1 -1
  93. package/package.json +5 -2
package/dist/endpoint.js CHANGED
@@ -5,11 +5,13 @@ import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, Author
5
5
  import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
6
6
  import { inspectCredHealth } from "./provision.js";
7
7
  import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
8
- import { EpEnvelopeError } from "./endpoint-envelope.js";
8
+ import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
9
+ import { isRepeatSafeCommand } from "./endpoint-grants.js";
10
+ import { assertIdToken } from "./endpoint-subjects.js";
9
11
  import { liveKvEntries } from "./kv-scan.js";
10
12
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
11
13
  import { assertValidName } from "./resolve.js";
12
- import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
14
+ import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
13
15
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
14
16
  import { Kvm } from "@nats-io/kv";
15
17
  import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
@@ -199,16 +201,34 @@ export class CotalEndpoint extends EventEmitter {
199
201
  owner;
200
202
  /** This endpoint's actor token (principal half 2) — the connection id in the dev default. */
201
203
  actor;
204
+ /** True when {@link actor} was SELF-MINTED at construction — a fresh random token, because the
205
+ * card declared no actor and no id and no creds named one. Such a principal differs on every
206
+ * restart, so nothing can be granted to it in advance and nothing durable may be keyed on it.
207
+ * Exposed via {@link actorIsEphemeral} so a caller deriving a per-agent resource name can refuse
208
+ * the mode instead of silently keying on a value that will not survive the process. */
209
+ actorIsEphemeral;
202
210
  /** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
203
211
  ownLifecycleUid;
204
212
  /** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
205
213
  * `failed-precondition` currency refusal (the described incarnation was superseded). */
206
214
  resolvedServices = new Map();
215
+ /** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) — the
216
+ * class-queue splits this endpoint hit and survived.
217
+ *
218
+ * Counted because it is recovered: handling the split is what makes it invisible, so this is the
219
+ * only evidence the split rate exists. Always on, never behind a flag — a counter you have to
220
+ * enable is not there when the thing you needed it for happened. */
221
+ splitsRecovered = 0;
207
222
  /** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
208
223
  * pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
209
224
  get principal() {
210
225
  return { owner: this.owner, actor: this.actor };
211
226
  }
227
+ /** Class-queue splits this endpoint has hit and silently survived ({@link splitsRecovered}).
228
+ * Pull it, or listen for `split-recovered` — the event can be missed, the count cannot. */
229
+ get splitRecoveryCount() {
230
+ return this.splitsRecovered;
231
+ }
212
232
  /** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
213
233
  * ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
214
234
  requireLifecycleUid(what) {
@@ -218,6 +238,9 @@ export class CotalEndpoint extends EventEmitter {
218
238
  }
219
239
  constructor(opts) {
220
240
  super();
241
+ /** Did the dev/static branch fall through to a random connId? Set on every path that assigns
242
+ * `connId` so the ephemeral verdict below can never read an unassigned value. */
243
+ let selfMintedConnId = false;
221
244
  this.space = opts.space;
222
245
  // A display name is the client-side handle a peer is addressed by; reject the reserved `/`
223
246
  // (the future owner/name separator) and surrounding whitespace at the one identity choke
@@ -258,6 +281,10 @@ export class CotalEndpoint extends EventEmitter {
258
281
  }
259
282
  this.connId = assertInboxConnId(`ibx${randomUUID().replace(/-/g, "")}`);
260
283
  this.sentinelCreds = opts.sentinelCreds;
284
+ // User mode's actor is SERVER-AUTHORED (bearer claims or a declared card checked against
285
+ // them), never self-minted — the ephemeral value here is the inbox nonce, which is the
286
+ // connection id and not the principal.
287
+ this.actorIsEphemeral = false;
261
288
  }
262
289
  else {
263
290
  // DEV / STATIC. Connection identity precedence: an explicit card.id, else the creds' identity, else
@@ -272,16 +299,22 @@ export class CotalEndpoint extends EventEmitter {
272
299
  throw new Error("a creds source requires an explicit card.id (no cred to derive the identity from at construction)");
273
300
  this.credsSource = opts.creds;
274
301
  this.connId = opts.card.id;
302
+ selfMintedConnId = false;
275
303
  }
276
304
  else {
277
305
  const credId = opts.creds ? idFromCreds(opts.creds) : undefined;
278
306
  if (opts.card.id && credId && opts.card.id !== credId)
279
307
  throw new Error(`card.id ${opts.card.id} != creds identity ${credId} - they must be the same nkey`);
280
308
  this.currentCreds = opts.creds;
309
+ selfMintedConnId = opts.card.id === undefined && credId === undefined;
281
310
  this.connId = opts.card.id ?? credId ?? randomUUID().replace(/-/g, "");
282
311
  }
283
312
  this.owner = opts.card.owner ?? DEV_OWNER;
284
313
  this.actor = opts.card.actor ?? this.connId;
314
+ // The actor is EPHEMERAL only when it inherited a self-minted connId — a declared `card.actor`
315
+ // is stable even on an otherwise identity-less connection. Recorded here, at the one site the
316
+ // fallback fires, so no caller has to re-derive the precedence rule from the outside.
317
+ this.actorIsEphemeral = opts.card.actor === undefined && selfMintedConnId;
285
318
  }
286
319
  // The incarnation's lifecycle UID (SPEC §13.1). AUTH mode (JWT creds/bearer) REQUIRES the
287
320
  // launcher to supply it — the dm/dlv/chathist durable names must match the exact names the
@@ -924,6 +957,164 @@ export class CotalEndpoint extends EventEmitter {
924
957
  await this.publishMsg(chatSubject(this.space, this.owner, this.actor, channel), msg);
925
958
  return msg;
926
959
  }
960
+ /** The broker's live `max_payload` — the CEILING a frame is measured against, not a budget for a
961
+ * caller's own payload.
962
+ *
963
+ * Exposed because the connection is private and callers outside core (a connector assembling a
964
+ * batched payload) otherwise have no way to learn the ceiling except by failing a publish.
965
+ * Throws rather than guessing a default: a wrong ceiling is worse than none, because it splits
966
+ * either too eagerly or too late and both look like working code.
967
+ *
968
+ * THIS ALONE CANNOT SIZE A MESSAGE. The envelope this endpoint adds after the publish call, and
969
+ * the client's own headers, are charged against the same ceiling and the caller never sees them.
970
+ * Use {@link encodedSize}, which measures what will actually be sent. */
971
+ get maxPayload() {
972
+ const max = this.nc?.info?.max_payload;
973
+ if (typeof max !== "number" || !Number.isFinite(max) || max <= 0)
974
+ throw new Error(`${this.notLiveMsg()} - max_payload is only known while connected`);
975
+ return max;
976
+ }
977
+ /**
978
+ * Verify the PRECONDITION {@link multicastExpecting} depends on: that the chat stream evaluates
979
+ * the subject expectation BEFORE the `Nats-Msg-Id` dedup cache. **Throws if it cannot be
980
+ * guaranteed.** Call before the first serialized append on a given endpoint.
981
+ *
982
+ * **The ordering follows the stream's REPLICATION FACTOR, not the deployment.** A standalone R1
983
+ * stream and an R1 stream inside a real 3-node cluster both refuse a stale expectation with a CAS
984
+ * error; only an R3 stream evaluates dedup first and answers a retry with `duplicate: true`. A
985
+ * check written against cluster size would pass on exactly the deployment that breaks.
986
+ *
987
+ * Every stream Cotal creates is `num_replicas: 1`, from the same canonical config the restore path
988
+ * uses, so the property holds by construction today. This exists because "by construction" is an
989
+ * observation until something checks it: nothing in the wire contract reserves the replica factor.
990
+ * A caller appending under a stale assumption does not fail loudly; it accepts a retry as success
991
+ * and drops a message.
992
+ *
993
+ * Evidence is `smoke:cas-preflight-cluster`, which records the server version it measured against
994
+ * rather than naming one here — the suites resolve `nats-server` from `PATH`, so a hardcoded
995
+ * provenance ages into a claim about a machine that no longer exists.
996
+ *
997
+ * @throws if the stream is unreadable (no `STREAM.INFO` grant, or absent) or reports more than
998
+ * one replica. Never degrades to a warning: the failure it prevents is silent.
999
+ */
1000
+ async assertExpectationSemantics() {
1001
+ if (!this.jsm)
1002
+ throw new Error(this.notLiveMsg());
1003
+ const stream = chatStream(this.space);
1004
+ let replicas;
1005
+ try {
1006
+ replicas = (await this.jsm.streams.info(stream)).config.num_replicas;
1007
+ }
1008
+ catch (e) {
1009
+ throw new Error(`cannot verify expectation semantics: stream "${stream}" info unavailable (${e.message}). ` +
1010
+ `Serialized appends are refused rather than run on an unverified stream.`);
1011
+ }
1012
+ // `undefined` is NOT treated as 1. A server that does not report the field is a server whose
1013
+ // ordering we have not established, which is the case this check exists for.
1014
+ if (replicas !== 1)
1015
+ throw new Error(`stream "${stream}" reports num_replicas=${String(replicas)}; serialized appends on THIS ` +
1016
+ `stream require 1 — a property of this one stream, not of the broker, so a clustered ` +
1017
+ `deployment is fine so long as this stream is R1, which a cluster can host. ` +
1018
+ `On a replicated stream the dedup cache is consulted before the subject expectation, so a ` +
1019
+ `retry returns a duplicate ack instead of a conflict and a lost message reads as success.`);
1020
+ }
1021
+ /** The envelope {@link multicastExpecting} publishes, built in ONE place so that a frame and any
1022
+ * measurement of that frame cannot describe different messages. The fields this adds — `ts`,
1023
+ * `space`, `from`, `channel` and the normalized `mentions` — are exactly the ones a caller
1024
+ * holding only its parts cannot account for. */
1025
+ casEnvelope(opts) {
1026
+ return {
1027
+ id: opts.id,
1028
+ ts: Date.now(),
1029
+ space: this.space,
1030
+ from: this.ref(),
1031
+ channel: opts.channel,
1032
+ mentions: normalizeMentions(opts.mentions),
1033
+ parts: opts.parts,
1034
+ replyTo: opts.replyTo,
1035
+ contextId: opts.contextId,
1036
+ };
1037
+ }
1038
+ /**
1039
+ * The bytes this frame will ACTUALLY put on the wire, to compare against {@link maxPayload}.
1040
+ *
1041
+ * Caller-side arithmetic is wrong in the dangerous direction: a split sized against the caller's
1042
+ * own payload produces a frame the broker REJECTS, and a rejected truncation makes the loss silent
1043
+ * again — the failure splitting exists to prevent.
1044
+ *
1045
+ * It lives on the surface that BUILDS the envelope so measurement and construction cannot drift
1046
+ * apart unnoticed: it shares {@link casEnvelope} with the publish path, sets the same two headers,
1047
+ * and lets the client's own encoder encode them rather than re-implementing the wire format.
1048
+ * `frame-size.smoke.ts` binary-searches a real broker's ceiling and requires this number to land
1049
+ * on it exactly.
1050
+ *
1051
+ * `expectedLastSubjectSeq` is a parameter because it is a header VALUE: sizing at 0 and publishing
1052
+ * at 123456 differ by five bytes.
1053
+ *
1054
+ * Residual: `ts` is re-stamped at publish, so the two differ in value — not in length until
1055
+ * epoch-millis needs a 14th digit.
1056
+ */
1057
+ encodedSize(opts) {
1058
+ // The same argument validation the publish path applies, so a caller cannot size a frame that
1059
+ // would have been refused before it ever reached the wire.
1060
+ if (!isConcreteChannel(opts.channel))
1061
+ throw new Error(`cannot publish to wildcard channel "${opts.channel}" - pick a concrete sub-channel`);
1062
+ assertIdToken(opts.id, "publish id");
1063
+ if (!Number.isSafeInteger(opts.expectedLastSubjectSeq) || opts.expectedLastSubjectSeq < 0)
1064
+ throw new Error(`expectedLastSubjectSeq must be a non-negative safe integer, got ${JSON.stringify(opts.expectedLastSubjectSeq)}`);
1065
+ if (!Array.isArray(opts.parts) || opts.parts.length === 0)
1066
+ throw new Error("encodedSize requires at least one part");
1067
+ const mh = headers();
1068
+ mh.set("Nats-Msg-Id", opts.id);
1069
+ mh.set("Nats-Expected-Last-Subject-Sequence", `${opts.expectedLastSubjectSeq}`);
1070
+ // `encode()` is on the client's header implementation but not on the published `MsgHdrs` type,
1071
+ // so it is reached through a cast rather than copied. If a client version drops it, this throws
1072
+ // immediately and loudly — the calibration cell would also fail — instead of returning a number
1073
+ // that is quietly wrong near the ceiling.
1074
+ const headerBytes = mh.encode().length;
1075
+ return headerBytes + Buffer.byteLength(JSON.stringify(this.casEnvelope(opts)), "utf8");
1076
+ }
1077
+ /**
1078
+ * Multicast with an OPTIMISTIC-CONCURRENCY expectation and a caller-chosen dedup id, returning
1079
+ * the `PubAck` fields instead of discarding them. The serialized-append primitive: two writers
1080
+ * racing one subject cannot interleave, because the loser's expectation no longer holds.
1081
+ *
1082
+ * **Why a separate method rather than options on {@link multicast}.** `multicast` mints a fresh
1083
+ * `id` per call and drops the ack; both are right for ordinary chat and both are fatal to a
1084
+ * caller that must retry an append idempotently. Keeping them apart means no existing caller
1085
+ * changes behaviour, and the stricter validation below applies only where a caller opted in.
1086
+ *
1087
+ * - `id` becomes the JetStream `Nats-Msg-Id`, so the SAME id may be republished on retry and the
1088
+ * server dedups it within the stream's duplicate window. It is validated rather than trusted:
1089
+ * it lands in a wire header, and the dedup cache is **stream-wide**, so a caller-supplied id is
1090
+ * both an injection surface and a way to suppress another publisher's message.
1091
+ * - `expectedLastSubjectSeq` is the sequence this publisher believes is the subject's tip; `0`
1092
+ * means "the subject must be empty". A mismatch throws, and the throw stays classifiable by the
1093
+ * already-public {@link isCasLoss} — the error is deliberately **not wrapped**, since wrapping
1094
+ * would hide the `err_code` that classification reads.
1095
+ *
1096
+ * @throws if the endpoint is not live, the channel is not concrete, `id` is malformed, `parts` is
1097
+ * empty, or `expectedLastSubjectSeq` is not a non-negative safe integer.
1098
+ */
1099
+ async multicastExpecting(opts) {
1100
+ if (!this.js)
1101
+ throw new Error(this.notLiveMsg());
1102
+ if (!isConcreteChannel(opts.channel))
1103
+ throw new Error(`cannot publish to wildcard channel "${opts.channel}" - pick a concrete sub-channel`);
1104
+ // Reuse the existing id grammar rather than mint a second one: [A-Za-z0-9_-]{1,64} admits a
1105
+ // UUID and rejects every character that could break a wire header (CR, LF, space, colon).
1106
+ assertIdToken(opts.id, "publish id");
1107
+ const expected = opts.expectedLastSubjectSeq;
1108
+ if (!Number.isSafeInteger(expected) || expected < 0)
1109
+ throw new Error(`expectedLastSubjectSeq must be a non-negative safe integer, got ${JSON.stringify(expected)}`);
1110
+ if (!Array.isArray(opts.parts) || opts.parts.length === 0)
1111
+ throw new Error("multicastExpecting requires at least one part");
1112
+ const message = this.casEnvelope(opts);
1113
+ // Publish DIRECTLY rather than through publishMsg: this path must set the expectation and read
1114
+ // the ack, and publishMsg deliberately does neither.
1115
+ const ack = await this.js.publish(chatSubject(this.space, this.owner, this.actor, opts.channel), JSON.stringify(message), { msgID: opts.id, expect: { lastSubjectSequence: expected } });
1116
+ return { message, ack: { seq: ack.seq, duplicate: ack.duplicate === true } };
1117
+ }
927
1118
  /** Unicast: direct message to one specific instance. */
928
1119
  async unicast(instanceId, text, opts) {
929
1120
  const msg = {
@@ -1071,11 +1262,22 @@ export class CotalEndpoint extends EventEmitter {
1071
1262
  /** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
1072
1263
  * the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
1073
1264
  * recompile ({@link resolveService}; cached per endpoint name) — and invoke one command. The
1074
- * resolve is describe-bound currency: when a DIFFERENT instance answers a later invoke
1075
- * (`failed-precondition`, a restart/supersede), the cache is dropped and resolved ONCE more
1076
- * against the current incarnation. Errors from the responder come back structurally on the
1077
- * attributed reply (`reply.ok === false`); transport/validation refusals throw
1078
- * {@link EpEnvelopeError}. */
1265
+ * resolve is describe-bound currency, and a call that reaches the wrong incarnation is recovered
1266
+ * two ways depending on WHO caught it — the difference between knowing the command did not run
1267
+ * and only knowing someone answered:
1268
+ * - the RESPONDER fenced it on the request's `bind` (§13.2, an `ok:false` reply marked
1269
+ * {@link replyRefusedBeforeEffect}): the command did not run, so the bind is dropped and the
1270
+ * call re-issued ONCE for any command. If that re-issue cannot be resolved, the refusal
1271
+ * surfaces — still saying the command did not run — naming the resolve failure as why the
1272
+ * repair could not be attempted.
1273
+ * - this CLIENT caught it on the reply ({@link respondedButUnbound}: a different instance,
1274
+ * `failed-precondition`; the same instance at any other epoch, `expired`), which is what a
1275
+ * responder too old to know the field produces. A live instance received and answered it, so
1276
+ * the bind is dropped but the call is re-issued only for a command on the
1277
+ * {@link isRepeatSafeCommand} allowlist; anything else surfaces, since a second attempt could
1278
+ * duplicate its effect.
1279
+ * Errors from the responder come back structurally on the attributed reply
1280
+ * (`reply.ok === false`); transport/validation refusals throw {@link EpEnvelopeError}. */
1079
1281
  async invokeService(endpoint, command, args, opts = {}) {
1080
1282
  if (!this.nc)
1081
1283
  throw new Error(this.notLiveMsg());
@@ -1092,13 +1294,111 @@ export class CotalEndpoint extends EventEmitter {
1092
1294
  const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
1093
1295
  const doInvoke = async () => {
1094
1296
  try {
1095
- return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
1297
+ const r = await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
1298
+ // THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
1299
+ // was bound to a different incarnation and refused BEFORE running the command.
1300
+ //
1301
+ // Handled here rather than below because it arrives as an ordinary `ok:false` REPLY, not a
1302
+ // throw, so the `respondedButUnbound` recovery never sees it — a long-lived client would
1303
+ // otherwise keep its stale bind and meet the same refusal forever.
1304
+ //
1305
+ // The re-issue is NOT gated on {@link isRepeatSafeCommand}: the responder states the command
1306
+ // did not run, so this is a FIRST attempt, not a second. Exactly once; a second refusal
1307
+ // surfaces.
1308
+ if (r.reply.ok === false && replyRefusedBeforeEffect(r.reply.error)) {
1309
+ // Counted before it is repaired: a recovery that leaves no trace takes the split rate with it.
1310
+ this.splitsRecovered++;
1311
+ // `boundTo` is the other half of `servedBy`: who the handle THOUGHT it was talking to,
1312
+ // against who actually answered. Without it a listener sees that a split was recovered
1313
+ // but not which bind went stale, so it cannot tell one handle's repeated staleness from
1314
+ // splits spread across many — and that is the difference between a handle to drop and a
1315
+ // class that is churning.
1316
+ this.emit("split-recovered", {
1317
+ endpoint, command, servedBy: r.responder, splitsRecovered: this.splitsRecovered,
1318
+ boundTo: (r.reply.error?.details ?? []).find((d) => d.kind === EP_BIND_REFUSED)?.boundTo,
1319
+ });
1320
+ this.resolvedServices.delete(endpoint);
1321
+ // A FAILED RE-ISSUE RETHROWS THE ORIGINAL REFUSAL, not the resolve error: if the endpoint
1322
+ // has since retired, the resolve times out and the caller would otherwise get `no describe
1323
+ // reply within 10000ms` — about a describe it never asked for — losing the one fact that
1324
+ // says its handle is stale AND that nothing ran. The resolve failure is named as the
1325
+ // reason the repair could not be attempted.
1326
+ //
1327
+ // The code must be the refusal's own (`failed-precondition` for a different instance,
1328
+ // `expired` for the same instance at ANY other epoch — the fence does not compare
1329
+ // direction, so a superseded incarnation still answering produces it too); the marker on
1330
+ // any other code is incoherent, so it is rejected rather than trusted (§13.3).
1331
+ const raw = r.reply.error?.code;
1332
+ const refusalCode = raw === "expired" ? "expired"
1333
+ : raw === "failed-precondition" ? "failed-precondition" : undefined;
1334
+ if (refusalCode === undefined)
1335
+ throw new EpEnvelopeError("internal", `${endpoint}.${command} came back marked as refused before it ran, but with code ${String(raw)}; the fence produces only failed-precondition or expired (SPEC 13.2)`, r.reply.error?.details);
1336
+ // ONLY THE RESOLVE IS WRAPPED — do not widen this `try` to cover the re-issue. A re-issue
1337
+ // that PUBLISHED AND RAN can still throw (an unfenced responder answers and the post-reply
1338
+ // currency check raises `respondedButUnbound`); wrapping that as the first hop's refusal
1339
+ // would hand the caller `WAS NOT RUN` for a command that executed, and its next attempt
1340
+ // would be a second one believing it was the first. A re-issue that fails on its own terms
1341
+ // must propagate its OWN error, because `respondedButUnbound` asserts the opposite of what
1342
+ // the fence's marker does: a responder answered and the effect may have landed.
1343
+ let reissueTarget;
1344
+ try {
1345
+ reissueTarget = await resolve();
1346
+ }
1347
+ catch (reissue) {
1348
+ throw new EpEnvelopeError(refusalCode, `${endpoint}.${command} WAS NOT RUN - the incarnation that received it refused it before any effect, and the re-issue could not be resolved: ${reissue instanceof Error ? reissue.message : String(reissue)}. Re-resolve and re-issue when the endpoint is reachable (SPEC 13.2)`, r.reply.error?.details,
1349
+ // §13.3: the message asserts the command did not run, so the FIELD a caller keys on
1350
+ // must assert it too. Omitted, it MUST be read as `unknown`, which is the opposite of
1351
+ // what this path knows: the responder fenced the call before any effect and the
1352
+ // re-issue never went out. Prose is for the reader; this is for the machine.
1353
+ "not-executed");
1354
+ }
1355
+ return await invokeCommand(nc, this.space, reissueTarget, command, args, invokeOpts);
1356
+ }
1357
+ return r;
1096
1358
  }
1097
1359
  catch (e) {
1098
- if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
1360
+ if (!(e instanceof EpEnvelopeError))
1361
+ throw e;
1362
+ // DO NOT auto-retry a command a responder already ANSWERED. This path covers the responders
1363
+ // WITHOUT the fence above: they ignore `bind`, run the command, and the error is raised
1364
+ // afterwards, so core cannot tell a repair from a duplicate and the allowlist is the only
1365
+ // guard left. (`failed-precondition` here is not only supersession — it also fires when a
1366
+ // DIFFERENT live instance wins the class queue and replies, an ordinary split.)
1367
+ //
1368
+ // Dropping the retry outright is not an option: in a two-manager space roughly half of all
1369
+ // class-queue calls split, so every other `ps` would surface an error this absorbs, and
1370
+ // re-running a read costs nothing.
1371
+ //
1372
+ // So it is gated on an ALLOWLIST (`REPEAT_SAFE_COMMANDS`), keyed by ENDPOINT because this
1373
+ // method is endpoint-agnostic. Polarity matters more than membership: an allowlist fails
1374
+ // CLOSED. Not a `GOAL_BEARING_COMMANDS`-only gate — `purge` is neither goal-bearing nor
1375
+ // convergent, and its second run deletes messages published after the first completed, so
1376
+ // the boundary is "changes anything", which core cannot see: commands carry no idempotency
1377
+ // declaration and every manager command shares `class: "ephemeral"`.
1378
+ //
1379
+ // Keyed on the MARKER, not the error code, because the same fact has two producers: a
1380
+ // DIFFERENT instance answering (`failed-precondition`) and the SAME instance at ANY OTHER
1381
+ // EPOCH (`expired`) — epoch inequality, not "a successor", so a superseded incarnation
1382
+ // still answering produces it too. Same rule for both, or a long-lived client stays bound
1383
+ // to a dead epoch while every call reaches whoever is actually there.
1384
+ if (respondedButUnbound(e)) {
1385
+ // Drop the stale bind FIRST, whichever way this goes: it names an incarnation that is not
1386
+ // the one answering, so every later call would reuse it and meet the same refusal. This is
1387
+ // not a retry — nothing is re-issued by it, and for a command that is not repeat-safe the
1388
+ // next call is the caller's, made after it has verified.
1389
+ this.resolvedServices.delete(endpoint);
1390
+ if (!isRepeatSafeCommand(endpoint, command))
1391
+ throw e;
1392
+ return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
1393
+ }
1394
+ // An UNMARKED `failed-precondition` is the resolve's own refusal, raised before any command
1395
+ // was published, so re-resolving once is a repair. The `replyRefusedBeforeEffect` half keeps
1396
+ // out the refusal THIS method raises after a failed re-issue: it carries that same code, and
1397
+ // would otherwise fall into the re-resolve below as a THIRD attempt at a command whose
1398
+ // second could not even be resolved. A marker means the disposition is already decided,
1399
+ // whichever code carries it.
1400
+ if (e.code !== "failed-precondition" || replyRefusedBeforeEffect(e.toEpError()))
1099
1401
  throw e;
1100
- // The describe-bound incarnation is gone (restart/supersede) — re-resolve once, then invoke
1101
- // against the CURRENT one; a second failure is the real state and throws.
1102
1402
  this.resolvedServices.delete(endpoint);
1103
1403
  return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
1104
1404
  }
@@ -1735,7 +2035,7 @@ export class CotalEndpoint extends EventEmitter {
1735
2035
  await this.ensureDmDurable(owner, actor, lifecycleUid, {});
1736
2036
  }
1737
2037
  /** Idempotent-PER-LIFECYCLE create of a `dm_<o>-<a>-<uid>` durable with its ACTIVATION FRONTIER
1738
- * (SPEC :467). Info-first: an existing durable (a manager-restart re-provision of the SAME uid, or
2038
+ * (SPEC §8). Info-first: an existing durable (a manager-restart re-provision of the SAME uid, or
1739
2039
  * the same lifecycle's own restart) is kept as-is, preserving the ORIGINAL frontier — the
1740
2040
  * activation moment never moves. A fresh lifecycle captures the DM stream's current `last_seq` and
1741
2041
  * starts delivery at frontier+1, so a same-alias successor inherits none of the predecessor's
@@ -1921,7 +2221,11 @@ export class CotalEndpoint extends EventEmitter {
1921
2221
  throw new Error("endpoint not started");
1922
2222
  if (this.managerLeaseKv)
1923
2223
  return this.managerLeaseKv;
1924
- const kvm = new Kvm(this.nc);
2224
+ // A JetStream client of its own, so every operation on this bucket carries the lease budget's
2225
+ // attempt deadline rather than the library default. The default is TTL/2, which would let one
2226
+ // attempt spend the whole renew window (see MANAGER_LEASE_ATTEMPT_MS). Scoped to this bucket:
2227
+ // every op on it is a single small keyed request, and nothing else shares this client.
2228
+ const kvm = new Kvm(jetstream(this.nc, { timeout: MANAGER_LEASE_ATTEMPT_MS }));
1925
2229
  if (this.authed) {
1926
2230
  this.managerLeaseKv = await kvm.open(managerBucket(this.space));
1927
2231
  }
@@ -1952,6 +2256,18 @@ export class CotalEndpoint extends EventEmitter {
1952
2256
  async renewManagerLease(info, revision) {
1953
2257
  return (await this.managerLeaseRegistry()).update(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }), revision);
1954
2258
  }
2259
+ /** Read THIS instance's OWN lease key, keyed (not the `lease.*` sweep {@link readManagerLease} does).
2260
+ *
2261
+ * `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
2262
+ * A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
2263
+ * not find out". That distinction is the whole point of the method: a renew that got no answer has
2264
+ * proved nothing, and only a definite answer here may be acted on. */
2265
+ async readOwnManagerLease(instanceId) {
2266
+ const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
2267
+ if (!e || e.operation !== "PUT")
2268
+ return undefined;
2269
+ return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision };
2270
+ }
1955
2271
  /** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
1956
2272
  * by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
1957
2273
  * no-ops. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
@@ -3483,7 +3799,7 @@ export class CotalEndpoint extends EventEmitter {
3483
3799
  return; // observers watch but never publish their own record
3484
3800
  const p = {
3485
3801
  card: this.card,
3486
- // SPEC §6/:315: presence carries the incarnation's lifecycle UID (MUST in auth mode from v0.4);
3802
+ // SPEC §6: presence carries the incarnation's lifecycle UID (MUST in auth mode from v0.4);
3487
3803
  // omitted only where the endpoint has none (a pure operator/daemon connection never registers).
3488
3804
  ...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
3489
3805
  status: this.status,