@cotal-ai/core 0.15.0 → 0.17.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 (146) hide show
  1. package/dist/acls.d.ts +21 -0
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +75 -12
  4. package/dist/acls.js.map +1 -1
  5. package/dist/artifact.d.ts +45 -0
  6. package/dist/artifact.d.ts.map +1 -0
  7. package/dist/artifact.js +82 -0
  8. package/dist/artifact.js.map +1 -0
  9. package/dist/backup-config.d.ts +9 -2
  10. package/dist/backup-config.d.ts.map +1 -1
  11. package/dist/backup-config.js +6 -2
  12. package/dist/backup-config.js.map +1 -1
  13. package/dist/broker-tls.d.ts +70 -0
  14. package/dist/broker-tls.d.ts.map +1 -0
  15. package/dist/broker-tls.js +195 -0
  16. package/dist/broker-tls.js.map +1 -0
  17. package/dist/canonical.d.ts +14 -1
  18. package/dist/canonical.d.ts.map +1 -1
  19. package/dist/canonical.js +21 -1
  20. package/dist/canonical.js.map +1 -1
  21. package/dist/channels.d.ts.map +1 -1
  22. package/dist/channels.js +18 -9
  23. package/dist/channels.js.map +1 -1
  24. package/dist/endpoint-action.d.ts +108 -3
  25. package/dist/endpoint-action.d.ts.map +1 -1
  26. package/dist/endpoint-action.js +209 -20
  27. package/dist/endpoint-action.js.map +1 -1
  28. package/dist/endpoint-binding.d.ts +138 -8
  29. package/dist/endpoint-binding.d.ts.map +1 -1
  30. package/dist/endpoint-binding.js +233 -26
  31. package/dist/endpoint-binding.js.map +1 -1
  32. package/dist/endpoint-contract-store.d.ts +24 -1
  33. package/dist/endpoint-contract-store.d.ts.map +1 -1
  34. package/dist/endpoint-contract-store.js +79 -27
  35. package/dist/endpoint-contract-store.js.map +1 -1
  36. package/dist/endpoint-envelope.d.ts +15 -37
  37. package/dist/endpoint-envelope.d.ts.map +1 -1
  38. package/dist/endpoint-envelope.js +93 -44
  39. package/dist/endpoint-envelope.js.map +1 -1
  40. package/dist/endpoint-error.d.ts +33 -0
  41. package/dist/endpoint-error.d.ts.map +1 -0
  42. package/dist/endpoint-error.js +34 -0
  43. package/dist/endpoint-error.js.map +1 -0
  44. package/dist/endpoint-grants.d.ts +48 -7
  45. package/dist/endpoint-grants.d.ts.map +1 -1
  46. package/dist/endpoint-grants.js +97 -9
  47. package/dist/endpoint-grants.js.map +1 -1
  48. package/dist/endpoint-invoke.d.ts +138 -0
  49. package/dist/endpoint-invoke.d.ts.map +1 -0
  50. package/dist/endpoint-invoke.js +340 -0
  51. package/dist/endpoint-invoke.js.map +1 -0
  52. package/dist/endpoint-records.d.ts.map +1 -1
  53. package/dist/endpoint-records.js +17 -0
  54. package/dist/endpoint-records.js.map +1 -1
  55. package/dist/endpoint-serve-kv.d.ts +97 -0
  56. package/dist/endpoint-serve-kv.d.ts.map +1 -0
  57. package/dist/endpoint-serve-kv.js +336 -0
  58. package/dist/endpoint-serve-kv.js.map +1 -0
  59. package/dist/endpoint-serve.d.ts.map +1 -1
  60. package/dist/endpoint-serve.js +21 -1
  61. package/dist/endpoint-serve.js.map +1 -1
  62. package/dist/endpoint-service.d.ts +1 -1
  63. package/dist/endpoint-service.d.ts.map +1 -1
  64. package/dist/endpoint-service.js +34 -10
  65. package/dist/endpoint-service.js.map +1 -1
  66. package/dist/endpoint-session-rail.d.ts +127 -0
  67. package/dist/endpoint-session-rail.d.ts.map +1 -0
  68. package/dist/endpoint-session-rail.js +325 -0
  69. package/dist/endpoint-session-rail.js.map +1 -0
  70. package/dist/endpoint-session.d.ts +1 -115
  71. package/dist/endpoint-session.d.ts.map +1 -1
  72. package/dist/endpoint-session.js +4 -316
  73. package/dist/endpoint-session.js.map +1 -1
  74. package/dist/endpoint-subjects.d.ts +10 -1
  75. package/dist/endpoint-subjects.d.ts.map +1 -1
  76. package/dist/endpoint-subjects.js +10 -1
  77. package/dist/endpoint-subjects.js.map +1 -1
  78. package/dist/endpoint-verbs.d.ts +1 -2
  79. package/dist/endpoint-verbs.d.ts.map +1 -1
  80. package/dist/endpoint-verbs.js +6 -6
  81. package/dist/endpoint-verbs.js.map +1 -1
  82. package/dist/endpoint.d.ts +169 -15
  83. package/dist/endpoint.d.ts.map +1 -1
  84. package/dist/endpoint.js +491 -43
  85. package/dist/endpoint.js.map +1 -1
  86. package/dist/identity.d.ts +19 -0
  87. package/dist/identity.d.ts.map +1 -1
  88. package/dist/identity.js +12 -0
  89. package/dist/identity.js.map +1 -1
  90. package/dist/index.d.ts +9 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +9 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/kv-scan.d.ts +105 -0
  95. package/dist/kv-scan.d.ts.map +1 -0
  96. package/dist/kv-scan.js +192 -0
  97. package/dist/kv-scan.js.map +1 -0
  98. package/dist/lease.d.ts +12 -5
  99. package/dist/lease.d.ts.map +1 -1
  100. package/dist/lease.js.map +1 -1
  101. package/dist/lifecycle-saga.d.ts +174 -0
  102. package/dist/lifecycle-saga.d.ts.map +1 -0
  103. package/dist/lifecycle-saga.js +418 -0
  104. package/dist/lifecycle-saga.js.map +1 -0
  105. package/dist/lifecycle-state.d.ts +202 -0
  106. package/dist/lifecycle-state.d.ts.map +1 -0
  107. package/dist/lifecycle-state.js +347 -0
  108. package/dist/lifecycle-state.js.map +1 -0
  109. package/dist/members.d.ts +8 -4
  110. package/dist/members.d.ts.map +1 -1
  111. package/dist/members.js +19 -9
  112. package/dist/members.js.map +1 -1
  113. package/dist/parts.d.ts +13 -0
  114. package/dist/parts.d.ts.map +1 -0
  115. package/dist/parts.js +17 -0
  116. package/dist/parts.js.map +1 -0
  117. package/dist/provision.d.ts +106 -9
  118. package/dist/provision.d.ts.map +1 -1
  119. package/dist/provision.js +506 -110
  120. package/dist/provision.js.map +1 -1
  121. package/dist/schema-profile.d.ts +3 -2
  122. package/dist/schema-profile.d.ts.map +1 -1
  123. package/dist/schema-profile.js +242 -18
  124. package/dist/schema-profile.js.map +1 -1
  125. package/dist/session-browser.d.ts +15 -0
  126. package/dist/session-browser.d.ts.map +1 -0
  127. package/dist/session-browser.js +14 -0
  128. package/dist/session-browser.js.map +1 -0
  129. package/dist/session-terminal-frames.d.ts +36 -0
  130. package/dist/session-terminal-frames.d.ts.map +1 -0
  131. package/dist/session-terminal-frames.js +149 -0
  132. package/dist/session-terminal-frames.js.map +1 -0
  133. package/dist/spaces.d.ts.map +1 -1
  134. package/dist/spaces.js +6 -1
  135. package/dist/spaces.js.map +1 -1
  136. package/dist/streams.d.ts +53 -1
  137. package/dist/streams.d.ts.map +1 -1
  138. package/dist/streams.js +135 -8
  139. package/dist/streams.js.map +1 -1
  140. package/dist/subjects.d.ts +32 -26
  141. package/dist/subjects.d.ts.map +1 -1
  142. package/dist/subjects.js +38 -25
  143. package/dist/subjects.js.map +1 -1
  144. package/dist/types.d.ts +27 -1
  145. package/dist/types.d.ts.map +1 -1
  146. package/package.json +6 -1
package/dist/endpoint.js CHANGED
@@ -4,15 +4,19 @@ import { createConnection } from "node:net";
4
4
  import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, AuthorizationError, PermissionViolationError, UserAuthenticationExpiredError, NoRespondersError, RequestError, } from "@nats-io/transport-node";
5
5
  import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
6
6
  import { inspectCredHealth } from "./provision.js";
7
+ import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
8
+ import { EpEnvelopeError } from "./endpoint-envelope.js";
9
+ import { liveKvEntries } from "./kv-scan.js";
10
+ import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
7
11
  import { assertValidName } from "./resolve.js";
8
12
  import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
9
13
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
10
14
  import { Kvm } from "@nats-io/kv";
11
15
  import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
12
- import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord } from "./acls.js";
16
+ import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
13
17
  import { openDeliveryRegistry } from "./lease.js";
14
18
  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, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
19
+ import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, managerLeaseKey, 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
20
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
17
21
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
18
22
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -33,6 +37,33 @@ export const DEFAULT_SPACE = "main";
33
37
  * (unknown owner) or DELIVER transfer is `term()`d + surfaced after this many redeliveries, so one
34
38
  * stuck/poison entry can't head-of-line the single shared reader forever. */
35
39
  const READER_MAX_REDELIVERIES = 10;
40
+ /** Page size for the mediated history read when the caller names none — matches `channelHistory`'s
41
+ * own default, since the mediated read exists to be a drop-in for it. */
42
+ const READ_HISTORY_DEFAULT_LIMIT = 100;
43
+ /** Hard server-side ceiling on one mediated page. The caller PROPOSES a limit and the mediator
44
+ * decides: the reader is pooled and privileged, so an unbounded caller-chosen limit would let one
45
+ * request pull a channel's whole retained set through it. Clamped, not refused — a UI asking for
46
+ * more than a page should get a page, and `complete: false` already tells it more remains. */
47
+ const READ_HISTORY_MAX_LIMIT = 200;
48
+ /** The NEWEST prefix-from-the-end of `items` whose serialized size fits `budget` bytes, order
49
+ * preserved. Returns `[]` when not even the newest single message fits — the caller must refuse
50
+ * loudly there rather than serve an empty page, which would read as "no history".
51
+ *
52
+ * Measured in ENCODED bytes, not `string.length`: a page of multi-byte text would otherwise be
53
+ * undercounted and still overflow the broker. Same discipline as `assertFactFits`. */
54
+ export function fitHistoryPage(items, budget) {
55
+ const enc = new TextEncoder();
56
+ let used = 2; // the enclosing `[]`
57
+ let first = items.length; // index of the oldest kept item
58
+ for (let i = items.length - 1; i >= 0; i--) {
59
+ const size = enc.encode(JSON.stringify(items[i])).length + 1; // + the `,` separator
60
+ if (used + size > budget)
61
+ break;
62
+ used += size;
63
+ first = i;
64
+ }
65
+ return first === items.length ? [] : items.slice(first);
66
+ }
36
67
  export class CotalEndpoint extends EventEmitter {
37
68
  card;
38
69
  space;
@@ -170,6 +201,14 @@ export class CotalEndpoint extends EventEmitter {
170
201
  actor;
171
202
  /** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
172
203
  ownLifecycleUid;
204
+ /** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
205
+ * `failed-precondition` currency refusal (the described incarnation was superseded). */
206
+ resolvedServices = new Map();
207
+ /** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
208
+ * pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
209
+ get principal() {
210
+ return { owner: this.owner, actor: this.actor };
211
+ }
173
212
  /** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
174
213
  * ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
175
214
  requireLifecycleUid(what) {
@@ -1021,6 +1060,55 @@ export class CotalEndpoint extends EventEmitter {
1021
1060
  const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
1022
1061
  return m.json();
1023
1062
  }
1063
+ /** This endpoint's v0.4 caller triple (§13.2) — the identity its minted ep-rail rows pin. The
1064
+ * owner/actor principal is mode-correct by construction (static: DEV_OWNER + the connection
1065
+ * identity; user mode: the bearer's callout-derived pair — 1c.2c), and the lifecycle UID is the
1066
+ * launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
1067
+ * lifecycle-proof refuses a divergent uid before any publish). */
1068
+ serviceCaller() {
1069
+ return { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
1070
+ }
1071
+ /** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
1072
+ * the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
1073
+ * 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}. */
1079
+ async invokeService(endpoint, command, args, opts = {}) {
1080
+ if (!this.nc)
1081
+ throw new Error(this.notLiveMsg());
1082
+ const nc = this.nc;
1083
+ const caller = this.serviceCaller();
1084
+ const resolve = async () => {
1085
+ const cached = this.resolvedServices.get(endpoint);
1086
+ if (cached)
1087
+ return cached;
1088
+ const svc = await resolveService(nc, this.space, endpoint, caller, { deadlineMs: opts.deadlineMs ?? 10_000 });
1089
+ this.resolvedServices.set(endpoint, svc);
1090
+ return svc;
1091
+ };
1092
+ const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
1093
+ const doInvoke = async () => {
1094
+ try {
1095
+ return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
1096
+ }
1097
+ catch (e) {
1098
+ if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
1099
+ 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
+ this.resolvedServices.delete(endpoint);
1103
+ return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
1104
+ }
1105
+ };
1106
+ // P2 item 2 (2b): a goal-bearing command (spawn/launch) follows its acceptance to the terminal so
1107
+ // the caller still returns on the real outcome (UX unchanged); every other command replies directly.
1108
+ if (!opts.follow)
1109
+ return doInvoke();
1110
+ return submitAndFollowGoal(nc, this.space, endpoint, caller, opts.deadlineMs ?? 10_000, doInvoke);
1111
+ }
1024
1112
  /** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
1025
1113
  * reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
1026
1114
  * per-id `_INBOX`), so the scoped delivery cred can answer without broad inbox-publish — see
@@ -1329,11 +1417,12 @@ export class CotalEndpoint extends EventEmitter {
1329
1417
  const kv = await this.membershipFeedRegistry();
1330
1418
  const members = [];
1331
1419
  let asOf;
1332
- for await (const key of await kv.keys()) {
1333
- const e = await kv.get(key);
1334
- if (!e || e.operation === "DEL" || e.operation === "PURGE")
1335
- continue;
1336
- if (key === MEMBERSHIP_FEED_KEY) {
1420
+ // ONE pass. This was `kv.keys()` followed by a sequential `kv.get()` per key — O(N) round trips,
1421
+ // measured at 30-34s for 89 entries against a mesh at 534ms RTT. `liveKvEntries` is ~3 round
1422
+ // trips regardless of N, and (unlike the old loop) refuses to return a truncated view rather
1423
+ // than reporting a partial roster as the whole one.
1424
+ for (const e of await liveKvEntries(kv)) {
1425
+ if (e.key === MEMBERSHIP_FEED_KEY) {
1337
1426
  try {
1338
1427
  asOf = e.json().observedAt;
1339
1428
  }
@@ -1342,7 +1431,7 @@ export class CotalEndpoint extends EventEmitter {
1342
1431
  }
1343
1432
  try {
1344
1433
  const rec = e.json();
1345
- members.push({ id: key, live: rec.live ?? [], durable: rec.durable ?? [], observedAt: rec.observedAt });
1434
+ members.push({ id: e.key, live: rec.live ?? [], durable: rec.durable ?? [], observedAt: rec.observedAt });
1346
1435
  }
1347
1436
  catch { /* skip undecodable */ }
1348
1437
  }
@@ -1366,6 +1455,45 @@ export class CotalEndpoint extends EventEmitter {
1366
1455
  // history from any sender
1367
1456
  return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
1368
1457
  }
1458
+ /** Read a channel's recent history THROUGH THE DELIVERY DAEMON instead of through a consumer this
1459
+ * connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
1460
+ * consumer / `DIRECT.GET` / `STREAM.MSG.GET` for an untrusted holder; the trusted reader serves it
1461
+ * onto the caller's own confined rail). `items` is shape-identical to what {@link channelHistory}
1462
+ * returns — the same `CotalMessage[]`, the same newest-N selection, the same oldest-first order
1463
+ * within the page — so a caller migrates by reading `.items` and nothing else changes. The return
1464
+ * is WRAPPED rather than bare precisely because of `complete`: a bare array cannot say whether
1465
+ * older history remains behind it.
1466
+ *
1467
+ * **Why this exists when `channelHistory` already works:** authorization. A consumer pins its
1468
+ * authorization at CREATE time, so a caller whose read ACL is revoked mid-scroll keeps being served
1469
+ * by the consumer it already holds. The mediator re-reads authorization on EVERY call (live
1470
+ * registry row ∩ mint-time ceiling — SPEC §9.6), so a revocation stops the very next read and a
1471
+ * registry-only widen cannot exceed the effective credential. That is the whole point of the verb;
1472
+ * a mediator that cached the ACL would be a rename of the path it replaces.
1473
+ *
1474
+ * **The caller never names itself.** The daemon takes the principal from the broker-authenticated
1475
+ * request subject ({@link serveControl} fail-closes when the payload `from` disagrees), so there is
1476
+ * no caller-supplied identity to forge.
1477
+ *
1478
+ * A channel outside the caller's read ACL THROWS. It must never come back as an empty page: "you
1479
+ * may not read this" and "there is nothing here" are different answers and only one is safe to
1480
+ * render as an empty conversation.
1481
+ *
1482
+ * Chat channels only — DM history is deliberately not served here (it is god-view-only today, a
1483
+ * different authorization model, and one handler with two authz paths is the wrong shape on the
1484
+ * surface where a mistake exposes private messages). */
1485
+ async readHistory(channel, opts) {
1486
+ const reply = await this.requestDelivery("readHistory", { channel, limit: opts?.limit });
1487
+ if (!reply.ok)
1488
+ throw new Error(reply.error ?? "readHistory failed");
1489
+ const data = reply.data;
1490
+ // Validate rather than coerce. A malformed reply must not be massaged into a plausible page:
1491
+ // defaulting `complete` would invent the very signal a caller uses to decide whether it is
1492
+ // looking at the start of a conversation.
1493
+ if (!Array.isArray(data?.items) || typeof data?.complete !== "boolean")
1494
+ throw new Error("readHistory: the delivery daemon returned a malformed page (expected { items, complete })");
1495
+ return { items: data.items, complete: data.complete };
1496
+ }
1369
1497
  /** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
1370
1498
  * a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
1371
1499
  * skips for them — only an `admin`-profile cred can read it. */
@@ -1373,34 +1501,175 @@ export class CotalEndpoint extends EventEmitter {
1373
1501
  // every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
1374
1502
  return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100);
1375
1503
  }
1376
- /** Drain up to `limit` recent messages matching `subject` from a stream's backlog via a
1377
- * throwaway consumer. Fetches exactly the pending count (from consumer info) so it returns
1378
- * the moment the backlog is delivered — a plain `fetch({max_messages: limit})` would instead
1379
- * block for the pull's full expiry (~30s) whenever the backlog is smaller than `limit`. */
1380
- async streamHistory(stream, subject, limit) {
1504
+ /**
1505
+ * The `limit` MOST RECENT messages matching `subject`, oldest-first within the page.
1506
+ *
1507
+ * **This used to return the OLDEST N.** `js.consumers.get(stream, {...})` builds an ORDERED
1508
+ * consumer, which defaults to `DeliverPolicy.StartSequence` with `opt_start_seq: 1` — the very
1509
+ * beginning of the stream. Capping the fetch at `limit` therefore took the first N messages ever
1510
+ * sent, while this method is documented as "recent" and every caller (the dashboard feed, the
1511
+ * agent-facing history tools) presents the result as the latest. Confirmed live against a
1512
+ * 123-message channel: `limit=10` returned the ten oldest, not the ten newest.
1513
+ *
1514
+ * Fixing it by draining from the start and keeping the tail would be correct and ruinous: it
1515
+ * transfers the entire backlog to show one screen. Instead, find the newest matching sequence and
1516
+ * consume a WINDOW ending there, widening geometrically until the window holds `limit` matches.
1517
+ * A filtered subject's sequences are non-contiguous (other channels interleave in the same
1518
+ * stream), so the window cannot be computed arithmetically. A FAILED attempt holds fewer than a
1519
+ * page by definition, so wasted transfer stays page-sized and geometric growth keeps the number of
1520
+ * attempts logarithmic. The one unbounded case is named in the body: a channel whose matches are
1521
+ * all old and sparse walks back to the start of the stream and reads its whole retained set.
1522
+ *
1523
+ * `before` pages toward the past: pass the `seq` of the oldest message you already have.
1524
+ */
1525
+ async streamHistory(stream, subject, limit, before) {
1381
1526
  if (!this.nc)
1382
1527
  throw new Error("endpoint not started");
1528
+ if (limit <= 0)
1529
+ return [];
1383
1530
  const js = jetstream(this.nc);
1384
- const msgs = [];
1385
1531
  try {
1386
- const consumer = await js.consumers.get(stream, { filter_subjects: [subject] });
1387
- const pending = Math.min(limit, (await consumer.info()).num_pending);
1532
+ // THE EXACT CEILING, on the already-granted surface. A one-shot consumer with
1533
+ // `DeliverPolicy.Last` plus this subject's filter reports the newest MATCHING sequence, and its
1534
+ // `num_pending` of zero means the channel is genuinely empty. Using the STREAM's last sequence
1535
+ // instead (which is all `STREAM.INFO` offers) was a loose upper bound, and on a quiet channel
1536
+ // in a busy stream the gap between the two is the whole problem: every window near the stream
1537
+ // head is empty, so the search widened over and over before finding anything.
1538
+ //
1539
+ // Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
1540
+ // needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
1541
+ // shipped once from this function and turned every non-admin history read into an empty list.
1542
+ const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject);
1543
+ if (ceiling < 1)
1544
+ return [];
1545
+ // Widen from the exact ceiling until a window holds a full page, or until the window IS the
1546
+ // whole subject. Draining each attempt is bounded, and that is the point: a window only fails
1547
+ // when it holds FEWER than `limit` matches, so every wasted drain moves less than one page.
1548
+ // Geometric growth keeps the number of attempts logarithmic, so total wasted transfer is a
1549
+ // small multiple of a page.
1550
+ //
1551
+ // NAMED POLICY for the remaining case: when a channel's matches are all old and sparse, the
1552
+ // search walks back to sequence 1 and the final drain transfers that subject's whole retained
1553
+ // set. That is chosen deliberately — a FULL page of genuinely recent messages, at the cost of
1554
+ // an unbounded read on a channel that has not been used in a long time — over returning a
1555
+ // short page while older messages exist. The exact ceiling above means this is now reached
1556
+ // only by real sparsity WITHIN a channel, never by the channel simply being quiet lately.
1557
+ let span = Math.max(limit * 4, 64);
1558
+ for (;;) {
1559
+ const start = Math.max(1, ceiling - span + 1);
1560
+ const page = await this.drainWindow(js, stream, subject, start, ceiling);
1561
+ if (page.length >= limit || start === 1)
1562
+ return page.slice(-limit);
1563
+ span *= 4;
1564
+ }
1565
+ }
1566
+ catch (e) {
1567
+ // NARROW. This catch is how the MSG.GET grant bug shipped: it turned a Permissions Violation
1568
+ // into an empty history, so every non-admin read looked exactly like a quiet channel, and the
1569
+ // smokes stayed green because they run as admin against a local broker. Emitting on the error
1570
+ // event is not enough either — callers consume the RETURN VALUE, and the dashboard renders
1571
+ // empty regardless of what an operator log says.
1572
+ //
1573
+ // So only two things may still produce an empty result:
1574
+ // 1. The stream does not exist (a space with no history yet).
1575
+ // 2. A permission denial on the DM backlog specifically. `dmHistory` is god-view by
1576
+ // contract: a normal agent's ACL denies it, and returning empty there is documented
1577
+ // behaviour, not a bug being hidden.
1578
+ // Anything else — a denial on CHAT, a timeout, a 503, a protocol or consumer-create failure —
1579
+ // is raised, because "no history" and "I could not read the history" are different answers and
1580
+ // only one of them is safe to render as an empty conversation.
1581
+ const msg = String(e?.message ?? "");
1582
+ if (/stream not found/i.test(msg) || e?.code === 404)
1583
+ return [];
1584
+ if (isPermissionDenied(e) && stream === dmStream(this.space))
1585
+ return [];
1586
+ throw e;
1587
+ }
1588
+ }
1589
+ /** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
1590
+ *
1591
+ * One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
1592
+ * (available from the create, before anything is delivered) is 0 for an empty subject, and
1593
+ * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
1594
+ * already uses, so no broker authority is added. */
1595
+ async lastMatchingSeq(js, stream, subject) {
1596
+ const consumer = await js.consumers.get(stream, {
1597
+ filter_subjects: [subject],
1598
+ deliver_policy: DeliverPolicy.Last,
1599
+ });
1600
+ try {
1601
+ // Bind-time zero is the ONLY thing that means "this subject has no messages".
1602
+ if ((await consumer.info(true)).num_pending === 0)
1603
+ return 0;
1604
+ const iter = await consumer.fetch({ max_messages: 1 });
1605
+ for await (const m of iter)
1606
+ return m.seq;
1607
+ // Bind said a message was pending and none arrived. The pinned client's pull iterator ends
1608
+ // CLEANLY when the connection closes ("we don't propagate the error here"), so this is what a
1609
+ // dropped link looks like from here. Returning 0 would make the caller report an empty
1610
+ // channel, which is the same "could not read means no history" lie the narrowed catch above
1611
+ // exists to stop.
1612
+ throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
1613
+ }
1614
+ finally {
1615
+ await consumer.delete().catch(() => { });
1616
+ }
1617
+ }
1618
+ /** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
1619
+ * One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
1620
+ * round trip. Fetches exactly the pending count so it returns as soon as the window is
1621
+ * delivered rather than blocking for the pull's full expiry. */
1622
+ async drainWindow(js, stream, subject, start, ceiling) {
1623
+ const out = [];
1624
+ const consumer = await js.consumers.get(stream, { filter_subjects: [subject], opt_start_seq: start });
1625
+ try {
1626
+ // A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
1627
+ // explicit uncached `info()` this used to call was a round trip for data we already had.
1628
+ const pending = (await consumer.info(true)).num_pending;
1388
1629
  if (pending === 0)
1389
- return msgs;
1630
+ return out;
1390
1631
  const iter = await consumer.fetch({ max_messages: pending });
1632
+ // PROVE THE WINDOW COMPLETED. The pull iterator ends cleanly on a dropped connection, so a
1633
+ // close after three of ten deliveries would otherwise return a convincing three-message page.
1634
+ // The window is done when we have reached its upper bound or consumed everything bind said
1635
+ // was pending; anything else is a cut-short read and must say so.
1636
+ let delivered = 0;
1637
+ let complete = false;
1391
1638
  for await (const m of iter) {
1639
+ delivered++;
1640
+ if (m.seq >= ceiling) { // reached the page's upper bound
1641
+ if (m.seq === ceiling) {
1642
+ try {
1643
+ out.push(m.json());
1644
+ }
1645
+ catch { /* skip undecodable */ }
1646
+ }
1647
+ complete = true;
1648
+ break;
1649
+ }
1392
1650
  try {
1393
- msgs.push(m.json());
1651
+ out.push(m.json());
1394
1652
  }
1395
1653
  catch {
1396
1654
  /* skip undecodable */
1397
1655
  }
1656
+ if (delivered >= pending) {
1657
+ complete = true;
1658
+ break;
1659
+ }
1398
1660
  }
1661
+ if (!complete)
1662
+ throw new Error(`history: read ${delivered} of ${pending} messages on ${subject} before the stream ended early - the window was cut short, not empty`);
1663
+ return out;
1399
1664
  }
1400
- catch {
1401
- /* stream missing or consumer create denied (non-admin) */
1665
+ finally {
1666
+ // DELETE THE EPHEMERAL CONSUMER. The pinned client gives an ordered consumer a 5-minute
1667
+ // inactive threshold, so leaving them behind is not free: the widening search below can make
1668
+ // up to eight per call, the dashboard makes one call per channel, and a reload repeats it.
1669
+ // Left alone that accumulates consumers on the broker until the thresholds expire, and the
1670
+ // resulting resource exhaustion would land in streamHistory's catch and read as empty history.
1671
+ await consumer.delete().catch(() => { });
1402
1672
  }
1403
- return msgs;
1404
1673
  }
1405
1674
  // ---- internals -----------------------------------------------------------
1406
1675
  /**
@@ -1557,6 +1826,14 @@ export class CotalEndpoint extends EventEmitter {
1557
1826
  async commitAcl(targetId, lifecycleUid, allowSubscribe) {
1558
1827
  await writeAclRecord(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
1559
1828
  }
1829
+ /**
1830
+ * Raise the mint-time ACL ceiling. Provision/remint only — see {@link reissueAcl}.
1831
+ * Process discipline: call only in the same act that bakes `allowSubscribe` into the JWT; the
1832
+ * write is not crypto-bound to credential bytes.
1833
+ */
1834
+ async reissueAcl(targetId, lifecycleUid, allowSubscribe) {
1835
+ await writeAclReissue(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
1836
+ }
1560
1837
  /** The server-side delivery daemon's fresh-per-entry ACL read: one LIFECYCLE's current read ACL
1561
1838
  * (`allowSubscribe`) from the durable registry (exact key `<owner>.<actor>.<uid>`), or `undefined`
1562
1839
  * if no record (an unknown lifecycle — the reader DEFERS, never drops). A present `[]` (known
@@ -1570,7 +1847,13 @@ export class CotalEndpoint extends EventEmitter {
1570
1847
  * authorize the successor (SPEC 13.1: at most one live lifecycle per alias). */
1571
1848
  async aclForAlias(principal) {
1572
1849
  const row = await readAclForAlias(await this.aclRegistry(), principal);
1573
- return row === undefined ? undefined : { allowSubscribe: row.record.allowSubscribe, lifecycleUid: row.lifecycleUid };
1850
+ if (row === undefined)
1851
+ return undefined;
1852
+ const allow = row.record.allowSubscribe;
1853
+ // Legacy rows predate the ceiling field: treat missing as equal to allowSubscribe so behaviour
1854
+ // matches what that row already exposed. New rows always carry an explicit ceiling.
1855
+ const issued = row.record.issuedAllowSubscribe ?? allow;
1856
+ return { allowSubscribe: allow, issuedAllowSubscribe: issued, lifecycleUid: row.lifecycleUid };
1574
1857
  }
1575
1858
  /** Lazily open the delivery lease/readiness KV (pre-created at `cotal up`; bind, never create). */
1576
1859
  async deliveryRegistry() {
@@ -1655,44 +1938,97 @@ export class CotalEndpoint extends EventEmitter {
1655
1938
  encodeManagerLease(info) {
1656
1939
  return new TextEncoder().encode(JSON.stringify(info));
1657
1940
  }
1658
- /** Acquire the singleton manager lease via ATOMIC CAS create. THROWS if a live lease exists (a loud
1659
- * refusal-to-start, never a retry) so two managers never split control. A crashed holder's lease
1660
- * auto-expires (bucket TTL). Returns the lease revision (for renew). */
1941
+ /** Acquire THIS logical instance's liveness lease via ATOMIC CAS create on its own per-instance key
1942
+ * ({@link managerLeaseKey}). THROWS only if that SAME instance id already holds a live key (a same-root
1943
+ * concurrent double-start, or a restart racing the crashed predecessor's not-yet-expired key) — a loud
1944
+ * refusal, never a retry. A DIFFERENT instance (second workspace root ⇒ different id) creates its OWN
1945
+ * key and coexists (P2 item 3 demotion). A crashed holder's key auto-expires (bucket TTL). Returns the
1946
+ * lease revision (for renew). */
1661
1947
  async acquireManagerLease(info) {
1662
- return (await this.managerLeaseRegistry()).create(MANAGER_LEASE_KEY, this.encodeManagerLease({ ...info, since: Date.now() }));
1948
+ return (await this.managerLeaseRegistry()).create(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }));
1663
1949
  }
1664
- /** Renew the held lease (CAS update against `revision`) before the bucket TTL expires it. Throws if the
1665
- * revision moved (lost the lease). Returns the new revision. */
1950
+ /** Renew THIS instance's held key (CAS update against `revision`) before the bucket TTL expires it.
1951
+ * Throws if the revision moved (lost the lease). Returns the new revision. */
1666
1952
  async renewManagerLease(info, revision) {
1667
- return (await this.managerLeaseRegistry()).update(MANAGER_LEASE_KEY, this.encodeManagerLease({ ...info, since: Date.now() }), revision);
1953
+ return (await this.managerLeaseRegistry()).update(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }), revision);
1668
1954
  }
1669
- /** Release the held lease on clean shutdown so a replacement manager acquires immediately. CAS-guarded
1670
- * by `revision`: if we already LOST the lease (renew gap / another manager took over) the stored
1671
- * revision has moved, the conditional delete no-ops, and we never delete the replacement's live lease. */
1672
- async releaseManagerLease(revision) {
1955
+ /** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
1956
+ * by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
1957
+ * no-ops. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
1958
+ async releaseManagerLease(instanceId, revision) {
1673
1959
  try {
1674
1960
  const kv = await this.managerLeaseRegistry();
1675
1961
  if (revision === undefined)
1676
- await kv.delete(MANAGER_LEASE_KEY);
1962
+ await kv.delete(managerLeaseKey(instanceId));
1677
1963
  else
1678
- await kv.delete(MANAGER_LEASE_KEY, { previousSeq: revision });
1964
+ await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
1679
1965
  }
1680
1966
  catch { /* not ours / already gone */ }
1681
1967
  }
1682
- /** Read the live manager lease, or undefined if none (bucket absent / key deleted/expired). Open-only —
1683
- * never creates the bucket, so a probe that finds no manager leaves none behind. */
1968
+ /** Read a live manager liveness lease, or undefined if NONE (no manager instance holds the space). A
1969
+ * presence/existence check for the CLI's `spawn -f` reuse and `waitLeaseGone`, which only need "is any
1970
+ * manager here". Open-only — never creates the bucket, so a probe that finds no manager leaves none
1971
+ * behind. (Instance-precise enumeration for the class scatter comes from the registration records KV
1972
+ * in 3b-4, not this liveness bucket.)
1973
+ *
1974
+ * MULTI-INSTANCE EXACT, and it has to be read that way rather than as a point get. Several managers
1975
+ * may hold one space, each renewing its own `lease.<instanceId>`. A single `last_by_subj` over
1976
+ * `lease.*` returns the newest message under the wildcard REGARDLESS OF KEY — so a stopping peer's
1977
+ * DEL tombstone, being newest, answered "no manager here" while a sibling was alive and renewing.
1978
+ * Only an explicit `kv.delete` writes that tombstone: a manager whose lease TTL-expires is removed by
1979
+ * limits and leaves nothing behind, so the poisoning case was the ORDINARY one (stop a manager
1980
+ * cleanly, then `spawn -f`), not the crash. Enumerating live entries collapses to the greatest
1981
+ * revision PER KEY and drops keys whose final state is a marker, so a peer's DEL can only retire that
1982
+ * peer's own key and can never mask a live one. */
1684
1983
  async readManagerLease() {
1685
1984
  if (!this.nc)
1686
1985
  return undefined;
1687
1986
  try {
1688
- const kv = await new Kvm(this.nc).open(managerBucket(this.space));
1689
- const e = await kv.get(MANAGER_LEASE_KEY);
1690
- if (!e || e.operation === "DEL" || e.operation === "PURGE")
1987
+ const jsm = this.jsm ?? (this.jsm = await jetstreamManager(this.nc));
1988
+ const stream = `KV_${managerBucket(this.space)}`;
1989
+ const prefix = `$KV.${managerBucket(this.space)}.${MANAGER_LEASE_KEY}.`;
1990
+ // STREAM.INFO to learn WHICH instance keys exist, then one point-get per key. NOT a bucket
1991
+ // scan: `liveKvEntries` binds a push consumer, and this principal's grant on this bucket is
1992
+ // STREAM.INFO + STREAM.MSG.GET + the `lease.*` publish only (`provision.ts`, supervisor). A
1993
+ // consumer here is a permissions violation for the very callers this probe serves, and no
1994
+ // open-mesh test can see that, because an open mesh has no permissions to violate.
1995
+ const info = await jsm.streams.info(stream, { subjects_filter: `${prefix}*` });
1996
+ let newest;
1997
+ for (const subject of Object.keys(info.state.subjects ?? {})) {
1998
+ // `last_by_subj` is CORRECT PER KEY and wrong across keys. Scoped to one instance's subject
1999
+ // it returns that instance's latest state, so a DEL here retires only its own key. The
2000
+ // defect was asking one wildcard for the newest message in the whole subtree, where a
2001
+ // stopping peer's tombstone outranks a live sibling's older PUT.
2002
+ const m = await jsm.streams.getMessage(stream, { last_by_subj: subject }).catch(() => null);
2003
+ if (m === null)
2004
+ continue; // key vanished between INFO and GET: it is not a live holder
2005
+ const op = m.header?.get("KV-Operation");
2006
+ if (op === "DEL" || op === "PURGE" || m.data.length === 0)
2007
+ continue;
2008
+ if (newest === undefined || m.seq > newest.seq)
2009
+ newest = { data: m.data, seq: m.seq };
2010
+ }
2011
+ if (newest === undefined)
1691
2012
  return undefined;
1692
- return e.json();
2013
+ return JSON.parse(new TextDecoder().decode(newest.data));
1693
2014
  }
1694
- catch {
1695
- return undefined;
2015
+ catch (e) {
2016
+ // ABSENCE MUST BE PROVEN, NOT INFERRED FROM A FAILED READ. Exactly two outcomes mean "genuinely
2017
+ // no manager": 10037, no message on the subtree, and a missing bucket — this probe is open-only
2018
+ // and on an authed mesh nothing creates it until a manager first takes a lease, so a space that
2019
+ // has never run one has no stream to read. Every OTHER failure (a JetStream hiccup, a request
2020
+ // timeout, a permissions refusal, or `liveKvEntries` refusing a pass that died mid-delivery) used
2021
+ // to return undefined here as well, and every caller reads undefined as "the space is empty": the
2022
+ // CLI's `spawn -f` reuse stands a SECOND manager up against a live one and `waitLeaseGone` reports
2023
+ // the space free. A read that failed is not evidence of absence, so refuse loudly rather than
2024
+ // degrade (AGENTS.md: no fallbacks). This is the wider of the two doors the point-get fix closes:
2025
+ // it needs only a transient error, where the tombstone path needed a peer to stop.
2026
+ const code = e.code;
2027
+ if (code === 10037)
2028
+ return undefined;
2029
+ if (code === 404 || /stream not found/i.test(e?.message ?? ""))
2030
+ return undefined;
2031
+ throw e;
1696
2032
  }
1697
2033
  }
1698
2034
  /** Privileged: one owner's NON-TOMBSTONED durable memberships as `{channel, generation, activated}` —
@@ -1918,6 +2254,8 @@ export class CotalEndpoint extends EventEmitter {
1918
2254
  return this.deliveryJoin(caller, args);
1919
2255
  if (req.op === "durableLeave")
1920
2256
  return this.deliveryLeave(caller, args);
2257
+ if (req.op === "readHistory")
2258
+ return this.deliveryReadHistory(caller, args);
1921
2259
  if (req.op === "listMemberships") {
1922
2260
  if (typeof args.lifecycleUid !== "string")
1923
2261
  return { ok: false, error: "listMemberships: the caller's lifecycleUid is required (membership rows are lifecycle-keyed, SPEC 13.1)" };
@@ -2014,6 +2352,107 @@ export class CotalEndpoint extends EventEmitter {
2014
2352
  }
2015
2353
  return { ok: true, data: { channel } };
2016
2354
  }
2355
+ /** Serve one MEDIATED HISTORY READ (`readHistory`, the client side is {@link readHistory}). The daemon
2356
+ * holds the consumer so the caller does not have to: this is a privilege reduction, not a new
2357
+ * capability, and it is the shape SPEC's "Mediated reads (normative)" rule asks for.
2358
+ *
2359
+ * THE ONE INVARIANT THIS METHOD EXISTS FOR: authorization is read FRESH on every call and never
2360
+ * cached across calls. A consumer pins its authorization when it is created, so a revoked caller
2361
+ * keeps being served by a consumer it already holds; re-reading here is what makes a revocation
2362
+ * stop the very NEXT read. Cache this and the verb loses its only advantage over the raw consumer
2363
+ * path.
2364
+ *
2365
+ * AUTHORITY (SPEC §9.6): "current read ACL" is the effective broker-accepted credential. The
2366
+ * durable registry is a live mirror of that credential, not a second grant source. History
2367
+ * authorizes against `allowSubscribe ∩ issuedAllowSubscribe` — the live row (so revocation via
2368
+ * plain commitAcl still stops the next read) intersected with the mint-time ceiling (so a
2369
+ * registry widen without a remint cannot grant what the JWT does not). Raising the ceiling is
2370
+ * {@link reissueAcl}, which is what provision does when it bakes the list into the JWT.
2371
+ *
2372
+ * The caller arrives as an alias (the control subject carries owner+actor, never a uid) and is the
2373
+ * AUTHENTICATED subject sender — `serveControl` has already fail-closed on any payload that names a
2374
+ * different principal, so `caller` cannot be self-asserted. */
2375
+ async deliveryReadHistory(caller, args) {
2376
+ const channel = this.checkDurableChannelArg(args, "readHistory");
2377
+ if (typeof channel !== "string")
2378
+ return channel; // a ControlReply error
2379
+ // The caller PROPOSES a limit; the mediator decides. Reject a nonsense limit loudly rather than
2380
+ // silently substituting a default — a caller asking for -1 has a bug and should hear about it.
2381
+ const asked = args.limit === undefined ? READ_HISTORY_DEFAULT_LIMIT : args.limit;
2382
+ if (typeof asked !== "number" || !Number.isInteger(asked) || asked < 1)
2383
+ return { ok: false, error: `readHistory: limit must be a positive integer (got ${JSON.stringify(args.limit)})` };
2384
+ const limit = Math.min(asked, READ_HISTORY_MAX_LIMIT);
2385
+ // FRESH per call — see the method doc. Same registry, same alias resolution, and the same loud
2386
+ // refusal on two live rows that `durableJoin` uses: a stale lifecycle must never authorize its
2387
+ // successor's read.
2388
+ //
2389
+ // SPEC §9.6: "current read ACL" is the effective broker-accepted credential. The registry row is
2390
+ // a live mirror and can be rewritten by plain commitAcl without a remint. Authorizing history
2391
+ // from the row ALONE let a widen grant reads channelHistory still broker-denies — a new
2392
+ // capability wearing a privilege-reduction label (panel BLOCKING at 914fd7b0). History therefore
2393
+ // requires the channel in BOTH the live row AND the mint-time ceiling (`issuedAllowSubscribe`).
2394
+ // Revocation still works: narrowing allowSubscribe stops the next read. Raising the ceiling
2395
+ // requires reissueAcl (provision/remint), which is the same act that bakes the list into the JWT.
2396
+ let acl;
2397
+ try {
2398
+ acl = await this.aclForAlias(caller);
2399
+ }
2400
+ catch (e) {
2401
+ return { ok: false, error: e.message };
2402
+ }
2403
+ if (acl === undefined)
2404
+ return { ok: false, error: `readHistory: no read ACL on record for ${caller} - not permitted` };
2405
+ const inLive = channelInAllow(acl.allowSubscribe, channel);
2406
+ const inIssued = channelInAllow(acl.issuedAllowSubscribe, channel);
2407
+ if (!inLive || !inIssued) {
2408
+ // Name the live list in the error — that is what operators edit and what revocation clears.
2409
+ // A ceiling miss (registry widened past the credential) is the same refusal shape as a live
2410
+ // miss so a caller cannot probe which half failed.
2411
+ return {
2412
+ ok: false,
2413
+ error: `readHistory: channel "${channel}" is not within your read ACL [${acl.allowSubscribe.join(", ")}] - refused`,
2414
+ };
2415
+ }
2416
+ // One message MORE than the page is the completeness probe: if the backlog hands back limit+1,
2417
+ // something older exists behind the page and `complete` is false. Cheaper and more honest than a
2418
+ // separate count, which could race the page it describes.
2419
+ let page;
2420
+ try {
2421
+ page = await this.channelHistory(channel, { limit: limit + 1 });
2422
+ }
2423
+ catch (e) {
2424
+ // Surface the read failure. `streamHistory` deliberately raises rather than returning empty on a
2425
+ // denial or a cut-short window, and that distinction must survive the rail: an error here would
2426
+ // otherwise reach the caller as an empty page and render as "no history".
2427
+ return { ok: false, error: `readHistory: ${e.message}` };
2428
+ }
2429
+ const reachedStart = page.length <= limit;
2430
+ const wanted = reachedStart ? page : page.slice(-limit);
2431
+ // A page that cannot be SENT is not a page. The reply rides one NATS message, so `limit` alone is
2432
+ // the wrong bound: 200 large messages serialize past `max_payload`, `m.respond` throws inside
2433
+ // `serveControl`'s swallow, and the caller sees a bare request timeout it cannot tell apart from a
2434
+ // dead daemon. Measured, not predicted: 200 x ~6 KB timed out at 5s with the message "timeout".
2435
+ //
2436
+ // So bound by BYTES too, keeping the NEWEST that fit — which needs no new vocabulary, because
2437
+ // `complete: false` already means "older history remains behind this page". Trimming here is the
2438
+ // documented truncation signal doing its job, not a silent degradation.
2439
+ const fitted = fitHistoryPage(wanted, this.payloadBudget());
2440
+ // `wanted.length > 0` is load-bearing, not defensive: an EMPTY channel also fits nothing, and
2441
+ // without this guard a channel nobody has posted to was refused with "the newest message exceeds
2442
+ // the payload budget" — a confident, entirely wrong explanation for a legitimately empty result.
2443
+ // Genuine emptiness is `{ items: [], complete: true }`; only a message too large to ever send is
2444
+ // the error.
2445
+ if (wanted.length > 0 && fitted.length === 0)
2446
+ return { ok: false, error: `readHistory: the newest message on "${channel}" alone exceeds the broker payload budget (${this.payloadBudget()} bytes) - refused loudly rather than served as an empty page` };
2447
+ return { ok: true, data: { items: fitted, complete: reachedStart && fitted.length === wanted.length } };
2448
+ }
2449
+ /** Bytes a control reply may occupy: the broker's `max_payload` less headroom for the `ControlReply`
2450
+ * envelope wrapped around the items. Read from the live server info rather than assumed, since an
2451
+ * operator can raise or lower it. */
2452
+ payloadBudget() {
2453
+ const max = this.nc?.info?.max_payload ?? 1_048_576;
2454
+ return Math.max(1, Math.floor(max * 0.9));
2455
+ }
2017
2456
  /** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
2018
2457
  * cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
2019
2458
  * the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
@@ -3247,6 +3686,13 @@ function isEndpointRef(value) {
3247
3686
  return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" &&
3248
3687
  (value.role === undefined || typeof value.role === "string");
3249
3688
  }
3689
+ /** A conformant message part: the three CORE kinds, or a reverse-DNS extension kind (SPEC §5).
3690
+ *
3691
+ * Reached from {@link isCotalMessage}, which gates the Plane-3 delivery frame — so a core kind
3692
+ * missing an arm here is not a schema nicety: the durable backstop DROPS every message carrying
3693
+ * it, silently, and the drop surfaces nowhere near the feature that added the part. `artifact`
3694
+ * was exactly that case before it was added (a bare kind has no dot, so it fell through to the
3695
+ * extension regex and failed). */
3250
3696
  function isMessagePart(value) {
3251
3697
  if (!isRecord(value) || typeof value.kind !== "string")
3252
3698
  return false;
@@ -3254,6 +3700,8 @@ function isMessagePart(value) {
3254
3700
  return typeof value.text === "string";
3255
3701
  if (value.kind === "data")
3256
3702
  return Object.prototype.hasOwnProperty.call(value, "data");
3703
+ if (value.kind === ARTIFACT_PART_KIND)
3704
+ return isArtifactPart(value);
3257
3705
  return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value.kind);
3258
3706
  }
3259
3707
  function isRecord(value) {