@cotal-ai/core 0.50.1 → 0.52.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 (57) hide show
  1. package/dist/auth-provider.d.ts +91 -2
  2. package/dist/auth-provider.d.ts.map +1 -1
  3. package/dist/auth-provider.js +6 -0
  4. package/dist/auth-provider.js.map +1 -1
  5. package/dist/command.d.ts +7 -0
  6. package/dist/command.d.ts.map +1 -1
  7. package/dist/command.js.map +1 -1
  8. package/dist/connector.d.ts +6 -2
  9. package/dist/connector.d.ts.map +1 -1
  10. package/dist/connector.js.map +1 -1
  11. package/dist/endpoint-grants.d.ts +7 -5
  12. package/dist/endpoint-grants.d.ts.map +1 -1
  13. package/dist/endpoint-grants.js +11 -6
  14. package/dist/endpoint-grants.js.map +1 -1
  15. package/dist/endpoint-serve.d.ts +8 -1
  16. package/dist/endpoint-serve.d.ts.map +1 -1
  17. package/dist/endpoint-serve.js +13 -2
  18. package/dist/endpoint-serve.js.map +1 -1
  19. package/dist/endpoint-verbs.d.ts +17 -5
  20. package/dist/endpoint-verbs.d.ts.map +1 -1
  21. package/dist/endpoint-verbs.js +49 -8
  22. package/dist/endpoint-verbs.js.map +1 -1
  23. package/dist/endpoint.d.ts +58 -8
  24. package/dist/endpoint.d.ts.map +1 -1
  25. package/dist/endpoint.js +294 -20
  26. package/dist/endpoint.js.map +1 -1
  27. package/dist/environment.d.ts +302 -0
  28. package/dist/environment.d.ts.map +1 -0
  29. package/dist/environment.js +58 -0
  30. package/dist/environment.js.map +1 -0
  31. package/dist/evict.d.ts +9 -5
  32. package/dist/evict.d.ts.map +1 -1
  33. package/dist/evict.js +24 -16
  34. package/dist/evict.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/launch-material.d.ts +2 -0
  40. package/dist/launch-material.d.ts.map +1 -1
  41. package/dist/launch-material.js +5 -0
  42. package/dist/launch-material.js.map +1 -1
  43. package/dist/launch.d.ts +3 -0
  44. package/dist/launch.d.ts.map +1 -1
  45. package/dist/membership-feed.d.ts +7 -5
  46. package/dist/membership-feed.d.ts.map +1 -1
  47. package/dist/membership-feed.js +60 -16
  48. package/dist/membership-feed.js.map +1 -1
  49. package/dist/provision.d.ts +6 -0
  50. package/dist/provision.d.ts.map +1 -1
  51. package/dist/provision.js +21 -3
  52. package/dist/provision.js.map +1 -1
  53. package/dist/run-host.d.ts +15 -0
  54. package/dist/run-host.d.ts.map +1 -1
  55. package/dist/types.d.ts +44 -1
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -141,6 +141,10 @@ export class CotalEndpoint extends EventEmitter {
141
141
  * `DrainingConnectionError` out of `reset()` on a timer nothing awaits. */
142
142
  presenceWatchIter;
143
143
  channelWatchIter;
144
+ /** The daemon's lease-loss watch (#1596): the INTENT that survives a connection rebuild, plus the
145
+ * live iterator. See {@link watchDeliveryLease}. */
146
+ leaseWatchIntent;
147
+ leaseWatchIter;
144
148
  /** Plane-3 durable-membership registry KV — lazily opened by the privileged delivery daemon (or a
145
149
  * short-lived provisioner). */
146
150
  membersKv;
@@ -221,6 +225,14 @@ export class CotalEndpoint extends EventEmitter {
221
225
  presenceWriteFailingSince;
222
226
  /** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
223
227
  lastPresenceWriteError;
228
+ /** #1461: monotonic per-put generation on the presence path, so a settle can tell whether it is
229
+ * the latest evidence on its epoch. {@link publishPresence} snapshots it at put start and every
230
+ * settle records its generation here: a settle is the latest evidence only while no put that
231
+ * started after it has already settled. */
232
+ presencePutGeneration = 0;
233
+ /** #1461: the generation of the newest presence put that has SETTLED (succeeded or rejected),
234
+ * or `undefined` when none ever has. A put that is merely in flight is not evidence yet. */
235
+ presencePutSettled;
224
236
  roster = new Map();
225
237
  /** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
226
238
  presenceSnapshot = Promise.resolve();
@@ -249,6 +261,11 @@ export class CotalEndpoint extends EventEmitter {
249
261
  presenceRebindAt = 0;
250
262
  status = "idle";
251
263
  activity;
264
+ condition;
265
+ /** Advances on every condition change so an older in-flight put cannot be the final KV state. */
266
+ conditionRevision = 0;
267
+ /** Read once at construction. Core publishes this opaque provider reference and never parses it. */
268
+ environment;
252
269
  /** Mirror of the connector's authoritative attention state, published in presence (advisory). The
253
270
  * endpoint never reads these back into delivery — they exist only to broadcast. */
254
271
  attentionMode;
@@ -300,6 +317,8 @@ export class CotalEndpoint extends EventEmitter {
300
317
  * only evidence the split rate exists. Always on, never behind a flag — a counter you have to
301
318
  * enable is not there when the thing you needed it for happened. */
302
319
  splitsRecovered = 0;
320
+ /** Presence rows rejected because their embedded id did not match the ACL-scoped KV key. */
321
+ presenceBindingDrops = 0;
303
322
  /** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
304
323
  * pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
305
324
  get principal() {
@@ -310,6 +329,10 @@ export class CotalEndpoint extends EventEmitter {
310
329
  get splitRecoveryCount() {
311
330
  return this.splitsRecovered;
312
331
  }
332
+ /** Mis-keyed presence rows this reader rejected. The warning event may be missed; the count cannot. */
333
+ get presenceBindingDropCount() {
334
+ return this.presenceBindingDrops;
335
+ }
313
336
  /** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
314
337
  * ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
315
338
  requireLifecycleUid(what) {
@@ -419,6 +442,7 @@ export class CotalEndpoint extends EventEmitter {
419
442
  // principalKey validates both tokens.
420
443
  const principal = principalKey(this.owner, this.actor);
421
444
  this.card = { ...opts.card, id: principal.key, owner: this.owner, actor: this.actor };
445
+ this.environment = process.env.COTAL_ENVIRONMENT?.trim() || undefined;
422
446
  this.servers = opts.servers ?? DEFAULT_SERVER;
423
447
  this.token = opts.token;
424
448
  this.user = opts.user;
@@ -971,6 +995,12 @@ export class CotalEndpoint extends EventEmitter {
971
995
  if (watchChannels)
972
996
  await this.startChannelWatch();
973
997
  }
998
+ // Rebind the daemon's lease-loss watch onto the fresh connection (#1596). The intent survived
999
+ // clearConnectionScoped; a bind that completes after a stop or another rebuild found no intent
1000
+ // and released itself. The replay re-delivers the row's current state, so a change that landed
1001
+ // in the null window still reaches the trigger.
1002
+ if (this.leaseWatchIntent)
1003
+ await this.bindDeliveryLeaseWatch();
974
1004
  // FAIL BEFORE PRESENCE (SPEC 13.1): an AUTHED endpoint that will register on the roster or
975
1005
  // bind lifecycle-keyed consumers must hold its launcher-supplied lifecycle uid BEFORE anything
976
1006
  // makes it visible - a missing uid must never leave a roster ghost that could not bind (false
@@ -1095,6 +1125,7 @@ export class CotalEndpoint extends EventEmitter {
1095
1125
  /* already closed with the connection */
1096
1126
  }
1097
1127
  this.channelWatchIter = undefined;
1128
+ this.stopDeliveryLeaseWatch();
1098
1129
  for (const sub of this.chatSubs.values()) {
1099
1130
  try {
1100
1131
  sub.unsubscribe();
@@ -1189,6 +1220,10 @@ export class CotalEndpoint extends EventEmitter {
1189
1220
  this.subs.length = 0;
1190
1221
  if (!failedNc)
1191
1222
  return;
1223
+ // The old status iterator is stale after nc is cleared, so its close cannot report this edge.
1224
+ // As in doRebuild, teardown itself must announce the no-nc window before closing the socket.
1225
+ this.emit("transport", { connected: false });
1226
+ this.emit("connection", { connected: false });
1192
1227
  // Layered teardown, comments kept OUT of the code span below on purpose: the mutation fixture
1193
1228
  // bin/smoke/mutations/failed-bind-cleanup.json anchors on that span verbatim and the fixture
1194
1229
  // census (bin/smoke/mutation-fixtures.smoke.ts) reddens an anchor that crosses a comment line.
@@ -1467,6 +1502,7 @@ export class CotalEndpoint extends EventEmitter {
1467
1502
  /* already closed */
1468
1503
  }
1469
1504
  this.channelWatchIter = undefined;
1505
+ this.stopDeliveryLeaseWatch();
1470
1506
  try {
1471
1507
  if (this.doRegister) {
1472
1508
  this.status = "offline";
@@ -1671,6 +1707,7 @@ export class CotalEndpoint extends EventEmitter {
1671
1707
  if (!Array.isArray(opts.parts) || opts.parts.length === 0)
1672
1708
  throw new Error("multicastExpecting requires at least one part");
1673
1709
  const message = this.casEnvelope(opts);
1710
+ assertPartsSerializable(message.parts);
1674
1711
  // Publish DIRECTLY rather than through publishMsg: this path must set the expectation and read
1675
1712
  // the ack, and publishMsg deliberately does neither.
1676
1713
  const ack = await this.js.publish(chatSubject(this.space, this.owner, this.actor, opts.channel), JSON.stringify(message), { msgID: opts.id, expect: { lastSubjectSequence: expected } });
@@ -2057,6 +2094,12 @@ export class CotalEndpoint extends EventEmitter {
2057
2094
  this.status = status;
2058
2095
  await this.publishPresence();
2059
2096
  }
2097
+ /** Publish a harness-reported condition, or clear it. Core stores the relay without interpretation. */
2098
+ async setCondition(condition) {
2099
+ this.condition = condition ?? undefined;
2100
+ this.conditionRevision++;
2101
+ await this.publishPresence();
2102
+ }
2060
2103
  /** Publish the agent's global attention mode into presence (advisory observability). Mirror only —
2061
2104
  * delivery decisions stay in the connector's authoritative state. */
2062
2105
  async setAttention(attention) {
@@ -2507,7 +2550,10 @@ export class CotalEndpoint extends EventEmitter {
2507
2550
  }));
2508
2551
  }
2509
2552
  /** Fetch recent messages from a channel's JetStream backlog. `signal` cancels the active pull and
2510
- * reclaims its ephemeral consumer before the promise rejects. */
2553
+ * reclaims its ephemeral consumer before the promise rejects. Returns `HistoryMessage[]`
2554
+ * (#1413): every field present on a row was checked by the drain before the row was
2555
+ * returned — a stored row missing `ts` / `space` / `parts` / `from.name` is dropped, never
2556
+ * returned with that member silently `undefined`. */
2511
2557
  async channelHistory(channel, opts) {
2512
2558
  // history from any sender
2513
2559
  return (await this.streamHistory(chatStream(this.space), [chatSubject(this.space, "*", "*", channel)], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
@@ -2654,7 +2700,10 @@ export class CotalEndpoint extends EventEmitter {
2654
2700
  /** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. `signal` cancels the
2655
2701
  * active pull and reclaims its ephemeral consumer. God-view only:
2656
2702
  * a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
2657
- * skips for them — only an `admin`-profile cred can read it. */
2703
+ * skips for them — only an `admin`-profile cred can read it. Returns `HistoryMessage[]`
2704
+ * (#1413): every field present on a row was checked by the drain before the row was
2705
+ * returned — a stored row missing `ts` / `space` / `parts` / `from.name` is dropped, never
2706
+ * returned with that member silently `undefined`. */
2658
2707
  async dmHistory(opts) {
2659
2708
  // every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
2660
2709
  return (await this.streamHistory(dmStream(this.space), [`${spacePrefix(this.space)}.inst.>`], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
@@ -3032,6 +3081,7 @@ export class CotalEndpoint extends EventEmitter {
3032
3081
  async publishMsg(subject, msg) {
3033
3082
  if (!this.js)
3034
3083
  throw new Error(this.notLiveMsg());
3084
+ assertPartsSerializable(msg.parts);
3035
3085
  // msgID = message id → free server-side dedup across JetStream redelivery.
3036
3086
  await this.js.publish(subject, JSON.stringify(msg), { msgID: msg.id });
3037
3087
  }
@@ -3283,6 +3333,68 @@ export class CotalEndpoint extends EventEmitter {
3283
3333
  async readDeliveryLease(shardIndex) {
3284
3334
  return (await this.readDeliveryLeaseEntry(shardIndex))?.info;
3285
3335
  }
3336
+ /** Watch THIS shard's lease key and nothing else, for the daemon's loss trigger (#1596).
3337
+ *
3338
+ * A KV watch is the native JetStream push for "the row changed hands NOW": a filtered ordered
3339
+ * consumer on the lease bucket delivers the row's own writes the moment the broker applies them,
3340
+ * instead of the daemon waiting for its next renew tick to notice. The watch is a TRIGGER, never a
3341
+ * decision: every event is handed to `onEvent` with the row as the broker now states it (PUT) or
3342
+ * the fact it is gone (DEL/PURGE, `info` undefined), and the caller re-runs the SAME decision tree
3343
+ * its renew tick would (`leaseAction`, `mayServeOn`, `readOwnLease`-style ownership tests). An
3344
+ * event the daemon itself caused (its acquire, its renew, its ready flip) is an ordinary input to
3345
+ * that tree — the row still reads `held` by this endpoint — so it never quiesces on its own write;
3346
+ * no event filtering happens here.
3347
+ *
3348
+ * Like the presence watch, this survives a connection rebuild as INTENT: the iterator dies with
3349
+ * its connection and {@link connectAndBind} rebinds it onto the fresh one, so a reconnect can
3350
+ * never leave the daemon blind to the row for the rest of its life. The rebind replays the
3351
+ * bucket's current last-per-subject state, so a change that landed during the null window is
3352
+ * delivered on the fresh watch. Resolves a stop handle. */
3353
+ async watchDeliveryLease(shardIndex, onEvent) {
3354
+ this.leaseWatchIntent = { shardIndex, onEvent };
3355
+ await this.bindDeliveryLeaseWatch();
3356
+ return () => {
3357
+ if (this.leaseWatchIntent?.onEvent !== onEvent)
3358
+ return; // a later watch owns the slot
3359
+ this.leaseWatchIntent = undefined;
3360
+ this.stopDeliveryLeaseWatch();
3361
+ };
3362
+ }
3363
+ stopDeliveryLeaseWatch() {
3364
+ try {
3365
+ this.leaseWatchIter?.stop();
3366
+ }
3367
+ catch { /* already closed with its connection */ }
3368
+ this.leaseWatchIter = undefined;
3369
+ }
3370
+ async bindDeliveryLeaseWatch() {
3371
+ const intent = this.leaseWatchIntent;
3372
+ const iter = await (await this.deliveryRegistry()).watch({ key: leaseKey(intent.shardIndex) });
3373
+ if (this.leaseWatchIntent !== intent) {
3374
+ try {
3375
+ iter.stop();
3376
+ }
3377
+ catch { /* already closed */ }
3378
+ return;
3379
+ }
3380
+ this.leaseWatchIter = iter;
3381
+ void (async () => {
3382
+ for await (const e of iter) {
3383
+ if (this.leaseWatchIter !== iter)
3384
+ break;
3385
+ if (e.operation === "DEL" || e.operation === "PURGE") {
3386
+ intent.onEvent(undefined);
3387
+ continue;
3388
+ }
3389
+ try {
3390
+ intent.onEvent(e.json());
3391
+ }
3392
+ catch {
3393
+ intent.onEvent(undefined);
3394
+ }
3395
+ }
3396
+ })().catch((e) => this.emit("error", e));
3397
+ }
3286
3398
  /** The lease row AND the KV revision it is at. The revision is the CAS token every renew and the
3287
3399
  * CAS release are argued against, so a caller re-establishing ownership after a failed renew
3288
3400
  * needs the BROKER's sequence, not the one it last cached: a renew can fail with its write
@@ -5148,6 +5260,8 @@ export class CotalEndpoint extends EventEmitter {
5148
5260
  // omitted only where the endpoint has none (a pure operator/daemon connection never registers).
5149
5261
  ...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
5150
5262
  status: this.status,
5263
+ condition: this.condition,
5264
+ environment: this.environment,
5151
5265
  activity: this.activity,
5152
5266
  attention: this.attentionMode,
5153
5267
  channelModes: this.channelModes,
@@ -5164,6 +5278,12 @@ export class CotalEndpoint extends EventEmitter {
5164
5278
  // it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
5165
5279
  // refusal on the connection that just published successfully.
5166
5280
  const epoch = this.presenceEpoch;
5281
+ const conditionRevision = this.conditionRevision;
5282
+ // #1461: snapshot the put's generation BEFORE the put. A settle (success or rejection) is the
5283
+ // latest evidence on its epoch only while no put that started after it has ALREADY settled —
5284
+ // a newer put merely in flight is not evidence yet. Same-epoch puts overlap routinely
5285
+ // (heartbeat 2s against a ~5s JetStream put timeout), so "succeeded" alone is not "newest".
5286
+ const generation = ++this.presencePutGeneration;
5167
5287
  try {
5168
5288
  await this.kv.put(this.card.id, JSON.stringify(record));
5169
5289
  }
@@ -5173,21 +5293,39 @@ export class CotalEndpoint extends EventEmitter {
5173
5293
  // nothing else here notices. Record WHEN the refusals started, at the one site that knows the
5174
5294
  // failing write was a presence write; a caller cannot infer that from the generic `warning`
5175
5295
  // stream, which carries any recoverable error.
5176
- if (epoch === this.presenceEpoch && !this.stopped) {
5296
+ //
5297
+ // #1461: a rejection speaks when it is the newest SETTLED evidence — a newer put that has
5298
+ // merely started does not silence it, but a newer put that already settled (either way)
5299
+ // does.
5300
+ const superseded = (this.presencePutSettled ?? 0) > generation;
5301
+ this.presencePutSettled = Math.max(this.presencePutSettled ?? 0, generation);
5302
+ if (epoch === this.presenceEpoch && !this.stopped && !superseded) {
5177
5303
  this.presenceWriteFailingSince ??= Date.now();
5178
5304
  this.lastPresenceWriteError = e?.message ?? String(e);
5179
5305
  }
5180
5306
  throw e;
5181
5307
  }
5182
- // A late SUCCESS is the same hazard mirrored, and this fence answers only the cross-epoch half
5183
- // of it: a success belonging to a retired epoch cannot erase a refusal the current one
5184
- // established from its own evidence. It does NOT order puts within a single epoch, because it
5185
- // compares epoch identity rather than which put is the latest evidence, so an earlier put that
5186
- // succeeds late still clears a later put's refusal. Heartbeats run at 2s against a ~5s put
5187
- // timeout, so that overlap is routine rather than a corner, and the next failing put re-plants
5188
- // the record with a fresh `since`. Tracked in #1461, not repaired here.
5189
- if (epoch !== this.presenceEpoch || this.stopped)
5308
+ // A late SUCCESS cannot erase a refusal from newer evidence. Two fences, each answering half:
5309
+ // the epoch fence (#1356) keeps a put that outlives its connection from writing or erasing a
5310
+ // record that belongs to a later connection; the generation fence (#1461) orders puts WITHIN
5311
+ // one epoch by SETTLE order, not start order: a settle is the latest evidence only while no
5312
+ // put that started after it has already settled, whichever way that newer put settled. So an
5313
+ // earlier put settling AFTER a later put settled no longer speaks — success cannot clear a
5314
+ // newer refusal (#1461's original case), and a newer settle is not blocked by being merely
5315
+ // started (panel round 1). Without the fence, an earlier put succeeding late cleared a later
5316
+ // put's refusal while the bucket still refused writes (heartbeat 2s against a ~5s put timeout,
5317
+ // so the overlap is routine rather than a corner).
5318
+ const superseded = (this.presencePutSettled ?? 0) > generation;
5319
+ this.presencePutSettled = Math.max(this.presencePutSettled ?? 0, generation);
5320
+ if (epoch !== this.presenceEpoch || this.stopped || superseded)
5190
5321
  return;
5322
+ // Presence writes may overlap. If this put carried an older condition, repair the KV with the
5323
+ // latest local state before returning so a late failure publish cannot resurrect after a new
5324
+ // turn cleared it. The revision is condition-specific; unrelated heartbeat overlap is unchanged.
5325
+ if (conditionRevision !== this.conditionRevision) {
5326
+ await this.publishPresence();
5327
+ return;
5328
+ }
5191
5329
  this.clearPresenceWriteFailure();
5192
5330
  }
5193
5331
  /** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
@@ -5204,8 +5342,9 @@ export class CotalEndpoint extends EventEmitter {
5204
5342
  this.lastPresenceWriteError = undefined;
5205
5343
  }
5206
5344
  /** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
5207
- * last publish succeeded. Cleared by the first successful write, so a survived blip reads as
5208
- * healthy and only a SUSTAINED failure carries a duration.
5345
+ * last publish succeeded. Cleared by a successful write that is the latest evidence on its
5346
+ * epoch (#1461), so a survived blip reads as healthy and only a SUSTAINED failure carries a
5347
+ * duration.
5209
5348
  *
5210
5349
  * Presence writes are the CANARY, not the scope: the broker can disable JetStream account-wide
5211
5350
  * while the NATS connection stays up, so a caller must not read this as "only presence is
@@ -5440,8 +5579,11 @@ export class CotalEndpoint extends EventEmitter {
5440
5579
  // with its bucket key is forged or corrupt. Drop it rather than surface a spoofed roster identity.
5441
5580
  // The write-side scoping ($KV.<presenceBucket>.<own-id>) is the primary guard; this rejects a
5442
5581
  // mis-keyed record even if a broad writer slips one in under another agent's key.
5443
- if (raw.card?.id !== id)
5582
+ if (raw.card?.id !== id) {
5583
+ this.presenceBindingDrops++;
5584
+ 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
5585
  return;
5586
+ }
5445
5587
  const prev = this.roster.get(id);
5446
5588
  const stale = Date.now() - raw.ts > this.ttlMs;
5447
5589
  // A watch recovering from a stall replays the bucket. Those PUTs still carry the publisher's
@@ -5481,6 +5623,8 @@ export class CotalEndpoint extends EventEmitter {
5481
5623
  prev.lifecycleUid === p.lifecycleUid &&
5482
5624
  prev.status === p.status &&
5483
5625
  prev.activity === p.activity &&
5626
+ sameCondition(prev.condition, p.condition) &&
5627
+ prev.environment === p.environment &&
5484
5628
  prev.attention === p.attention &&
5485
5629
  sameChannelModes(prev.channelModes, p.channelModes)) {
5486
5630
  this.roster.set(id, p);
@@ -5592,7 +5736,8 @@ function kindFromParsed(kind) {
5592
5736
  * unparseable subject, or `from.id !== parsed.sender` (SPEC §5). This derives the remaining
5593
5737
  * routing tokens from the subject for rows that survived — it does not rewrite a mismatched
5594
5738
  * `from.id`. Live tails, channel backfill, and channel recall skip the mismatch; history
5595
- * does the same (#388). */
5739
+ * does the same (#388). The row's other fields pass through UNTOUCHED, so what the caller's
5740
+ * row type already verified stays verified (#1413). */
5596
5741
  function authenticatedMessage(msg, parsed) {
5597
5742
  if (parsed.kind === "chat")
5598
5743
  return authenticatedChannelMessage(msg, parsed.rest);
@@ -5627,6 +5772,8 @@ function historyMessageFromDelivery(m) {
5627
5772
  }
5628
5773
  if (!isHistoryDrainEnvelope(raw))
5629
5774
  return undefined;
5775
+ if (!isVerifiedHistoryRow(raw))
5776
+ return undefined;
5630
5777
  const parsed = parseSubject(m.subject);
5631
5778
  if (!parsed || !isPrincipalOwnerToken(parsed.owner))
5632
5779
  return undefined;
@@ -5634,17 +5781,59 @@ function historyMessageFromDelivery(m) {
5634
5781
  return undefined;
5635
5782
  return authenticatedMessage(raw, parsed);
5636
5783
  }
5784
+ /** The members `HistoryMessage` promises beyond the drain envelope (#1413): a full
5785
+ * `EndpointRef` (`from.name`, and `from.role` only when a string), a finite `ts`, a string
5786
+ * `space`, `parts` the drain could read, and the optional members in their promised shape
5787
+ * when present. Every field the PUBLIC row type promises is checked here, before the row is
5788
+ * returned. A row missing one is dropped, never returned with that member silently
5789
+ * `undefined` under the full type — a consumer reading `msg.ts`, `msg.space`, `msg.parts`
5790
+ * or `msg.from.name` must not be able to reach one that was never there. Nothing is invented
5791
+ * for an absent field.
5792
+ *
5793
+ * `parts` is held to READABLE, not to `isMessagePart`: every member must be an object with a
5794
+ * string `kind` (so `partsToText` can read `part.kind` without throwing), but a keyless
5795
+ * `{kind:"data"}` row from a pre-#1404 producer is not dropped — history must surface it.
5796
+ * `mentions`, `replyTo`, and `contextId` keep exactly their `CotalMessage` shapes when
5797
+ * present: an array of strings, a string, a string. */
5798
+ function isVerifiedHistoryRow(row) {
5799
+ if (typeof row.from.name !== "string")
5800
+ return false;
5801
+ if (row.from.role !== undefined && typeof row.from.role !== "string")
5802
+ return false;
5803
+ if (typeof row.ts !== "number" || !Number.isFinite(row.ts))
5804
+ return false;
5805
+ if (typeof row.space !== "string")
5806
+ return false;
5807
+ if (!Array.isArray(row.parts) || !row.parts.every(isReadableMessagePart))
5808
+ return false;
5809
+ if (row.mentions !== undefined &&
5810
+ (!Array.isArray(row.mentions) || !row.mentions.every((name) => typeof name === "string")))
5811
+ return false;
5812
+ if (row.replyTo !== undefined && typeof row.replyTo !== "string")
5813
+ return false;
5814
+ if (row.contextId !== undefined && typeof row.contextId !== "string")
5815
+ return false;
5816
+ return true;
5817
+ }
5818
+ /** A part `partsToText` can read without throwing: an object with a string `kind` (#1413).
5819
+ * Deliberately weaker than `isMessagePart` — a keyless `{kind:"data"}` part passes here —
5820
+ * because history must surface rows a pre-#1404 producer wrote, not drop them. */
5821
+ function isReadableMessagePart(value) {
5822
+ return isRecord(value) && typeof value.kind === "string";
5823
+ }
5637
5824
  /**
5638
5825
  * Narrow enough for the type checker and for fail-closed history: object envelope, usable `id`,
5639
5826
  * object `from`. That is what lets `from.id !== parsed.sender` run without throwing, and what
5640
5827
  * lets `authenticatedMessage` take the row without a cast.
5641
5828
  *
5642
5829
  * Does NOT verify SPEC §5 message shape. It does not require a string `from.id` (the SPEC §5
5643
- * comparison still rejects a mismatch), exactly one route key, a finite `ts`, a string
5644
- * `space`, a full EndpointRef `from` (`name`/`role`), or well-formed `parts`. Those belong
5645
- * to `isCotalMessage` (Plane-3). History must not use that guard: a public
5646
- * `unicast(..., { parts: [{ kind: "data", data: undefined }] })` serializes to `{kind:"data"}`
5647
- * and must still surface.
5830
+ * comparison still rejects a mismatch) or exactly one route key. The members the PUBLIC
5831
+ * history row promises beyond this envelope — a full EndpointRef `from` (`name`/`role`), a
5832
+ * finite `ts`, a string `space` — are checked by {@link isVerifiedHistoryRow} before the row
5833
+ * is returned (#1413). Well-formed `parts` still belong to `isCotalMessage` (Plane-3), and
5834
+ * history must not use that guard: a publisher now REFUSES a `data` part carrying a non-JSON
5835
+ * value, but a keyless `{kind:"data"}` row from a pre-fix producer can still sit in a stream,
5836
+ * and history must surface it rather than drop it.
5648
5837
  */
5649
5838
  function isHistoryDrainEnvelope(value) {
5650
5839
  return isRecord(value) && isUsableMessageId(value.id) && isRecord(value.from);
@@ -5702,6 +5891,85 @@ function isMessagePart(value) {
5702
5891
  function isRecord(value) {
5703
5892
  return !!value && typeof value === "object" && !Array.isArray(value);
5704
5893
  }
5894
+ /** A `data` part value the wire can carry faithfully: a JSON value at EVERY depth (SPEC §5's
5895
+ * `<any JSON value>`). `JSON.stringify` does not reject what it cannot represent — it silently
5896
+ * rewrites: `undefined` (and a function-valued member) drops the key, `NaN`/`Infinity` store as
5897
+ * `null`, a `Date` stores as a string, a sparse array's holes store as `null`, a `Map` stores as
5898
+ * `{}`, a `Buffer` stores as `{"type":"Buffer",...}`. A reader then cannot tell a stored `null`
5899
+ * from a real one (#1404 fix round). So the producer checks structurally, matching the exported
5900
+ * `JsonValue`: null, boolean, finite number, string, an array whose every slot (holes included)
5901
+ * passes, or a plain object (prototype `null` or `Object.prototype`) whose every defined member
5902
+ * passes. A cycle is refused here too, never left for stringify's TypeError.
5903
+ *
5904
+ * THE OBJECT/ARRAY ASYMMETRY ON `undefined`: an `undefined`-valued MEMBER of a plain object is
5905
+ * allowed — stringify drops just that key, which is faithful (and the exported type says so) —
5906
+ * while `undefined` at the top level or in an ARRAY SLOT is refused, because there stringify
5907
+ * cannot drop a position: it stores `null`, which is a rewrite a reader cannot distinguish from
5908
+ * a real `null`.
5909
+ *
5910
+ * `seen` is the set of ANCESTORS on the current path, not every object visited: it is deleted
5911
+ * from on the way out, so a SHARED subtree (`{a: x, b: x}`) passes — stringify carries it twice,
5912
+ * which is faithful — while an object that contains itself at any depth still reports its path. */
5913
+ function jsonPathProblem(path, value, seen) {
5914
+ if (value === null)
5915
+ return undefined;
5916
+ const t = typeof value;
5917
+ if (t === "boolean" || t === "string")
5918
+ return undefined;
5919
+ if (t === "number")
5920
+ return Number.isFinite(value) ? undefined : `${path} is not a finite number`;
5921
+ if (t === "undefined" || t === "function" || t === "symbol" || t === "bigint")
5922
+ return `${path} is not a JSON value`;
5923
+ if (typeof value === "object") {
5924
+ if (seen.has(value))
5925
+ return `${path} is cyclic`;
5926
+ seen.add(value);
5927
+ let problem;
5928
+ if (Array.isArray(value)) {
5929
+ for (let i = 0; i < value.length; i++) {
5930
+ problem = jsonPathProblem(`${path}[${i}]`, value[i], seen);
5931
+ if (problem)
5932
+ return problem;
5933
+ }
5934
+ }
5935
+ else {
5936
+ const proto = Object.getPrototypeOf(value);
5937
+ if (proto !== null && proto !== Object.prototype) {
5938
+ seen.delete(value);
5939
+ return `${path} is not a plain object`;
5940
+ }
5941
+ for (const key of Object.keys(value)) {
5942
+ // A member whose value is undefined is the allowed key-drop, not a defect: stringify omits
5943
+ // the key, the object stays a JSON object, so skip it rather than walk it.
5944
+ if (value[key] === undefined)
5945
+ continue;
5946
+ problem = jsonPathProblem(`${path}.${key}`, value[key], seen);
5947
+ if (problem)
5948
+ return problem;
5949
+ }
5950
+ }
5951
+ seen.delete(value);
5952
+ return undefined;
5953
+ }
5954
+ return `${path} is not a JSON value`;
5955
+ }
5956
+ /** Refuse, at publish, a `data` part `JSON.stringify` would silently rewrite. The guard is the
5957
+ * runtime half of `Part`'s `data: JsonValue` arm: with it, a non-JSON value at any depth never
5958
+ * reaches the wire, so every reader (live core-sub, Plane-3 durable, history) gives one answer —
5959
+ * the message was never sent — instead of the old split where history returned a rewritten row
5960
+ * while Plane-3 terminated a keyless one as malformed. The error names the path to the offending
5961
+ * value (e.g. `data[2].at`), so a caller learns which member to fix rather than that stringify
5962
+ * would have mangled something. Throws rather than coercing: no fallback. */
5963
+ function assertPartsSerializable(parts) {
5964
+ for (const p of parts) {
5965
+ if (p.kind !== "data")
5966
+ continue;
5967
+ const problem = jsonPathProblem("data", p.data, new Set());
5968
+ if (problem !== undefined) {
5969
+ throw new Error(`cannot publish a data part carrying a non-JSON value (SPEC §5) - ${problem}`);
5970
+ }
5971
+ }
5972
+ }
5705
5973
  /** Shallow-equal two per-channel-mode maps (presence dedup): a change must re-emit, so an attention
5706
5974
  * toggle isn't swallowed as a quiet heartbeat. Absent and empty compare equal. */
5707
5975
  function sameChannelModes(a, b) {
@@ -5711,6 +5979,12 @@ function sameChannelModes(a, b) {
5711
5979
  return false;
5712
5980
  return ak.every((k) => a[k] === b?.[k]);
5713
5981
  }
5982
+ function sameCondition(a, b) {
5983
+ return a?.code === b?.code
5984
+ && a?.source === b?.source
5985
+ && a?.message === b?.message
5986
+ && a?.since === b?.since;
5987
+ }
5714
5988
  function authOpts(a) {
5715
5989
  const tls = a.tls ? {} : undefined;
5716
5990
  // USER MODE: present the shared auth-account sentinel creds AND the user bearer as `auth_token` (an