@cotal-ai/core 0.16.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 (141) hide show
  1. package/dist/acls.d.ts +21 -0
  2. package/dist/acls.d.ts.map +1 -1
  3. package/dist/acls.js +44 -1
  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.js +4 -4
  22. package/dist/channels.js.map +1 -1
  23. package/dist/endpoint-action.d.ts +108 -3
  24. package/dist/endpoint-action.d.ts.map +1 -1
  25. package/dist/endpoint-action.js +209 -20
  26. package/dist/endpoint-action.js.map +1 -1
  27. package/dist/endpoint-binding.d.ts +138 -8
  28. package/dist/endpoint-binding.d.ts.map +1 -1
  29. package/dist/endpoint-binding.js +233 -26
  30. package/dist/endpoint-binding.js.map +1 -1
  31. package/dist/endpoint-contract-store.d.ts +24 -1
  32. package/dist/endpoint-contract-store.d.ts.map +1 -1
  33. package/dist/endpoint-contract-store.js +79 -27
  34. package/dist/endpoint-contract-store.js.map +1 -1
  35. package/dist/endpoint-envelope.d.ts +15 -37
  36. package/dist/endpoint-envelope.d.ts.map +1 -1
  37. package/dist/endpoint-envelope.js +93 -44
  38. package/dist/endpoint-envelope.js.map +1 -1
  39. package/dist/endpoint-error.d.ts +33 -0
  40. package/dist/endpoint-error.d.ts.map +1 -0
  41. package/dist/endpoint-error.js +34 -0
  42. package/dist/endpoint-error.js.map +1 -0
  43. package/dist/endpoint-grants.d.ts +48 -7
  44. package/dist/endpoint-grants.d.ts.map +1 -1
  45. package/dist/endpoint-grants.js +97 -9
  46. package/dist/endpoint-grants.js.map +1 -1
  47. package/dist/endpoint-invoke.d.ts +138 -0
  48. package/dist/endpoint-invoke.d.ts.map +1 -0
  49. package/dist/endpoint-invoke.js +340 -0
  50. package/dist/endpoint-invoke.js.map +1 -0
  51. package/dist/endpoint-records.d.ts.map +1 -1
  52. package/dist/endpoint-records.js +17 -0
  53. package/dist/endpoint-records.js.map +1 -1
  54. package/dist/endpoint-serve-kv.d.ts +97 -0
  55. package/dist/endpoint-serve-kv.d.ts.map +1 -0
  56. package/dist/endpoint-serve-kv.js +336 -0
  57. package/dist/endpoint-serve-kv.js.map +1 -0
  58. package/dist/endpoint-serve.d.ts.map +1 -1
  59. package/dist/endpoint-serve.js +21 -1
  60. package/dist/endpoint-serve.js.map +1 -1
  61. package/dist/endpoint-service.d.ts +1 -1
  62. package/dist/endpoint-service.d.ts.map +1 -1
  63. package/dist/endpoint-service.js +34 -10
  64. package/dist/endpoint-service.js.map +1 -1
  65. package/dist/endpoint-session-rail.d.ts +127 -0
  66. package/dist/endpoint-session-rail.d.ts.map +1 -0
  67. package/dist/endpoint-session-rail.js +325 -0
  68. package/dist/endpoint-session-rail.js.map +1 -0
  69. package/dist/endpoint-session.d.ts +1 -115
  70. package/dist/endpoint-session.d.ts.map +1 -1
  71. package/dist/endpoint-session.js +4 -316
  72. package/dist/endpoint-session.js.map +1 -1
  73. package/dist/endpoint-subjects.d.ts +10 -1
  74. package/dist/endpoint-subjects.d.ts.map +1 -1
  75. package/dist/endpoint-subjects.js +10 -1
  76. package/dist/endpoint-subjects.js.map +1 -1
  77. package/dist/endpoint-verbs.d.ts +1 -2
  78. package/dist/endpoint-verbs.d.ts.map +1 -1
  79. package/dist/endpoint-verbs.js +6 -6
  80. package/dist/endpoint-verbs.js.map +1 -1
  81. package/dist/endpoint.d.ts +136 -11
  82. package/dist/endpoint.d.ts.map +1 -1
  83. package/dist/endpoint.js +329 -24
  84. package/dist/endpoint.js.map +1 -1
  85. package/dist/identity.d.ts +19 -0
  86. package/dist/identity.d.ts.map +1 -1
  87. package/dist/identity.js +12 -0
  88. package/dist/identity.js.map +1 -1
  89. package/dist/index.d.ts +8 -0
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +8 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/kv-scan.d.ts +8 -0
  94. package/dist/kv-scan.d.ts.map +1 -1
  95. package/dist/kv-scan.js +8 -0
  96. package/dist/kv-scan.js.map +1 -1
  97. package/dist/lease.d.ts +12 -5
  98. package/dist/lease.d.ts.map +1 -1
  99. package/dist/lease.js.map +1 -1
  100. package/dist/lifecycle-saga.d.ts +174 -0
  101. package/dist/lifecycle-saga.d.ts.map +1 -0
  102. package/dist/lifecycle-saga.js +418 -0
  103. package/dist/lifecycle-saga.js.map +1 -0
  104. package/dist/lifecycle-state.d.ts +202 -0
  105. package/dist/lifecycle-state.d.ts.map +1 -0
  106. package/dist/lifecycle-state.js +347 -0
  107. package/dist/lifecycle-state.js.map +1 -0
  108. package/dist/parts.d.ts +13 -0
  109. package/dist/parts.d.ts.map +1 -0
  110. package/dist/parts.js +17 -0
  111. package/dist/parts.js.map +1 -0
  112. package/dist/provision.d.ts +106 -9
  113. package/dist/provision.d.ts.map +1 -1
  114. package/dist/provision.js +506 -110
  115. package/dist/provision.js.map +1 -1
  116. package/dist/schema-profile.d.ts +3 -2
  117. package/dist/schema-profile.d.ts.map +1 -1
  118. package/dist/schema-profile.js +242 -18
  119. package/dist/schema-profile.js.map +1 -1
  120. package/dist/session-browser.d.ts +15 -0
  121. package/dist/session-browser.d.ts.map +1 -0
  122. package/dist/session-browser.js +14 -0
  123. package/dist/session-browser.js.map +1 -0
  124. package/dist/session-terminal-frames.d.ts +36 -0
  125. package/dist/session-terminal-frames.d.ts.map +1 -0
  126. package/dist/session-terminal-frames.js +149 -0
  127. package/dist/session-terminal-frames.js.map +1 -0
  128. package/dist/spaces.d.ts.map +1 -1
  129. package/dist/spaces.js +6 -1
  130. package/dist/spaces.js.map +1 -1
  131. package/dist/streams.d.ts +53 -1
  132. package/dist/streams.d.ts.map +1 -1
  133. package/dist/streams.js +135 -8
  134. package/dist/streams.js.map +1 -1
  135. package/dist/subjects.d.ts +32 -26
  136. package/dist/subjects.d.ts.map +1 -1
  137. package/dist/subjects.js +38 -25
  138. package/dist/subjects.js.map +1 -1
  139. package/dist/types.d.ts +27 -1
  140. package/dist/types.d.ts.map +1 -1
  141. package/package.json +6 -1
package/dist/endpoint.js CHANGED
@@ -4,16 +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";
7
9
  import { liveKvEntries } from "./kv-scan.js";
10
+ import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
8
11
  import { assertValidName } from "./resolve.js";
9
12
  import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
10
13
  import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
11
14
  import { Kvm } from "@nats-io/kv";
12
15
  import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
13
- 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";
14
17
  import { openDeliveryRegistry } from "./lease.js";
15
18
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
16
- 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";
17
20
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
18
21
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
19
22
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -34,6 +37,33 @@ export const DEFAULT_SPACE = "main";
34
37
  * (unknown owner) or DELIVER transfer is `term()`d + surfaced after this many redeliveries, so one
35
38
  * stuck/poison entry can't head-of-line the single shared reader forever. */
36
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
+ }
37
67
  export class CotalEndpoint extends EventEmitter {
38
68
  card;
39
69
  space;
@@ -171,6 +201,14 @@ export class CotalEndpoint extends EventEmitter {
171
201
  actor;
172
202
  /** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
173
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
+ }
174
212
  /** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
175
213
  * ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
176
214
  requireLifecycleUid(what) {
@@ -1022,6 +1060,55 @@ export class CotalEndpoint extends EventEmitter {
1022
1060
  const m = await this.nc.request(reqSubject, JSON.stringify(body), { timeout: timeoutMs, noMux: true, reply });
1023
1061
  return m.json();
1024
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
+ }
1025
1112
  /** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
1026
1113
  * reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
1027
1114
  * per-id `_INBOX`), so the scoped delivery cred can answer without broad inbox-publish — see
@@ -1368,6 +1455,45 @@ export class CotalEndpoint extends EventEmitter {
1368
1455
  // history from any sender
1369
1456
  return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
1370
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
+ }
1371
1497
  /** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. God-view only:
1372
1498
  * a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
1373
1499
  * skips for them — only an `admin`-profile cred can read it. */
@@ -1700,6 +1826,14 @@ export class CotalEndpoint extends EventEmitter {
1700
1826
  async commitAcl(targetId, lifecycleUid, allowSubscribe) {
1701
1827
  await writeAclRecord(await this.aclRegistry(), targetId, lifecycleUid, allowSubscribe);
1702
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
+ }
1703
1837
  /** The server-side delivery daemon's fresh-per-entry ACL read: one LIFECYCLE's current read ACL
1704
1838
  * (`allowSubscribe`) from the durable registry (exact key `<owner>.<actor>.<uid>`), or `undefined`
1705
1839
  * if no record (an unknown lifecycle — the reader DEFERS, never drops). A present `[]` (known
@@ -1713,7 +1847,13 @@ export class CotalEndpoint extends EventEmitter {
1713
1847
  * authorize the successor (SPEC 13.1: at most one live lifecycle per alias). */
1714
1848
  async aclForAlias(principal) {
1715
1849
  const row = await readAclForAlias(await this.aclRegistry(), principal);
1716
- 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 };
1717
1857
  }
1718
1858
  /** Lazily open the delivery lease/readiness KV (pre-created at `cotal up`; bind, never create). */
1719
1859
  async deliveryRegistry() {
@@ -1798,44 +1938,97 @@ export class CotalEndpoint extends EventEmitter {
1798
1938
  encodeManagerLease(info) {
1799
1939
  return new TextEncoder().encode(JSON.stringify(info));
1800
1940
  }
1801
- /** Acquire the singleton manager lease via ATOMIC CAS create. THROWS if a live lease exists (a loud
1802
- * refusal-to-start, never a retry) so two managers never split control. A crashed holder's lease
1803
- * 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). */
1804
1947
  async acquireManagerLease(info) {
1805
- 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() }));
1806
1949
  }
1807
- /** Renew the held lease (CAS update against `revision`) before the bucket TTL expires it. Throws if the
1808
- * 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. */
1809
1952
  async renewManagerLease(info, revision) {
1810
- 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);
1811
1954
  }
1812
- /** Release the held lease on clean shutdown so a replacement manager acquires immediately. CAS-guarded
1813
- * by `revision`: if we already LOST the lease (renew gap / another manager took over) the stored
1814
- * revision has moved, the conditional delete no-ops, and we never delete the replacement's live lease. */
1815
- 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) {
1816
1959
  try {
1817
1960
  const kv = await this.managerLeaseRegistry();
1818
1961
  if (revision === undefined)
1819
- await kv.delete(MANAGER_LEASE_KEY);
1962
+ await kv.delete(managerLeaseKey(instanceId));
1820
1963
  else
1821
- await kv.delete(MANAGER_LEASE_KEY, { previousSeq: revision });
1964
+ await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
1822
1965
  }
1823
1966
  catch { /* not ours / already gone */ }
1824
1967
  }
1825
- /** Read the live manager lease, or undefined if none (bucket absent / key deleted/expired). Open-only —
1826
- * 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. */
1827
1983
  async readManagerLease() {
1828
1984
  if (!this.nc)
1829
1985
  return undefined;
1830
1986
  try {
1831
- const kv = await new Kvm(this.nc).open(managerBucket(this.space));
1832
- const e = await kv.get(MANAGER_LEASE_KEY);
1833
- 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)
1834
2012
  return undefined;
1835
- return e.json();
2013
+ return JSON.parse(new TextDecoder().decode(newest.data));
1836
2014
  }
1837
- catch {
1838
- 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;
1839
2032
  }
1840
2033
  }
1841
2034
  /** Privileged: one owner's NON-TOMBSTONED durable memberships as `{channel, generation, activated}` —
@@ -2061,6 +2254,8 @@ export class CotalEndpoint extends EventEmitter {
2061
2254
  return this.deliveryJoin(caller, args);
2062
2255
  if (req.op === "durableLeave")
2063
2256
  return this.deliveryLeave(caller, args);
2257
+ if (req.op === "readHistory")
2258
+ return this.deliveryReadHistory(caller, args);
2064
2259
  if (req.op === "listMemberships") {
2065
2260
  if (typeof args.lifecycleUid !== "string")
2066
2261
  return { ok: false, error: "listMemberships: the caller's lifecycleUid is required (membership rows are lifecycle-keyed, SPEC 13.1)" };
@@ -2157,6 +2352,107 @@ export class CotalEndpoint extends EventEmitter {
2157
2352
  }
2158
2353
  return { ok: true, data: { channel } };
2159
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
+ }
2160
2456
  /** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
2161
2457
  * cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
2162
2458
  * the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
@@ -3390,6 +3686,13 @@ function isEndpointRef(value) {
3390
3686
  return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" &&
3391
3687
  (value.role === undefined || typeof value.role === "string");
3392
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). */
3393
3696
  function isMessagePart(value) {
3394
3697
  if (!isRecord(value) || typeof value.kind !== "string")
3395
3698
  return false;
@@ -3397,6 +3700,8 @@ function isMessagePart(value) {
3397
3700
  return typeof value.text === "string";
3398
3701
  if (value.kind === "data")
3399
3702
  return Object.prototype.hasOwnProperty.call(value, "data");
3703
+ if (value.kind === ARTIFACT_PART_KIND)
3704
+ return isArtifactPart(value);
3400
3705
  return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(value.kind);
3401
3706
  }
3402
3707
  function isRecord(value) {