@cotal-ai/core 0.50.1 → 0.51.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/endpoint.js CHANGED
@@ -249,6 +249,11 @@ export class CotalEndpoint extends EventEmitter {
249
249
  presenceRebindAt = 0;
250
250
  status = "idle";
251
251
  activity;
252
+ condition;
253
+ /** Advances on every condition change so an older in-flight put cannot be the final KV state. */
254
+ conditionRevision = 0;
255
+ /** Read once at construction. Core publishes this opaque provider reference and never parses it. */
256
+ environment;
252
257
  /** Mirror of the connector's authoritative attention state, published in presence (advisory). The
253
258
  * endpoint never reads these back into delivery — they exist only to broadcast. */
254
259
  attentionMode;
@@ -300,6 +305,8 @@ export class CotalEndpoint extends EventEmitter {
300
305
  * only evidence the split rate exists. Always on, never behind a flag — a counter you have to
301
306
  * enable is not there when the thing you needed it for happened. */
302
307
  splitsRecovered = 0;
308
+ /** Presence rows rejected because their embedded id did not match the ACL-scoped KV key. */
309
+ presenceBindingDrops = 0;
303
310
  /** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
304
311
  * pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
305
312
  get principal() {
@@ -310,6 +317,10 @@ export class CotalEndpoint extends EventEmitter {
310
317
  get splitRecoveryCount() {
311
318
  return this.splitsRecovered;
312
319
  }
320
+ /** Mis-keyed presence rows this reader rejected. The warning event may be missed; the count cannot. */
321
+ get presenceBindingDropCount() {
322
+ return this.presenceBindingDrops;
323
+ }
313
324
  /** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
314
325
  * ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
315
326
  requireLifecycleUid(what) {
@@ -419,6 +430,7 @@ export class CotalEndpoint extends EventEmitter {
419
430
  // principalKey validates both tokens.
420
431
  const principal = principalKey(this.owner, this.actor);
421
432
  this.card = { ...opts.card, id: principal.key, owner: this.owner, actor: this.actor };
433
+ this.environment = process.env.COTAL_ENVIRONMENT?.trim() || undefined;
422
434
  this.servers = opts.servers ?? DEFAULT_SERVER;
423
435
  this.token = opts.token;
424
436
  this.user = opts.user;
@@ -1189,6 +1201,10 @@ export class CotalEndpoint extends EventEmitter {
1189
1201
  this.subs.length = 0;
1190
1202
  if (!failedNc)
1191
1203
  return;
1204
+ // The old status iterator is stale after nc is cleared, so its close cannot report this edge.
1205
+ // As in doRebuild, teardown itself must announce the no-nc window before closing the socket.
1206
+ this.emit("transport", { connected: false });
1207
+ this.emit("connection", { connected: false });
1192
1208
  // Layered teardown, comments kept OUT of the code span below on purpose: the mutation fixture
1193
1209
  // bin/smoke/mutations/failed-bind-cleanup.json anchors on that span verbatim and the fixture
1194
1210
  // census (bin/smoke/mutation-fixtures.smoke.ts) reddens an anchor that crosses a comment line.
@@ -2057,6 +2073,12 @@ export class CotalEndpoint extends EventEmitter {
2057
2073
  this.status = status;
2058
2074
  await this.publishPresence();
2059
2075
  }
2076
+ /** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
2077
+ async setCondition(condition) {
2078
+ this.condition = condition ?? undefined;
2079
+ this.conditionRevision++;
2080
+ await this.publishPresence();
2081
+ }
2060
2082
  /** Publish the agent's global attention mode into presence (advisory observability). Mirror only —
2061
2083
  * delivery decisions stay in the connector's authoritative state. */
2062
2084
  async setAttention(attention) {
@@ -5148,6 +5170,8 @@ export class CotalEndpoint extends EventEmitter {
5148
5170
  // omitted only where the endpoint has none (a pure operator/daemon connection never registers).
5149
5171
  ...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
5150
5172
  status: this.status,
5173
+ condition: this.condition,
5174
+ environment: this.environment,
5151
5175
  activity: this.activity,
5152
5176
  attention: this.attentionMode,
5153
5177
  channelModes: this.channelModes,
@@ -5164,6 +5188,7 @@ export class CotalEndpoint extends EventEmitter {
5164
5188
  // it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
5165
5189
  // refusal on the connection that just published successfully.
5166
5190
  const epoch = this.presenceEpoch;
5191
+ const conditionRevision = this.conditionRevision;
5167
5192
  try {
5168
5193
  await this.kv.put(this.card.id, JSON.stringify(record));
5169
5194
  }
@@ -5188,6 +5213,13 @@ export class CotalEndpoint extends EventEmitter {
5188
5213
  // the record with a fresh `since`. Tracked in #1461, not repaired here.
5189
5214
  if (epoch !== this.presenceEpoch || this.stopped)
5190
5215
  return;
5216
+ // Presence writes may overlap. If this put carried an older condition, repair the KV with the
5217
+ // latest local state before returning so a late failure publish cannot resurrect after a new
5218
+ // turn cleared it. The revision is condition-specific; unrelated heartbeat overlap is unchanged.
5219
+ if (conditionRevision !== this.conditionRevision) {
5220
+ await this.publishPresence();
5221
+ return;
5222
+ }
5191
5223
  this.clearPresenceWriteFailure();
5192
5224
  }
5193
5225
  /** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
@@ -5440,8 +5472,11 @@ export class CotalEndpoint extends EventEmitter {
5440
5472
  // with its bucket key is forged or corrupt. Drop it rather than surface a spoofed roster identity.
5441
5473
  // The write-side scoping ($KV.<presenceBucket>.<own-id>) is the primary guard; this rejects a
5442
5474
  // mis-keyed record even if a broad writer slips one in under another agent's key.
5443
- if (raw.card?.id !== id)
5475
+ if (raw.card?.id !== id) {
5476
+ this.presenceBindingDrops++;
5477
+ this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(id)}: card.id ${JSON.stringify(raw.card?.id)} does not match its KV key`));
5444
5478
  return;
5479
+ }
5445
5480
  const prev = this.roster.get(id);
5446
5481
  const stale = Date.now() - raw.ts > this.ttlMs;
5447
5482
  // A watch recovering from a stall replays the bucket. Those PUTs still carry the publisher's
@@ -5481,6 +5516,8 @@ export class CotalEndpoint extends EventEmitter {
5481
5516
  prev.lifecycleUid === p.lifecycleUid &&
5482
5517
  prev.status === p.status &&
5483
5518
  prev.activity === p.activity &&
5519
+ sameCondition(prev.condition, p.condition) &&
5520
+ prev.environment === p.environment &&
5484
5521
  prev.attention === p.attention &&
5485
5522
  sameChannelModes(prev.channelModes, p.channelModes)) {
5486
5523
  this.roster.set(id, p);
@@ -5711,6 +5748,12 @@ function sameChannelModes(a, b) {
5711
5748
  return false;
5712
5749
  return ak.every((k) => a[k] === b?.[k]);
5713
5750
  }
5751
+ function sameCondition(a, b) {
5752
+ return a?.code === b?.code
5753
+ && a?.source === b?.source
5754
+ && a?.message === b?.message
5755
+ && a?.since === b?.since;
5756
+ }
5714
5757
  function authOpts(a) {
5715
5758
  const tls = a.tls ? {} : undefined;
5716
5759
  // USER MODE: present the shared auth-account sentinel creds AND the user bearer as `auth_token` (an